Compare commits

...
44 Commits
Author SHA1 Message Date
Wyatt Johnson 1b73a5fcd7 [CORL-976] Story Closed Fix (#2903)
* fix: fixed issue where `isStoryClosed` can return null

* chore: version bump
2020-03-23 18:02:45 +00:00
Wyatt Johnson 1503b735e5 fix: set SameSite: None for cookies (#2890) 2020-03-19 19:16:50 +00:00
Wyatt Johnson 6db72db837 [CORL-963] Story sorting fix (#2887)
* fix: fixed sorting issues with queries

* review: naming touchups
2020-03-19 18:59:34 +00:00
Wyatt Johnson 18b274a6a9 [CORL-972] Version Linter (#2898)
* fix: added version linting

* chore: bump 6.0.2
2020-03-19 18:44:56 +00:00
Wyatt Johnson 78cfb4cac8 fix: added more checks to migration (#2894) 2020-03-18 20:53:16 +00:00
Wyatt Johnson d513e3807a chore: bump 6.0.0 (#2891) 2020-03-16 15:47:09 +00:00
VinhandWyatt Johnson faf4ad9480 [ADR] Use Relay over Apollo (#2881)
* chore: ADR on Relay and Apollo

* chore: typo

* chore: add neg cons

* chore: add another pos

* chore: remove neg, because apollo needed an introspection query as a build step too..

* chore: fix number

* chore: deco

* chore: typo

* chore: clarification

Co-authored-by: Wyatt Johnson <wyattjoh@gmail.com>
2020-03-13 21:20:24 +00:00
Tessa ThorntonandWyatt Johnson e5ea1ee179 [CORL-959] pass siteID through to approved and rejected route queries (#2884)
* pass siteID through to approved and rejected route queries

* Fix tests

Co-authored-by: Wyatt Johnson <wyattjoh@gmail.com>
2020-03-12 22:19:26 +00:00
Tessa ThorntonandWyatt Johnson 75c0bad073 use getModerationLink helper in moderateSearchBar (#2885)
Co-authored-by: Wyatt Johnson <wyattjoh@gmail.com>
2020-03-12 18:03:07 +00:00
Nick FunkandWyatt Johnson 8b92878e9b Disable featuring options when a story is in Q&A mode (#2883)
CORL-960

Co-authored-by: Wyatt Johnson <wyattjoh@gmail.com>
2020-03-12 17:48:27 +00:00
Wyatt Johnson 2b2ff7b9c4 [CORL-965] Experimental Notice on Webhooks (#2880)
* fix: added experimental callout message to webhooks

* fix: copy/design review
2020-03-12 17:03:27 +00:00
Wyatt Johnson 2057b750f3 fix: changed copy (#2879) 2020-03-12 16:15:45 +00:00
Wyatt JohnsonandKim Gardner 7abd703f72 fix: addressed issues with cache headers (#2873)
Co-authored-by: Kim Gardner <kgardnr@gmail.com>
2020-03-10 16:46:44 -04:00
Nick Funk 6f1014289b Add flag to optionally enable Q&A (#2878)
CORL-956
2020-03-10 20:22:26 +00:00
Wyatt JohnsonandKim Gardner 45b778c522 feat: expanded regexp generation, locale support, caching (#2869)
Co-authored-by: Kim Gardner <kgardnr@gmail.com>
2020-03-10 12:05:44 -04:00
Nick Funk 7d967fc93b [CORL-933] Add additional error handling around logQuery (#2868)
* Add additional error handling around logQuery

We are seeing that sometimes the context has
an undefined logger. We probably want to catch
this case and ensure we don't fail. If it happens
again, we will catch everything we need to know
and that will help us resolve the issue in the
future.

CORL-933

* Simplify logging objects for efficiency

Some of the objects that we were going to log
are too large to be dumped to the logging context.
Instead, prefer simple boolean evaluation of their
existence.

CORL-933
2020-03-02 16:34:17 +00:00
Jan Slusarczyk ae7b0fbb37 polish updates for QA and fixes for #2856 (#2870) 2020-02-28 18:05:16 +00:00
Tessa ThorntonandWyatt Johnson 2478e9a250 [CORL-770] save comment sort order preference in local storage (#2854)
* save comment sort order preference in local storage

* await async localstorage operation

Co-authored-by: Wyatt Johnson <accounts+github@wyattjoh.ca>
2020-02-27 23:28:07 +00:00
Nick FunkandWyatt Johnson fe8b6535a9 Ensure comments are still visible when stream is closed (#2865)
CORL-938

Co-authored-by: Wyatt Johnson <accounts+github@wyattjoh.ca>
2020-02-27 22:40:24 +00:00
Jan Slusarczyk 7989f87d02 q&a fix for consistency of unanswered tab string (#2864) 2020-02-27 22:26:28 +00:00
Nick FunkandWyatt Johnson 769ab2a910 Q&A Fixes (#2866)
* Fix various Q&A moderation/tagging issues

- Allow Staff members to have their questions
answered and appropriately tagged
- Properly filter answering for only top level
questions (comments)
- Add documentation around various moderation
phases and comment creation steps
- Remove unnecessary status filter when setting
the status for a comment

* fix: abstracted out perspective configs

* fix: reworked tag injection

* Fix sorting/unused imports

Co-authored-by: Wyatt Johnson <accounts+github@wyattjoh.ca>
2020-02-27 14:40:01 -07:00
Tessa ThorntonandWyatt Johnson c052d37a6f rename Comments.comment to Comments.visible and allow slack task to load any comment regardless of visibility (#2855)
Co-authored-by: Wyatt Johnson <accounts+github@wyattjoh.ca>
2020-02-27 18:46:28 +00:00
Wyatt Johnson a059e70d05 [DOCS Day] Contribution Guidelines Update (#2863)
* feat: initial commit with new handbook material

- Adds guides related to creating PR's
- Enhances PR templates

* feat: harmonized docs
2020-02-26 11:29:18 -05:00
Nick FunkandKim Gardner 1a3401710f Q&A Beta (#2845)
* [CORL-863] Allow streams to be converted/configured to Q&A (#2809)

* Create preliminary schema changes for Q&A

Adds a mode and expert User onto the StorySettings.
Adds a mode selection drop down on a story's
Configure tab.

CORL-863

* Allow multiple experts, remove form elements from search

Makes the previous expert user on a Q&A story now
an array of users who can be assigned.

Converts the previous form based search that was
pulled from the admin community area into a set
of events built on callbacks.

CORL-863

* Create addExpertToStory mutation

CORL-863

* Create removeExpertFromStory mutation

CORL-863

* Conditionally show the the expert selection options

CORL-863

* Create a dropdown search control for Q&A experts

CORL-863

* Fixing up tests to match new QA stream options

Adds a few localization fixes to make sure tests
pass.
Updates existing snapshots.

CORL-863

* Add load more button to expert search list

CORL-863

* Update experts query to match react upgrades

CORL-863

* Move the Q&A config to its own section under stream config

Create enable and disable Q&A mutations/button toggle.

CORL-863

* Fix alignment and layout of expert list items

CORL-863

* Define translations and update tests

CORL-863

* Use official copy for Q&A config

CORL-863

* [CORL-856] Show expert badge on comments (#2829)

* Create preliminary schema changes for Q&A

Adds a mode and expert User onto the StorySettings.
Adds a mode selection drop down on a story's
Configure tab.

CORL-863

* Allow multiple experts, remove form elements from search

Makes the previous expert user on a Q&A story now
an array of users who can be assigned.

Converts the previous form based search that was
pulled from the admin community area into a set
of events built on callbacks.

CORL-863

* Create addExpertToStory mutation

CORL-863

* Create removeExpertFromStory mutation

CORL-863

* Conditionally show the the expert selection options

CORL-863

* Create a dropdown search control for Q&A experts

CORL-863

* Fixing up tests to match new QA stream options

Adds a few localization fixes to make sure tests
pass.
Updates existing snapshots.

CORL-863

* Add load more button to expert search list

CORL-863

* Update experts query to match react upgrades

CORL-863

* Move the Q&A config to its own section under stream config

Create enable and disable Q&A mutations/button toggle.

CORL-863

* Fix alignment and layout of expert list items

CORL-863

* Define translations and update tests

CORL-863

* Use official copy for Q&A config

CORL-863

* Show expert badges on comments when Q&A is enabled

CORL-856

* Update mutation responses and tests due to added expert fields

CORL-856

* Use EXPERT user tags to denote expert users

Removes the need for viewerIsExpert and
authorIsExpert loader/resolvers on Stories
and Comments respectively.

CORL-856

* [CORL-879] Add an unanswered tab to stream when in Q&A mode (#2838)

* Create preliminary schema changes for Q&A

Adds a mode and expert User onto the StorySettings.
Adds a mode selection drop down on a story's
Configure tab.

CORL-863

* Allow multiple experts, remove form elements from search

Makes the previous expert user on a Q&A story now
an array of users who can be assigned.

Converts the previous form based search that was
pulled from the admin community area into a set
of events built on callbacks.

CORL-863

* Create addExpertToStory mutation

CORL-863

* Create removeExpertFromStory mutation

CORL-863

* Conditionally show the the expert selection options

CORL-863

* Create a dropdown search control for Q&A experts

CORL-863

* Fixing up tests to match new QA stream options

Adds a few localization fixes to make sure tests
pass.
Updates existing snapshots.

CORL-863

* Add load more button to expert search list

CORL-863

* Update experts query to match react upgrades

CORL-863

* Move the Q&A config to its own section under stream config

Create enable and disable Q&A mutations/button toggle.

CORL-863

* Fix alignment and layout of expert list items

CORL-863

* Define translations and update tests

CORL-863

* Use official copy for Q&A config

CORL-863

* Show expert badges on comments when Q&A is enabled

CORL-856

* Update mutation responses and tests due to added expert fields

CORL-856

* Use EXPERT user tags to denote expert users

Removes the need for viewerIsExpert and
authorIsExpert loader/resolvers on Stories
and Comments respectively.

CORL-856

* Show an unanswered comment stream when Q&A is enabled

CORL-879

* Do not visually show the unanswered tag

CORL-879

* [CORL-859] Convert Featured stream into Answered for Q&A (#2842)

* Create preliminary schema changes for Q&A

Adds a mode and expert User onto the StorySettings.
Adds a mode selection drop down on a story's
Configure tab.

CORL-863

* Allow multiple experts, remove form elements from search

Makes the previous expert user on a Q&A story now
an array of users who can be assigned.

Converts the previous form based search that was
pulled from the admin community area into a set
of events built on callbacks.

CORL-863

* Create addExpertToStory mutation

CORL-863

* Create removeExpertFromStory mutation

CORL-863

* Conditionally show the the expert selection options

CORL-863

* Create a dropdown search control for Q&A experts

CORL-863

* Fixing up tests to match new QA stream options

Adds a few localization fixes to make sure tests
pass.
Updates existing snapshots.

CORL-863

* Add load more button to expert search list

CORL-863

* Update experts query to match react upgrades

CORL-863

* Move the Q&A config to its own section under stream config

Create enable and disable Q&A mutations/button toggle.

CORL-863

* Fix alignment and layout of expert list items

CORL-863

* Define translations and update tests

CORL-863

* Use official copy for Q&A config

CORL-863

* Show expert badges on comments when Q&A is enabled

CORL-856

* Update mutation responses and tests due to added expert fields

CORL-856

* Use EXPERT user tags to denote expert users

Removes the need for viewerIsExpert and
authorIsExpert loader/resolvers on Stories
and Comments respectively.

CORL-856

* Show an unanswered comment stream when Q&A is enabled

CORL-879

* Create preliminary schema changes for Q&A

Adds a mode and expert User onto the StorySettings.
Adds a mode selection drop down on a story's
Configure tab.

CORL-863

* Do not visually show the unanswered tag

CORL-879

* Allow multiple experts, remove form elements from search

Makes the previous expert user on a Q&A story now
an array of users who can be assigned.

Converts the previous form based search that was
pulled from the admin community area into a set
of events built on callbacks.

CORL-863

* Create addExpertToStory mutation

CORL-863

* Create removeExpertFromStory mutation

CORL-863

* Create a dropdown search control for Q&A experts

CORL-863

* Fixing up tests to match new QA stream options

Adds a few localization fixes to make sure tests
pass.
Updates existing snapshots.

CORL-863

* Add load more button to expert search list

CORL-863

* Update experts query to match react upgrades

CORL-863

* Move the Q&A config to its own section under stream config

Create enable and disable Q&A mutations/button toggle.

CORL-863

* Fix alignment and layout of expert list items

CORL-863

* Define translations and update tests

CORL-863

* Show expert badges on comments when Q&A is enabled

CORL-856

* Use official copy for Q&A config

CORL-863

* Update mutation responses and tests due to added expert fields

CORL-856

* Use EXPERT user tags to denote expert users

Removes the need for viewerIsExpert and
authorIsExpert loader/resolvers on Stories
and Comments respectively.

CORL-856

* Create the answered stream for Q&A

CORL-859

* Sort the Q&A on Most Voted by default

CORL-859

* Fix type mismatch between post comment form and fragments

CORL-859

* Adding localizations for Q&A tags

* Hide feature flags in Q&A streams

* Allow experts to clear answered questions

Can click a button that shows up in the unanswered
tab under answered questions to refresh the stream,
clearing the answered questions from that tab.

* Show arrow upvote icon when in Q&A mode

Also localized the upvote text so it can
be translated.

* Hide mod/report buttons on answered questions

* Remove unnecessary fragment container

* Remove errant debug console log

* Make story mode required on story settings

* Make remove button outlined, not filled

* Further schema changes around Q&A and experts

Rename add/remove story expert to removeStoryExpert
and addStoryExpert naming.

Replace enableQA and disableQA mutators with single
updateStoryMode mutator.

* Remove story mode from UpdateStorySettings

* Replace inline string val's with enum

* add dependencies to useEffect

* docs cleanup around tags.type index check

* Approve a question when it is answered in Q&A

Approves using the author's id as the moderator

* Add comment around use of TAG on comments edge

* Use tagFilter instead of $elemMatch to filter by tags

* Improve responsive styles for expert list items

* Update copy to "Done" from "Remove answered questions"

* Text styling for no users found text

Styles the expert search list to have proper
text styling when no users are found for the
search keyword.

* Remove duplicate checks around story experts

Not necessary as Mongo does this for us.

* Fix a missed "sort imports" during rebase

* Refactor Q&A moderation phases for clarity

Simplify logic and update comments.
Rename: "answered" -> "tagExpertAnswers"
Rename: "unanswered" -> "tagUnansweredQuestions"

* Remove username & email from add expert mutation

* Format expert list emails with Localized

* Break out no comments logic into fragment

* Remove ref handling from expert search field

Use value assignment on TextField instead.

* Replace Box with Flex and CSS

* Show Q&A tooltip on Answered tab

Co-authored-by: Kim Gardner <kgardnr@gmail.com>
2020-02-25 20:10:25 -05:00
Nick FunkandWyatt Johnson 184f145c4a Add a contributing section to our README (#2860)
- Define how to contribute to our public docs
- Define schema types and their conventions

Co-authored-by: Wyatt Johnson <accounts+github@wyattjoh.ca>
2020-02-25 23:35:06 +00:00
dcb2a10e72 [CORL-845] Account Linking (#2818)
* feat: added new linking backend

* feat: added duplicateEmail to hash

* fix: stored the duplicate email on the user

* feat: initial implmentation of account linking in auth

* test: fix unit tests

* fix+test: translations and tests added

* chore+test: rename view to LINK_ACCOUNT + more tests

* feat+test: account linking admin + more tests

* feat: Handle incomplete accounts

* chore: add some comments

* feat: expose duplicateEmail through graphql and impl for stream

* feat: admin to use duplicateEmail from graphql

* fix: no need to validate password for account linking

* fix: dont validate password

* fix: no need to render error message when account was incomplete

* chore: log to console when encountering incomplete account

* chore: adjust comment

* chore: simplify + add comments

* chore: wording

* chore: comments

Co-authored-by: Vinh <vinh@vinh.tech>
Co-authored-by: Kim Gardner <kgardnr@gmail.com>
2020-02-25 15:46:32 -05:00
Jan Slusarczyk 7497e33046 pl translation updated strings and fixes (#2853)
* Polish translation updates

* polish translation fixes and updates

* Typo fix
2020-02-23 12:37:11 -05:00
Tessa ThorntonandKim Gardner 330c30f1b3 [CORL-883] show comment counts for stories in story table (#2847)
* show comment counts for stories in story table

* remove debug code

Co-authored-by: Kim Gardner <kgardnr@gmail.com>
2020-02-21 14:07:22 -05:00
Tessa Thornton e2185a33f5 [CORL-918] make siteID required in site query (#2851)
* make siteID required in site query

* update specs and snaps

* remove unnecessary comment
2020-02-21 12:46:40 -05:00
Tessa Thornton d6db287c55 [CORL-882] option to reject all a user's comments when banning (#2827)
* show comment counts for stories in story table

* remove debug code

* add rejector task

* connect comment rejection job to user banning

* localize strings

* remove debug code

* remove debug code

* resolve merge conflicts

* add documentation to rejectExistingComments

* clean up rejector task

* add TODO about broker

* make rejectExistingComments nullable
2020-02-21 12:38:40 -05:00
Jan Slusarczyk ca52cc3253 Polish translation updates (#2852) 2020-02-21 11:28:37 -05:00
Wyatt Johnson c2dadcc034 feat: consolidated migrations for 6.0.0 release (#2831) 2020-02-20 22:42:05 +00:00
Jan Slusarczyk 6865a71684 polish (pl) translation - initial version (#2849)
* pl translation

* fix spaces at the end of a tag in test
2020-02-20 16:24:10 -05:00
Tessa Thornton 33556044a8 [CORL-919] approved queue (#2848)
* show comment counts for stories in story table

* remove debug code

* add 'approved' moderation queue to moderation tabs

* add site/story specific routes

* Revert "remove debug code"

This reverts commit ed3a44304d225c79336423f34d0bde57a998cd69.

* Revert "show comment counts for stories in story table"

This reverts commit 61eb00c70c873fd2d10f1476601c7a69ae12651d.
2020-02-19 16:37:51 -05:00
Tessa ThorntonandVinh d883ab029c [CORL-920] use SHORT_NUMBER fluent function to format comment counts (#2846)
* use SHORT_NUMBER fluent function to format comment counts

* fix: short number algo

* chore: remove whitespace

* chore: rename var

Co-authored-by: Vinh <vinh@vinh.tech>
2020-02-19 15:13:16 -05:00
Tessa Thornton 4b637a2dd5 [CORL-761] stream-side account tab for sso (#2834)
* move download comments to my comments tab

* only show download comments if available

* move ignored users management to same tab as notifications, rename to preferences

* fix query and ts defs

* add url to jwt

* make account tab go to external url if provided

* ensure url is an optional jwt field

* update tabs for stream profile

* update classnames for tabs

* fix tests
2020-02-19 14:39:58 -05:00
e42c2b925d [CORL-687] Webhooks (#2738)
* feat: initial webhook impl

* feat: added support for key rotation

* feat: harmonized fetcher

* feat: added expired secrets cleaning

* feat: event system refactor

* feat: added story event

* feat: simplfiied webhook handler

* feat: added ref's to locations where user events can be added

* feat: added UI to support webhooks

* fix: renaming some Webhook -> WebhookEndpoint

* fix: review comments to adjuist flow

* feat: added localizations

* fix: linting, updated snapshots

* fix: adapted for new fluent

* fix: rearranged folders

* fix: linting

* feat: added webhooks documentation

* feat: improved toc generation

* feat: added some tests to webhooks

* fix: chain transition hooks

* feat: added tests around webhook ui

* fix: renamed events

* fix: adjusted circle markdown linting

* fix: adjusted doctoc script call

* review: review fixes

* review: review comments

* review: adjusted signing secret confirmation

* review: adjusted styles to harmonize button usage

* fix: updated snapshots and tests

* review: move form out of webhooks

Moved the form out of the webhooks by relocating the layout used for the
route associated with the configure routes.

* fix: fixed bugs and snapshots with tests

* feat: revised slack message format to use block api

* fix: fixed a small text bug

Co-authored-by: Vinh <vinh@vinh.tech>
Co-authored-by: Kim Gardner <kgardnr@gmail.com>
2020-02-18 13:25:48 -05:00
Tessa Thornton 34ba2da88d update perspective models docs link (#2844) 2020-02-18 10:31:48 -05:00
Wyatt Johnson cec76e0046 fix: fixes autofill issues in chrome (#2837) 2020-02-13 13:49:44 -05:00
Tessa ThorntonandWyatt Johnson 707d65a119 Support multisite (#2799)
* resovle import error by removing useContext from ui component

* update snaps

* create useUIContext hook

* add site and community models

* create sites and communities on install

* add site name to install wizard

* add site id to stories

* pass site id to stream query in embed

* fix spec

* add sites query

* list sites in organization config

* add route for new sites

* add create site mutation

* view and update sites

* show embed codes for sites

* add site id to comments

* allow filtering moderation queues by site id

* add site selector to queue

* move sites config routes

* Revert "move sites config routes"

This reverts commit 4ed5345d3e1df6263f8390b64214956c43c4d8cd.

* update sites routes

* show site name in moderate card

* remove site selector from queue selector

* style create site form

* edit site form

* clean up ts

* move :storyID paths to /storeis/:storyID

* make queues respect site id

* add site switcher

* styles for site selector

* add global notifications

* style app notifications

* clear notifications after x miliseconds

* use notification component in add site form

* fix types

* make notifications dismissable

* dismiss site created notification

* remove button letter spacing if lowercase

* filter stories by site in search

* add site name to story search results

* add site column to stories table

* filter stories table by site

* make sure notification displays after site creation

* paginate sites table

* paginage site selector

* add paginated site filter to stories table

* fix merge conflicts

* sort by createdAt

* default to 20 sites

* delete comments

* add translation tags

* make site ID not mandatory

* Fix tests and specs

* only include site id in embed code for multisite

* update tenant cache when adding first site

* only show site selector if multiple sites

* use story url instead of site id for story upsert

* update snaps

* make ui conditional on multisite

* update snaps and remove unnecessary site ID

* sloppily calculate counts for filtered queues

* get origins of allowed domains

* add migration

* enable migration

* only show permitted domains if mulltisite is false

* remove site id from embed code

* update snaps

* undo updates to singletonresolver

* remove refernces to communities

* fix mints

* remove community reference

* update copy in installation

* use sites services in installer

* remove unused loader

* correct error text for useNotification

* order sites by name

* make multisite a computed property

* use map/filter instead of for/of for url origins

* add missing/incorrect translations

* remove references to siteID

* remove references to tenant isURLpermitted

* add comments to schema updates

* simplify filtering stories by site

* remove domains config from advanced

* fix: adjusted CSP header generation

* add migration to create indexes on site

* clear notifications on navigate

* remove count for filtering by site

* throw duplicate error for allowed domains

* handle errors for create/update sites

* remove contacturl and contactemail from sites

* fix types for counts

* sort imports

* ensure props get passed down to link version of button component

* add url and email fields back into organization config

* sort imports

* fix moderation queues resolver types

* fix appearance of sites dropdown

* add status role to notificaiton

* remove duplicate layout file

* fix: rename allowedDomains -> allowdOrigins

* move Link conditional from button to basebutton component

* fix merge conflict

* fix mutation optimistic response

* make sure to prop gets passed to link

* change labels on install steps

* show story's site in site selector when moderating by story

* feat: support site counting

* update snap

* remove multisite from settings

* move paginated select to admin/components

* fix circular import errors

* remove uicontext component from v2 timestamp

Co-authored-by: Wyatt Johnson <accounts+github@wyattjoh.ca>
2020-02-12 09:22:07 -05:00
Sami Safatli 014aa2d86a Update translation pt br files (#2833)
* Update translations of admin pt-BR file

* Update server files translations pt-BR
2020-02-10 20:28:27 -05:00
Wyatt Johnson 94e1c75c0b chore: bump version 5.5.1 (#2832) 2020-02-10 17:11:14 +00:00
Hector NievaandWyatt Johnson 3014017976 Fix regexp mathing of banned word list by exchanging .test() with .match() (#2828)
* [Fix] Use .match instead of .test to avoid false positives when matching regex returned groups

* Add test to assure memoized regexp for banned words will match properly

* fix: fixes bug with wordlist matching

Co-authored-by: Wyatt Johnson <accounts+github@wyattjoh.ca>
2020-02-08 00:28:08 +00:00
Sami Safatli 1388e0bc5e Update translation pt-BR (#2826) 2020-02-06 13:38:55 -05:00
588 changed files with 21201 additions and 5957 deletions
+1
View File
@@ -0,0 +1 @@
src/docs/architecture/decisions
+8 -3
View File
@@ -65,11 +65,13 @@ jobs:
name: Lint Source Code
command: npm run lint
- run:
name: Lint README.md
name: Lint Markdown
command: |
cp README.md README.md.orig
npm run doctoc
diff -q README.md README.md.orig
git diff --exit-code
- run:
name: Lint Versions
command: npx @coralproject/package-version-lint
# unit_tests will run the unit tests.
unit_tests:
@@ -142,6 +144,9 @@ jobs:
<<: *job_defaults
steps:
- checkout
- run:
name: Verify release version
command: npx @coralproject/package-version-lint --expect ${CIRCLE_TAG/#v}
- setup_remote_docker
- deploy:
name: Deploy the code
+36 -2
View File
@@ -1,14 +1,48 @@
<!--
Thank you for submitting a pull request! Please note that by contributing to Coral, you agree to our Code of Conduct: http://code-of-conduct.voxmedia.com/
Before submitting your PR, please verify that:
Thank you for submitting a pull request! Please note that by contributing to
Coral, you agree to our Code of Conduct: http://code-of-conduct.voxmedia.com/
Before submitting your Pull Request (or PR), please verify that:
* [ ] Your code is up-to-date with the base branch
* [ ] You've successfully run `npm run test` locally
Refer to CONTRIBUTING.MD for more details.
https://github.com/coralproject/talk/blob/master/CONTRIBUTING.md
-->
## What does this PR do?
<!--
In this section, you should be describing what other Github issues or tickets
that this PR is designed to addressed.
Any related Github issue should be linked by adding its URL to this section.
-->
## What changes to the GraphQL/Database Schema does this PR introduce?
<!--
In this section, you should describe any changes to be made to the GraphQL
schema file (located https://github.com/coralproject/talk/blob/master/src/core/server/graph/schema/schema.graphql) or any
database model (located as types in the https://github.com/coralproject/talk/blob/master/src/core/server/models directory).
If no changes were added to the GraphQL/Database Schema as a part of this PR,
simply write "None".
-->
## How do I test this PR?
<!--
In this section, you should be describing any manual testing that can be used to
verify features introduced or bugs fixed in this PR.
-->
+1 -1
View File
@@ -54,5 +54,5 @@
"search.exclude": {
"package-lock.json": true
},
"debug.node.autoAttach": "off"
"debug.node.autoAttach": "on"
}
+32 -10
View File
@@ -1,31 +1,52 @@
# Client Events Guide
This serves as a guide to events emitted by the javascript via the embed events
hook, as described below in [Viewer Events](#viewer-events).
<!-- 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
- [Viewer Events](#viewer-events)
- [Viewer Network Events](#viewer-network-events)
- [Event List](#event-list)
- [Index](#index)
- [Events](#events)
<!-- END doctoc generated TOC please keep comment here to allow auto update -->
## Viewer Events
_Viewer Events_ are emitted when the viewer performs certain actions.
They can be subscribed to using the `events` parameter in
`Coral.createStreamEmbed`.
```html
<script>
const CoralStreamEmbed = Coral.createStreamEmbed({
events: function(events) {
events.onAny(function(eventName, data) {
console.log(eventName, data);
});
},
});
</script>
<script>
const CoralStreamEmbed = Coral.createStreamEmbed({
events: function(events) {
events.onAny(function(eventName, data) {
console.log(eventName, data);
});
},
});
</script>
```
Example events:
- `setMainTab {tab: "PROFILE"}`
- `showFeaturedCommentTooltip`
- `viewConversation {from: "FEATURED_COMMENTS", commentID: "c45fb5f5-03f9-49a3-a755-488c698ca0df"}`
### Viewer Network Events
_Viewer Network Events_ are events that involves a network request and thus can succeed or fail. Succeeding events will have a `.success` appended to the event name while failing events have an `.error` appended to the event name.
_Viewer Network Events_ are events that involves a network request and thus can succeed or fail. Succeeding events will have a `.success` appended to the event name while failing events have an `.error` appended to the event name.
Moreover _Viewer Network Events_ contains the `rtt` field which indicates the time it needed from initiating the request until the _UI_ has been updated with the response data.
Example events:
```
createComment.success
{
@@ -53,6 +74,7 @@ createComment.error
```
## Event List
<!-- START docs:events -->
<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN npm run docs:events -->
### Index
+492 -12
View File
@@ -5,20 +5,60 @@ Welcome! We are very excited that you are interested in contributing to Coral.
This document is a companion to help you approach contributing. If it does not
do so, please [let us know how we can improve it](https://github.com/coralproject/talk/issues)!
By contributing to this project you agree to the
[Code of Conduct](CODE_OF_CONDUCT.md).
By contributing to this project you agree to the [Code of Conduct](CODE_OF_CONDUCT.md).
<!-- 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
- [What should I Contribute?](#what-should-i-contribute)
- [Writing Code](#writing-code)
- [When should I create an issue?](#when-should-i-create-an-issue)
- [What should I include in my issue?](#what-should-i-include-in-my-issue)
- [When should I create a pull request?](#when-should-i-create-a-pull-request)
- [What should I include in my pull request?](#what-should-i-include-in-my-pull-request)
- [Reviewing pull requests](#reviewing-pull-requests)
- [Ensure contributions are linted and tested](#ensure-contributions-are-linted-and-tested)
- [Review the feature/fixes](#review-the-featurefixes)
- [Review architectural decisions](#review-architectural-decisions)
- [Verify localizations](#verify-localizations)
- [Localization](#localization)
- [Documentation](#documentation)
- [Design Principles](#design-principles)
- [GraphQL](#graphql)
<!-- END doctoc generated TOC please keep comment here to allow auto update -->
## What should I Contribute?
There are at least three ways to contribute to Coral:
There are at least three different ways to contribute to Coral:
- Writing Code
- Providing Translations
- [Writing Code](#writing-code)
- [Reviewing pull requests](#reviewing-pull-requests)
- [Localization](#localization)
- [Documentation](#documentation)
Typically these take the form of creating a Pull Request for Coral, and
submitting it to be reviewed by a member of our team and the greater Coral
community.
Working on your first Pull Request? You can learn how from this free video
series:
[How to Contribute to an Open Source Project on GitHub](https://egghead.io/series/how-to-contribute-to-an-open-source-project-on-github)
If you decide to fix an issue, please be sure to check the comment thread in
case somebody is already working on a fix. If nobody is working on it at the
moment, please leave a comment stating that you intend to work on it so other
people dont accidentally duplicate your effort.
If somebody claims an issue but doesnt follow up for more than two weeks, its
fine to take it over but you should still leave a comment.
## Writing Code
Conversation surrounding contributions begins in
[issues](https://github.com/coralproject/talk/issues).
Conversation surrounding contributions begins when you can create an issue
describing your issue or suggestion.
### When should I create an issue?
@@ -26,9 +66,7 @@ File an issue as soon as you have an idea of something you'd like to contribute.
We would love to hear what you're thinking and help refine the idea to make it
into the Coral ecosystem.
Please file issues if you would like to contribute to Coral.
### What should I include?
### What should I include in my issue?
Coral has adopted an iterative, agile development philosophy. All contributions
that make it into the Coral repository should start with a user story in this
@@ -38,8 +76,8 @@ form:
This exercise does two things:
- allows us to ground our technical choices in a clear, simple product need.
- expresses that product need in a way that doesn't imply a specific technical
- Allows us to ground our technical choices in a clear, simple product need.
- Expresses that product need in a way that doesn't imply a specific technical
solution allowing for debate as to the best way to solve the problem.
Please feel free to provide as much detail as possible when filing the issue but
@@ -49,6 +87,72 @@ technical or design solutions.
If you have a specific technical or design solution in mind, please submit it as
the first comment on the thread.
### When should I create a pull request?
File a pull request if you've created an issue in our [issues](https://github.com/coralproject/talk/issues)
page and have heard back from a member or contributor to Coral. This allows our
team to review the proposed changes prior to time being spent if the team
already has the feature/fix in the road map.
### What should I include in my pull request?
When you create a pull request, the template will describe the required
components needed for it to be reviewed by a member of the Coral team. You
should end up filling out:
- What does this PR (pull request) do?
- How do I test this PR?
You should describe what Github issue or ticket that the PR is associated with
to assist the review process. If this PR is resolving a particular bug, a
testing strategy should be described in the testing section. If this PR is
contributing a new feature, a description should describe a scenario to test or
verify the new functionality.
## Reviewing pull requests
Reviewing pull requests in Coral is generally completed by the core Coral team
that is composed of developers employed by Vox Media Inc, but external reviews
or suggestions are also welcomed.
Our review process generally follows a few core principles:
### Ensure contributions are linted and tested
It is the job of CI linting and tests to notify of style issues within the
codebase. If it is not possible for style issues to be encapsulated as a
linting rule, it shouldn't be concretely enforced during the review process.
This can ensure that code reviews contain more meaningful feedback tied to the
contribution rather than nit-picking on stylistic choices.
Reviewers must ensure that linting and tests pass in CI and locally prior to a
review taking place. You can do this by running `npm run generate` followed by
`npm run lint` and `npm run test`.
### Review the feature/fixes
Any new features added to Coral should be reviewed for bugs through a manual
verification process to ensure that they function on your machine. If possible
you should review any automated tests that were added (or not added) related to
the feature.
While the Coral team is not strict on test driven development (or TDD), any
contributions that include tests are greatly appreciated, and preferred over
those that do not.
### Review architectural decisions
Any substantial changes made to the codebase should be reviewed to ensure
that they conform to the current way code/services are laid out.
Architecture Decision Records (or [ADR](http://thinkrelevance.com/blog/2011/11/15/documenting-architecture-decisions)) are now being used to describe architectural decisions and can be found in the `src/docs/architecture/decisions` directory.
### Verify localizations
While we don't have any automated tools at the time of writing that will
verify this in CI, any strings being added that are presented to the end user
should be wrapped in localization components to support other languages.
## Localization
We use the [fluent](http://projectfluent.org/) library and store our
@@ -69,3 +173,379 @@ The [Perspective API](https://github.com/conversationai/perspectiveapi/blob/mast
also supports comments in specific languages. When the language is supported in
Coral and supported by the Perspective API, the language should be added to the
language map in `src/core/server/services/comments/pipeline/phases/toxic.ts`.
## Documentation
Documentation that is publicly shown on
[docs.coralproject.net](https://docs.coralproject.net/coral/) is stored under
the `release/4` branch as it contains information for versions 4.0 onwards of
Coral.
To contribute new docs, you can either click the "Suggest Edits" in the top
right of each page, or you can edit directly via source. We suggest that for
individual fixes or contributions to the documentation.
If you want to contribute via source files, you can follow the procedure
outlined below:
1. Clone the Talk repository via `git clone https://github.com/coralproject/talk.git`
2. Switch to the `release/4` branch via `git checkout release/4`
3. Follow the procedure outlined on that branch's [CONTRIBUTING.md](https://github.com/coralproject/talk/blob/release/4/CONTRIBUTING.md#contributing-documentation) file for contributing documentation changes
4. Create a pull request to merge your changes back into the `release/4` branch
## Design Principles
### GraphQL
Coral relies heavily on [GraphQL](https://graphql.org) as the query language for
the API and the runtime on the server that powers resolving data from data
sources. This heavily influences a lot of the decisions around how we create and
consume it's API internally and how we expose it to others to interact with.
There are many GraphQL types in our [`schema.graphql`](https://github.com/coralproject/talk/blob/master/src/core/server/graph/schema/schema.graphql)
that define the way we handle data in our API. We'll try to outline a few of
them here with examples to help you understand their uses.
#### Types
Similar to defining an interface or a _struct_ definition, GraphQL has flexible
types that can be used to define data types that are used for querying data from
the API. This retrieval can happen directly via a query, or after executing an
action using a mutation and querying its response result.
An example of these types is the `Comment` and its nested `CommentRevision`
type:
```graphql
"""
Comment is a comment left by a User on an Story or another Comment as a reply.
"""
type Comment {
"""
id is the identifier of the Comment.
"""
id: ID!
"""
body is the content of the Comment, and is an alias to the body of the
`revision.body`.
"""
body: String
"""
revision is the current revision of the Comment's body.
"""
revision: CommentRevision
"""
revisionHistory stores the previous CommentRevision's, with the most recent
edit last.
"""
revisionHistory: [CommentRevision!]!
@auth(
roles: [MODERATOR, ADMIN]
userIDField: "author_id"
permit: [SUSPENDED, BANNED, PENDING_DELETION]
)
"""
status represents the Comment's current status.
"""
status: COMMENT_STATUS!
}
```
Notice how the `Comment` type can nest more custom defined types. A `Comment`
can have a current `CommentRevision` named `revision`. It also has a list of its
historical `revisionHistory`:
```graphql
type CommentRevision {
"""
id is the identifier of the CommentRevision.
"""
id: ID!
"""
comment is the reference to the original Comment associated with the current
Comment.
"""
comment: Comment!
"""
actionCounts stores the counts of all the actions for the CommentRevision
specifically.
"""
actionCounts: ActionCounts! @auth(roles: [MODERATOR, ADMIN])
"""
body is the content of the CommentRevision. If null, it indicates that the
body text was deleted.
"""
body: String
}
```
Another thing to note, see how `CommentRevision` is not only referenced by
`revision` and `revisionHistory` on the `Comment` type. The `CommentRevision`
also references back to its parent `Comment` via the `comment: Comment!`
property. This is how defined types interact between each other in the GraphQL
schema.
Our naming scheme is upper camel case (also known as Pascal Case) for these
types:
- Start with a capital letter
- Following characters are lower case
- Every new word in the type name begins with a new capital letter
- Acronyms are always capitalized (with the only exception being the
`clientMutationId: String!` field in mutation input/payload types)
Some of the properties have an `!` beside their type (i.e `id: ID!`) which
indicates that this property is required and is non-nullable. GraphQL will
validate the input request for these properties and ensure they are provided
during the GraphQL request.
You can learn more about GraphQL types in their documentation:
[Learn GraphQL: Schemas and Types](https://graphql.org/learn/schema/)
#### Enumeration Types
In the previous example with the `Comment` type. We also had a property called
`status` which was of type `COMMENT_STATUS`.
This is another kind of defined type, an [Enumeration Type](https://graphql.org/learn/schema/#enumeration-types),
also called _enums_.
```graphql
enum COMMENT_STATUS {
NONE
APPROVED
REJECTED
PREMOD
SYSTEM_WITHHELD
}
```
Like all enumeration types this definition enumerates out typed, named values
that are reusable for state elsewhere on other types.
Our naming scheme for enumeration types and values in those types as:
- All capital letters
- Spaces delimited with underscores
This is because they are treated as shared constant values across the schema.
Rather than storing strings or numbers to capture selected state, we prefer
using enumeration types because they are much more stricter in terms of value.
You can learn more about GraphQL Enumeration types in their documentation:
[Learn GraphQL: Enumeration Types](https://graphql.org/learn/schema/#enumeration-types)
#### Mutation Types
Mutations are a request to GraphQL to initiate an action which will result in a response. As such,they're broken up into an `Input` and `Payload` pair that matches a mutation's request and response pair.
An example is the `CreateCommentInput` and `CreateCommentPayload`:
```graphql
"""
CreateCommentInput provides the input for the createComment Mutation.
"""
input CreateCommentInput {
"""
storyID is the ID of the Story where we are creating a comment on.
"""
storyID: ID!
"""
nudge when true will instead return an error related to recoverable moderation
faults such as a toxic comment or spam comment to provide user feedback to
nudge the user to correct the comment.
"""
nudge: Boolean = false
"""
body is the Comment body, the content of the Comment.
"""
body: String!
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
"""
CreateCommentPayload contains the created Comment after the createComment
mutation.
"""
type CreateCommentPayload {
"""
edge is the possibly created comment edge.
"""
edge: CommentEdge!
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
```
The `CreateCommentInput` type contains some parameters that allow us to execute
the mutation. These are:
- `storyID` - the story we are submitting the comment to.
- `body` - the body of our comment.
- `nudge` - whether we should return validation prompts to the user about
improper comment language.
- `clientMutationId` - the identifier used by Relay to identify this mutation,
our front-end state cache to process the mutation request. This is the only
place in the code-base that we do not capitalize the acronym when written in
camel-case, this is unfortunately due to legacy reasons from within Relay.
The returned response for a mutation is a what we call a Payload, in this case
it's `CreateCommentPayload`. This usually has a response that is the full data
type of whatever was modified by the earlier called mutation action. The
properties on this type are:
- `edge` - we return the full comment edge that was created by the previous
input. This is done so that the mutation request can query whatever it needs
to from the returned input to update state on the client.
- `clientMutationId` - an identifier used by Relay (our front-end client state
cache) to process the mutation request.
#### Access Permissions On Types
Sometimes, you only want specific users to be allowed to view certain
information. For instance, we have roles that are defined in our schema so we
can filter who can have access to what.
These roles are used with directives on our schema that GraphQL then enforces
when trying to resolve requests a user makes. If the user has the specified role
associated with their signed-in account, they are given access to the requested
data.
An example of this is the `revisionHistory` on the `Comment` type:
```graphql
fragment on Comment {
"""
revisionHistory stores the previous CommentRevision's, with the most recent
edit last.
"""
revisionHistory: [CommentRevision!]!
@auth(
roles: [MODERATOR, ADMIN]
userIDField: "author_id"
permit: [SUSPENDED, BANNED, PENDING_DELETION]
)
}
```
Here we see the `@auth` directive. It has documentation describing the various
parameters allowed located in the [`schema.graphql`](https://github.com/coralproject/talk/blob/master/src/core/server/graph/schema/schema.graphql)
file, we'll discuss below what this particular set of parameters can be read as:
- The roles that are allowed to access this information are `MODERATOR` and
`ADMIN` as defined by the `roles` argument.
- We let the directive know that the author of the comment is `author_id` from
the `Comment` by defining the `userIDField`. It's a rule of thumb in Coral if
the Author created the document, they have permission to view it. A Comment
for example is authored by a user, with the underlying field associated with
the id of that author living on the `author_id` field. You can see how this
is related if you look at the resolver for the `Comment` type to see that it
is based off of the `Comment` interface from `src/core/server/models/comment`.
- We permit returning comments when the author has the following conditions
associated with their account: `SUSPENDED`, `BANNED`, or `PENDING_DELETION`.
These directives can be simpler, for example the `metadata` property on the
`CommentRevision`:
```graphql
fragment on CommentRevision {
"""
metadata stores details on a CommentRevision.
"""
metadata: CommentRevisionMetadata! @auth(roles: [ADMIN, MODERATOR])
}
```
Here we see an auth directive with only roles defined. This is sufficient to
make sure that the metadata property is only accessible to `ADMIN` and
`MODERATOR` user roles.
Note: Wondering how the user roles are defined? They're simply an enumeration
type that is also defined in the schema.
```graphql
enum USER_ROLE {
COMMENTER
STAFF
MODERATOR
ADMIN
}
```
#### Arrays of Items
Sometimes you don't want a singular property, your property is instead a
collection of items.
The `revisionHistory` from the `Comment` is again useful as an example:
```graphql
fragment on Comment {
"""
revisionHistory stores the previous CommentRevision's, with the most recent
edit last.
"""
revisionHistory: [CommentRevision!]!
}
```
The interior type `CommentRevision` is required using the `!` and the outer
array is also required using `!`.
We do this for a couple of reasons:
- This ensures that we do not return null/undefined values within the array.
- Why would we return a null when we can just return nothing for null values?
- We want the array to always be defined, if empty, we return and empty array (i.e. `[]`).
- This can be handled nicely in our resolvers. We simply check if the retrieved values is null or undefined and simply return an empty array in its stead.
These little tweaks aren't necessary, but they ease the use of our API by making
the results for arrays predictable and strongly typed.
#### Documenting
As you may have noticed, there is quite a bit of documentation in the schema examples listed here.
We typically follow these two rules in commenting our GraphQL types:
- Always comment the property within a type describing its purpose on its parent type
i.e. `createdAt` on our `Setting` type:
```graphql
fragment on Setting {
"""
createdAt is the time that the Settings was created at.
"""
createdAt: Time! @auth(roles: [ADMIN])
}
```
- Always comment the purpose of each type
i.e. the `Comment` type:
```graphql
"""
Comment is a comment left by a User on an Story or another Comment as a reply.
"""
type Comment { }
```
+205
View File
@@ -0,0 +1,205 @@
# Webhooks Guide
This document is in reference to webhooks emitted by Coral. You can configure
webhooks on your installation of Coral by visiting `/admin/configure/webhooks`.
Once you've configured a webhook endpoint in Coral, you will receive updates
from Coral when those events occur. These will be in the form of `POST` requests
with a `JSON` payload consisting of the schema represented below.
<!-- 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
- [Webhook Signing](#webhook-signing)
- [How to verify the signature(s)](#how-to-verify-the-signatures)
- [Schema](#schema)
- [Events Listing](#events-listing)
- [Events](#events)
<!-- END doctoc generated TOC please keep comment here to allow auto update -->
## Webhook Signing
Each webhook sent by Coral is signed by your webhook endpoint signing secret.
The signature method closely resembles the signing method used by Stripe for
their `v1` signing method. The `X-Coral-Signature` header contains one or more
signatures prefixed by `sha256=`.
If you receive a signature containing multiple signatures, it is typically when
you have rolled the signing secret from the administrative panel, and chosen to
keep the previous secret active for a duration of time.
### How to verify the signature(s)
```js
// Set your signing secret here from the administration panel.
const SIGNING_SECRET = "< YOUR SIGNING SECRET HERE >";
// We're using crypto to verify the signatures.
const crypto = require("crypto");
// We're using express to receive webhooks here.
const app = require("express")();
// Use the body-parser to get the raw body as a buffer so we can use it with the
// hashing functions.
const parser = require("body-parser");
function extractEvent(body, sig) {
// Step 1: Extract signatures from the header.
const signatures = sig
// Split the header by `,` to get a list of elements.
.split(",")
// Split each element by `=` to get a prefix and value pair.
.map(element => element.split("="))
// Grab all the elements with the prefix of `sha256`.
.filter(([prefix]) => prefix === "sha256")
// Grab the value from the prefix and value pair.
.map(([, value]) => value);
// Step 2: Prepare the `signed_payload`.
const signed_payload = body;
// Step 3: Calculate the expected signature.
const expected = crypto
.createHmac("sha256", SIGNING_SECRET)
.update(signed_payload)
.digest()
.toString("hex");
// Step 4: Compare signatures.
if (
// For each of the signatures on the request...
!signatures.some(signature =>
// Compare the expected signature to the signature on in the header. If at
// least one of the match, we should continue to process the event.
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
)
) {
throw new Error("Invalid signature");
}
// Parse the JSON for the event.
return JSON.parse(body.toString());
}
app.post("/webhook", parser.raw({ type: "application/json" }), (req, res) => {
const sig = req.headers["x-coral-signature"];
let event;
try {
// Parse the JSON for the event.
event = extractEvent(req.body, sig);
} catch (err) {
return res.status(400).send(`Webhook Error: ${err.message}`);
}
// Handle the event.
switch (event.type) {
case "STORY_CREATED":
const data = event.data;
console.log(
`A Story with ID ${data.storyID} and URL ${data.storyURL} was created!`
);
break;
// ... handle other event types.
default:
// Unexpected event type
return response.status(400).end();
}
// Return a response to acknowledge receipt of the event
res.json({ received: true });
});
app.listen(4242, () => console.log("Running on port 4242"));
```
The procedure of how to verify the signatures follows.
#### **Step 1**: Extract signatures from the header
Split the header using `,` as the separator, to get a list of elements. Then
split each of these elements using `=` as the separator, to get a prefix and
value pair. The value for the prefix `sha256` corresponds to the signature(s).
#### **Step 2**: Prepare the `signed_payload` string
You can do this by taking the string contents of the body (before parsing or the
request body).
#### **Step 3**: Calculate the expected signature
Compute an HMAC signature using the SHA256 hash function. You can use the
webhook endpoint's signing secret as the key, and the above calculated
`signed_payload` as the message.
#### **Step 4**: Compare signatures
Compare the signature(s) in the header to the expected signature. To protect
against timing attacks, ensure you use a constant-time string comparison
function when comparing signatures.
## Schema
```ts
{
/**
* id is the identifier for this event, each event
* will have a unique id.
*/
id: string;
/**
* type is the name of this event, this indicates
* what is stored in the following `data` property.
* Refer to the `Events List` below to see what the
* type is for each event.
*/
type: string;
/**
* data is the object representing this particular
* event. Each type of event has a different shape
* to the data property. Refer to the `Events List`
* below to see what the data looks like for each
* event.
*/
data: object;
/**
* createdAt is the ISO 8601 representation of the
* date when this event was created.
*/
createdAt: string;
}
```
## Events Listing
- [`STORY_CREATED`](#story-created-event)
## Events
- <a id="story-created-event">**STORY_CREATED**</a>
```ts
{
id: string;
type: "STORY_CREATED";
data: {
/**
* storyID is the ID of the newly created Story.
*/
storyID: string;
/**
* storyURL is the URL of the newly created Story.
*/
storyURL: string;
}
createdAt: string;
}
```
+254 -41
View File
@@ -1,6 +1,6 @@
{
"name": "@coralproject/talk",
"version": "5.5.0",
"version": "6.0.3",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
@@ -8680,20 +8680,95 @@
"dev": true
},
"body-parser": {
"version": "1.18.2",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.18.2.tgz",
"integrity": "sha1-h2eKGdhLR9hZuDGZvVm84iKxBFQ=",
"version": "1.19.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.19.0.tgz",
"integrity": "sha512-dhEPs72UPbDnAQJ9ZKMNTP6ptJaionhP5cBb541nXPlW60Jepo9RV/a4fX4XWW9CuFNK22krhrj1+rgzifNCsw==",
"requires": {
"bytes": "3.0.0",
"bytes": "3.1.0",
"content-type": "~1.0.4",
"debug": "2.6.9",
"depd": "~1.1.1",
"http-errors": "~1.6.2",
"iconv-lite": "0.4.19",
"depd": "~1.1.2",
"http-errors": "1.7.2",
"iconv-lite": "0.4.24",
"on-finished": "~2.3.0",
"qs": "6.5.1",
"raw-body": "2.3.2",
"type-is": "~1.6.15"
"qs": "6.7.0",
"raw-body": "2.4.0",
"type-is": "~1.6.17"
},
"dependencies": {
"bytes": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.0.tgz",
"integrity": "sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg=="
},
"http-errors": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.7.2.tgz",
"integrity": "sha512-uUQBt3H/cSIVfch6i1EuPNy/YsRSOUBXTVfZ+yR7Zjez3qjBz6i9+i4zjNaoqcoFVI4lQJ5plg63TvGfRSDCRg==",
"requires": {
"depd": "~1.1.2",
"inherits": "2.0.3",
"setprototypeof": "1.1.1",
"statuses": ">= 1.5.0 < 2",
"toidentifier": "1.0.0"
}
},
"iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"requires": {
"safer-buffer": ">= 2.1.2 < 3"
}
},
"mime-db": {
"version": "1.43.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.43.0.tgz",
"integrity": "sha512-+5dsGEEovYbT8UY9yD7eE4XTc4UwJ1jBYlgaQQF38ENsKR3wj/8q8RFZrF9WIZpB2V1ArTVFUva8sAul1NzRzQ=="
},
"mime-types": {
"version": "2.1.26",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.26.tgz",
"integrity": "sha512-01paPWYgLrkqAyrlDorC1uDwl2p3qZT7yl806vW7DvDoxwXi46jsjFbg+WdwotBIk6/MbEhO/dh5aZ5sNj/dWQ==",
"requires": {
"mime-db": "1.43.0"
}
},
"qs": {
"version": "6.7.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.7.0.tgz",
"integrity": "sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ=="
},
"raw-body": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.4.0.tgz",
"integrity": "sha512-4Oz8DUIwdvoa5qMJelxipzi/iJIi40O5cGV1wNYp5hvZP8ZN0T+jiNkL0QepXs+EsQ9XJ8ipEDoiH70ySUJP3Q==",
"requires": {
"bytes": "3.1.0",
"http-errors": "1.7.2",
"iconv-lite": "0.4.24",
"unpipe": "1.0.0"
}
},
"setprototypeof": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.1.tgz",
"integrity": "sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw=="
},
"statuses": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz",
"integrity": "sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow="
},
"type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
"requires": {
"media-typer": "0.3.0",
"mime-types": "~2.1.24"
}
}
}
},
"bonjour": {
@@ -10373,7 +10448,8 @@
"content-disposition": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.2.tgz",
"integrity": "sha1-DPaLud318r55YcOoUXjLhdunjLQ="
"integrity": "sha1-DPaLud318r55YcOoUXjLhdunjLQ=",
"dev": true
},
"content-security-policy-builder": {
"version": "2.0.0",
@@ -15674,46 +15750,182 @@
"integrity": "sha512-6SK3MG/Bbhm8MsgyJAylg+ucIOU71/FzyFalcfu5nY19dH8y/z0tBJU0wrNBXD4B27EoQtqPF/9wqH0iYAd04g=="
},
"express": {
"version": "4.16.3",
"resolved": "https://registry.npmjs.org/express/-/express-4.16.3.tgz",
"integrity": "sha1-avilAjUNsyRuzEvs9rWjTSL37VM=",
"version": "4.17.1",
"resolved": "https://registry.npmjs.org/express/-/express-4.17.1.tgz",
"integrity": "sha512-mHJ9O79RqluphRrcw2X/GTh3k9tVv8YcoyY4Kkh4WDMUYKRZUq0h1o0w2rrrxBqM7VoeUVqgb27xlEMXTnYt4g==",
"requires": {
"accepts": "~1.3.5",
"accepts": "~1.3.7",
"array-flatten": "1.1.1",
"body-parser": "1.18.2",
"content-disposition": "0.5.2",
"body-parser": "1.19.0",
"content-disposition": "0.5.3",
"content-type": "~1.0.4",
"cookie": "0.3.1",
"cookie": "0.4.0",
"cookie-signature": "1.0.6",
"debug": "2.6.9",
"depd": "~1.1.2",
"encodeurl": "~1.0.2",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"finalhandler": "1.1.1",
"finalhandler": "~1.1.2",
"fresh": "0.5.2",
"merge-descriptors": "1.0.1",
"methods": "~1.1.2",
"on-finished": "~2.3.0",
"parseurl": "~1.3.2",
"parseurl": "~1.3.3",
"path-to-regexp": "0.1.7",
"proxy-addr": "~2.0.3",
"qs": "6.5.1",
"range-parser": "~1.2.0",
"safe-buffer": "5.1.1",
"send": "0.16.2",
"serve-static": "1.13.2",
"setprototypeof": "1.1.0",
"statuses": "~1.4.0",
"type-is": "~1.6.16",
"proxy-addr": "~2.0.5",
"qs": "6.7.0",
"range-parser": "~1.2.1",
"safe-buffer": "5.1.2",
"send": "0.17.1",
"serve-static": "1.14.1",
"setprototypeof": "1.1.1",
"statuses": "~1.5.0",
"type-is": "~1.6.18",
"utils-merge": "1.0.1",
"vary": "~1.1.2"
},
"dependencies": {
"cookie": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.3.1.tgz",
"integrity": "sha1-5+Ch+e9DtMi6klxcWpboBtFoc7s="
"accepts": {
"version": "1.3.7",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.7.tgz",
"integrity": "sha512-Il80Qs2WjYlJIBNzNkK6KYqlVMTbZLXgHx2oT0pU/fjRHyEp+PEfEPY0R3WCwAGVOtauxh1hOxNgIf5bv7dQpA==",
"requires": {
"mime-types": "~2.1.24",
"negotiator": "0.6.2"
}
},
"content-disposition": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.3.tgz",
"integrity": "sha512-ExO0774ikEObIAEV9kDo50o+79VCUdEB6n6lzKgGwupcVeRlhrj3qGAfwq8G6uBJjkqLrhT0qEYFcWng8z1z0g==",
"requires": {
"safe-buffer": "5.1.2"
}
},
"finalhandler": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz",
"integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==",
"requires": {
"debug": "2.6.9",
"encodeurl": "~1.0.2",
"escape-html": "~1.0.3",
"on-finished": "~2.3.0",
"parseurl": "~1.3.3",
"statuses": "~1.5.0",
"unpipe": "~1.0.0"
}
},
"http-errors": {
"version": "1.7.3",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.7.3.tgz",
"integrity": "sha512-ZTTX0MWrsQ2ZAhA1cejAwDLycFsd7I7nVtnkT3Ol0aqodaKW+0CTZDQ1uBv5whptCnc8e8HeRRJxRs0kmm/Qfw==",
"requires": {
"depd": "~1.1.2",
"inherits": "2.0.4",
"setprototypeof": "1.1.1",
"statuses": ">= 1.5.0 < 2",
"toidentifier": "1.0.0"
}
},
"inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
},
"mime": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="
},
"mime-db": {
"version": "1.43.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.43.0.tgz",
"integrity": "sha512-+5dsGEEovYbT8UY9yD7eE4XTc4UwJ1jBYlgaQQF38ENsKR3wj/8q8RFZrF9WIZpB2V1ArTVFUva8sAul1NzRzQ=="
},
"mime-types": {
"version": "2.1.26",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.26.tgz",
"integrity": "sha512-01paPWYgLrkqAyrlDorC1uDwl2p3qZT7yl806vW7DvDoxwXi46jsjFbg+WdwotBIk6/MbEhO/dh5aZ5sNj/dWQ==",
"requires": {
"mime-db": "1.43.0"
}
},
"negotiator": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.2.tgz",
"integrity": "sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw=="
},
"parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="
},
"qs": {
"version": "6.7.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.7.0.tgz",
"integrity": "sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ=="
},
"range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="
},
"safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
},
"send": {
"version": "0.17.1",
"resolved": "https://registry.npmjs.org/send/-/send-0.17.1.tgz",
"integrity": "sha512-BsVKsiGcQMFwT8UxypobUKyv7irCNRHk1T0G680vk88yf6LBByGcZJOTJCrTP2xVN6yI+XjPJcNuE3V4fT9sAg==",
"requires": {
"debug": "2.6.9",
"depd": "~1.1.2",
"destroy": "~1.0.4",
"encodeurl": "~1.0.2",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"fresh": "0.5.2",
"http-errors": "~1.7.2",
"mime": "1.6.0",
"ms": "2.1.1",
"on-finished": "~2.3.0",
"range-parser": "~1.2.1",
"statuses": "~1.5.0"
}
},
"serve-static": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.14.1.tgz",
"integrity": "sha512-JMrvUwE54emCYWlTI+hGrGv5I8dEwmco/00EvkzIIsR7MqrHonbD9pO2MOfFnpFntl7ecpZs+3mW+XbQZu9QCg==",
"requires": {
"encodeurl": "~1.0.2",
"escape-html": "~1.0.3",
"parseurl": "~1.3.3",
"send": "0.17.1"
}
},
"setprototypeof": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.1.tgz",
"integrity": "sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw=="
},
"statuses": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz",
"integrity": "sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow="
},
"type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
"requires": {
"media-typer": "0.3.0",
"mime-types": "~2.1.24"
}
}
}
},
@@ -16084,6 +16296,7 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.1.tgz",
"integrity": "sha512-Y1GUDo39ez4aHAw7MysnUD5JzYX+WaIj8I57kO3aEPT1fFRL4sr7mjei97FgnwhAyyzRYmQZaTHb2+9uZ1dPtg==",
"dev": true,
"requires": {
"debug": "2.6.9",
"encodeurl": "~1.0.2",
@@ -20265,9 +20478,9 @@
"integrity": "sha512-pKnZpbgCTfH/1NLIlOduP/V+WRXzC2MOz3Qo8xmxk8C5GudJLgK5QyLVXOSWy3ParAH7Eemurl3xjv/WXYFvMA=="
},
"ipaddr.js": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.6.0.tgz",
"integrity": "sha1-4/o1e3c9phnybpXwSdBVxyeW+Gs="
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="
},
"is-absolute": {
"version": "1.0.0",
@@ -29819,12 +30032,12 @@
}
},
"proxy-addr": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.3.tgz",
"integrity": "sha512-jQTChiCJteusULxjBp8+jftSQE5Obdl3k4cnmLA6WXtK6XFuWRnvVL7aCiBqaLPM8c4ph0S4tKna8XvmIwEnXQ==",
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.6.tgz",
"integrity": "sha512-dh/frvCBVmSsDYzw6n926jv974gddhkFPfiN8hPOi30Wax25QZyZEGveluCgliBnqmuM+UJmBErbAUFIoDbjOw==",
"requires": {
"forwarded": "~0.1.2",
"ipaddr.js": "1.6.0"
"ipaddr.js": "1.9.1"
}
},
"proxy-agent": {
+9 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@coralproject/talk",
"version": "5.5.0",
"version": "6.0.3",
"author": "The Coral Project",
"homepage": "https://coralproject.net/",
"sideEffects": [
@@ -25,8 +25,8 @@
"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",
"docs:events": "ts-node ./scripts/generateEventDocs.ts ./src/core/client/stream/events.ts ./events.md",
"docs:events": "ts-node ./scripts/generateEventDocs.ts ./src/core/client/stream/events.ts ./CLIENT_EVENTS.md",
"doctoc": "doctoc --maxlevel=3 --title '## Table of Contents' README.md CLIENT_EVENTS.md CONTRIBUTING.md WEBHOOKS.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",
"generate:css-types": "tcm src/core/client/",
@@ -83,7 +83,7 @@
"dotenv": "^6.0.0",
"dotenv-expand": "^4.2.0",
"env-rewrite": "^1.0.2",
"express": "^4.16.3",
"express": "^4.17.1",
"express-enforces-ssl": "^1.1.0",
"express-static-gzip": "^0.3.2",
"fs-extra": "^6.0.1",
@@ -400,8 +400,12 @@
"src/core/server/graph/schema/schema.graphql": [
"graphql-schema-linter"
],
"{src/core/client/stream/events.ts,scripts/generateEventDocs.ts,events.md}": [
"{src/core/client/stream/events.ts,scripts/generateEventDocs.ts,CLIENT_EVENTS.md}": [
"npm run docs:events -- --verify"
],
"{README,CLIENT_EVENTS,CONTRIBUTING,WEBHOOKS}.md": [
"npm run doctoc",
"git add"
]
},
"bundlesize": [
+2 -2
View File
@@ -1,8 +1,8 @@
/* eslint-disable no-bitwise */
import { codeBlock, stripIndent } from "common-tags";
import * as fs from "fs";
import * as path from "path";
import fs from "fs";
import path from "path";
import ts from "typescript";
interface DocEntry {
-5
View File
@@ -501,11 +501,6 @@ export default function createWebpackConfig(
exclude: [/\.(js|ts|tsx)$/, /\.html$/, /\.json$/],
loader: require.resolve("file-loader"),
options: {
// Because the resources loaded via CSS can sometimes be loaded
// directly from a CSS file, this will ensure that they are
// relative to those referencing files.
publicPath: (loaderPublicPath: string) =>
"../../" + loaderPublicPath,
name: isProduction
? "assets/media/[name].[hash:8].[ext]"
: "assets/media/[name].[ext]",
@@ -0,0 +1,45 @@
import React, { createContext, ReactNode, useMemo, useReducer } from "react";
interface State {
message: ReactNode | null;
visible: boolean;
}
type Action =
| {
type: "SET_MESSAGE";
} & State
| {
type: "CLEAR_MESSAGE";
};
// TODO (tessalt) can't figure out types for this
const NotificationContext = createContext<State | any>({
message: null,
visible: false,
});
function notificationReducer(state: State, action: Action): State {
switch (action.type) {
case "SET_MESSAGE": {
return { message: action.message, visible: true };
}
case "CLEAR_MESSAGE": {
return { message: null, visible: false };
}
default: {
throw new Error("unsupported action");
}
}
}
function NotificationProvider(props: any) {
const [state, dispatch] = useReducer(notificationReducer, {
message: null,
visible: false,
});
const value = useMemo(() => [state, dispatch], [state]);
return <NotificationContext.Provider value={value} {...props} />;
}
export { NotificationProvider, NotificationContext };
@@ -0,0 +1,12 @@
import React, { FunctionComponent } from "react";
import useNotification from "./useNotification";
const NotificationContainer: FunctionComponent<{}> = () => {
const { state } = useNotification();
if (!state.visible) {
return null;
}
return <div role="status">{state.message}</div>;
};
export default NotificationContainer;
@@ -0,0 +1,3 @@
export { NotificationProvider } from "./GlobalNotificationContext";
export { default as NotificationContainer } from "./NotificationContainer";
export { default as useNotification } from "./useNotification";
@@ -0,0 +1,39 @@
import { useRouter } from "found";
import { ReactNode, useContext } from "react";
import { NotificationContext } from "./GlobalNotificationContext";
function useNotification() {
const context = useContext(NotificationContext);
if (!context) {
throw new Error(
"useNotification must be used within a NotificationProvider"
);
}
const [state, dispatch] = context;
const { router } = useRouter();
const setMessage = (message: ReactNode, timeout?: number) => {
dispatch({ type: "SET_MESSAGE", message });
if (timeout) {
setTimeout(() => {
dispatch({ type: "CLEAR_MESSAGE" });
}, timeout);
}
router.addTransitionHook(() => {
dispatch({ type: "CLEAR_MESSAGE" });
return true;
});
};
const clearMessage = () => dispatch({ type: "CLEAR_MESSAGE" });
return {
state,
dispatch,
setMessage,
clearMessage,
};
}
export default useNotification;
+8 -1
View File
@@ -5,6 +5,10 @@ import { LogoHorizontal } from "coral-ui/components";
import { AppBar, Begin, Divider, End } from "coral-ui/components/v2/AppBar";
import { DecisionHistoryButton } from "./DecisionHistory";
import {
NotificationContainer,
NotificationProvider,
} from "./GlobalNotification";
import NavigationContainer from "./Navigation";
import UserMenuContainer from "./UserMenu";
import Version from "./Version";
@@ -32,7 +36,10 @@ const Main: FunctionComponent<Props> = ({ children, viewer }) => (
<UserMenuContainer viewer={viewer} />
</End>
</AppBar>
{children}
<NotificationProvider>
<NotificationContainer />
{children}
</NotificationProvider>
<Version />
</div>
);
@@ -28,7 +28,10 @@ exports[`renders correctly 1`] = `
/>
</withPropsOnChange(End)>
</withPropsOnChange(AppBar)>
child
<NotificationProvider>
<NotificationContainer />
child
</NotificationProvider>
<Version />
</div>
`;
@@ -0,0 +1,10 @@
.icon {
margin-right: var(--spacing-1);
}
.title {
font-weight: bold;
display: block;
margin-bottom: 5px;
}
@@ -0,0 +1,22 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent } from "react";
import { CallOut, Flex, Icon } from "coral-ui/components/v2";
import styles from "./ExperimentalCallOut.css";
const ExperimentalCallOut: FunctionComponent = ({ children }) => (
<CallOut color="primary" fullWidth>
<Flex>
<Icon size="md" className={styles.icon}>
new_releases
</Icon>
<Localized id="configure-experimentalFeature">
<span className={styles.title}>Experimental Feature</span>
</Localized>
</Flex>
<span>{children}</span>
</CallOut>
);
export default ExperimentalCallOut;
@@ -7,8 +7,13 @@ import CommentContent from "./CommentContent";
it("renders correctly", () => {
const props: PropTypesOf<typeof CommentContent> = {
suspectWords: ["worse"],
bannedWords: ["bad"],
phrases: {
locale: "en-US",
wordList: {
suspect: ["worse"],
banned: ["bad"],
},
},
className: "custom",
children: "Hello <b>Bob</b>, you bad guy",
};
@@ -19,8 +24,13 @@ it("renders correctly", () => {
it("renders empty words correctly", () => {
const props: PropTypesOf<typeof CommentContent> = {
suspectWords: [],
bannedWords: [],
phrases: {
locale: "en-US",
wordList: {
suspect: [],
banned: [],
},
},
className: "custom",
children: "Hello <b>Bob</b>, you bad guy",
};
@@ -1,7 +1,7 @@
import cn from "classnames";
import { memoize } from "lodash";
import React, { FunctionComponent } from "react";
import React, { FunctionComponent, useMemo } from "react";
import { getPhrasesRegExp, GetPhrasesRegExpOptions } from "coral-admin/helpers";
import { createPurify } from "coral-common/utils/purify";
import styles from "./CommentContent.css";
@@ -14,8 +14,7 @@ const purify = createPurify(window, false);
interface Props {
className?: string;
children: string | React.ReactElement;
suspectWords: ReadonlyArray<string>;
bannedWords: ReadonlyArray<string>;
phrases: GetPhrasesRegExpOptions;
}
function escapeHTML(unsafe: string) {
@@ -27,50 +26,11 @@ function escapeHTML(unsafe: string) {
.replace(/'/g, "&#039;");
}
function escapeRegExp(str: string) {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // $& means the whole matched string
}
// generate a regulare expression that catches the `phrases`.
function generateRegExp(phrases: ReadonlyArray<string>) {
const inner = phrases
.map(phrase =>
phrase
.split(/\s+/)
.map(word => escapeRegExp(word))
.join('[\\s"?!.]+')
)
.join("|");
const pattern = `(^|[^\\w])(${inner})(?=[^\\w]|$)`;
try {
return new RegExp(pattern, "iu");
} catch (_err) {
// IE does not support unicode support, so we'll create one without.
return new RegExp(pattern, "i");
}
}
// Generate a regular expression detecting `suspectWords` and `bannedWords` phrases.
function getPhrasesRegexp(
suspectWords: ReadonlyArray<string>,
bannedWords: ReadonlyArray<string>
) {
return generateRegExp([...suspectWords, ...bannedWords]);
}
// Memoized version as arguments rarely change.
const getPhrasesRegexpMemoized = memoize(getPhrasesRegexp);
// markPhrasesHTML looks for `supsectWords` and `bannedWords` inside `text` and highlights them by returning
// a HTML string.
function markPhrasesHTML(
text: string,
suspectWords: ReadonlyArray<string>,
bannedWords: ReadonlyArray<string>
) {
const regexp = getPhrasesRegexpMemoized(suspectWords, bannedWords);
const tokens = text.split(regexp);
// markPhrasesHTML looks for `supsect` and `banned` words inside `text` given
// the settings applied for the locale and highlights them by returning an HTML
// string.
function markPhrasesHTML(text: string, expression: RegExp) {
const tokens = text.split(expression);
if (tokens.length === 1) {
return text;
}
@@ -87,45 +47,42 @@ function markPhrasesHTML(
// markHTMLNode manipulates the node by looking for #text nodes and adding markers
// for `supsectWords` and `bannedWords`.
function markHTMLNode(
parentNode: Node,
suspectWords: ReadonlyArray<string>,
bannedWords: ReadonlyArray<string>
) {
function markHTMLNode(parentNode: Node, expression: RegExp) {
parentNode.childNodes.forEach(node => {
if (node.nodeName === "#text") {
const newContent = markPhrasesHTML(
node.textContent!,
suspectWords,
bannedWords
);
const newContent = markPhrasesHTML(node.textContent!, expression);
if (newContent !== node.textContent) {
const newNode = document.createElement("span");
newNode.innerHTML = newContent;
parentNode.replaceChild(newNode, node);
}
} else {
markHTMLNode(node, suspectWords, bannedWords);
markHTMLNode(node, expression);
}
});
}
const CommentContent: FunctionComponent<Props> = ({
suspectWords,
bannedWords,
phrases,
className,
children,
}) => {
// Cache the expression used via memo. This will reduce duplicate renders of
// this comment content when the children change but the phrase configuration
// does not change. The regExp is already cached on a deeper level
// automatically, this is just lessening that impact further.
const expression = useMemo(() => getPhrasesRegExp(phrases), [phrases]);
if (typeof children === "string") {
// We create a Shadow DOM Tree with the HTML body content and
// use it as a parser.
const node = document.createElement("div");
node.innerHTML = purify.sanitize(children);
if (suspectWords.length || bannedWords.length) {
if (expression) {
// Then we traverse it recursively and manipulate it to highlight suspect words
// and banned words.
markHTMLNode(node, suspectWords, bannedWords);
markHTMLNode(node, expression);
}
// Finally we render the content of the Shadow DOM Tree
@@ -30,10 +30,7 @@ const CommentRevisionContainer: FunctionComponent<Props> = ({
.map(c => (
<div key={c.id}>
<Timestamp>{c.createdAt}</Timestamp>
<CommentContent
suspectWords={settings.wordList.suspect}
bannedWords={settings.wordList.banned}
>
<CommentContent phrases={settings}>
{c.body ? c.body : ""}
</CommentContent>
</div>
@@ -57,6 +54,7 @@ const enhanced = withFragmentContainer<Props>({
`,
settings: graphql`
fragment CommentRevisionContainer_settings on Settings {
locale
wordList {
banned
suspect
@@ -22,10 +22,24 @@
border-color: var(--v2-palette-primary-main);
color: var(--v2-palette-primary-main);
}
&:disabled {
cursor: default;
background-color: transparent;
border-color: var(--v2-colors-grey-300);
color: var(--v2-colors-grey-300);
}
}
.invert {
background-color: var(--v2-palette-primary-main);
border-color: var(--v2-palette-primary-main);
color: var(--v2-palette-text-light);
&:disabled {
color: var(--v2-colors-pure-white);
border-color: var(--v2-colors-grey-300);
background-color: var(--v2-colors-grey-300);
}
}
@@ -142,13 +142,23 @@ $moderateCardLinkTextColor: var(--v2-colors-teal-700);
.storyTitle {
color: $moderateCardStoryTitleColor;
}
.commentOn {
font-size: var(--v2-font-size-2);
font-weight: var(--v2-font-weight-primary-regular);
font-family: var(--v2-font-family-primary);
line-height: var(--v2-line-height-reset);
margin-bottom: var(--v2-spacing-1);
}
.siteName {
font-weight: var(--v2-font-weight-primary-regular);
}
.storyTitle {
font-weight: var(--v2-font-weight-primary-semi-bold);
}
.borderless {
border-width: 0px;
box-shadow: none;
@@ -21,8 +21,14 @@ const baseProps: PropTypesOf<typeof ModerateCardN> = {
status: "undecided",
featured: false,
viewContextHref: "http://localhost/comment",
suspectWords: ["suspect"],
bannedWords: ["banned"],
phrases: {
locale: "en-US",
wordList: {
suspect: ["suspect"],
banned: ["banned"],
},
},
siteName: null,
onApprove: noop,
onReject: noop,
onFeature: noop,
@@ -10,12 +10,14 @@ import React, {
} from "react";
import { HOTKEYS } from "coral-admin/constants";
import { GetPhrasesRegExpOptions } from "coral-admin/helpers";
import { PropTypesOf } from "coral-framework/types";
import {
BaseButton,
Card,
Flex,
HorizontalGutter,
Icon,
TextLink,
Timestamp,
} from "coral-ui/components/v2";
@@ -47,11 +49,11 @@ interface Props {
featured: boolean;
moderatedBy: React.ReactNode | null;
viewContextHref: string;
suspectWords: ReadonlyArray<string>;
bannedWords: ReadonlyArray<string>;
phrases: GetPhrasesRegExpOptions;
showStory: boolean;
storyTitle?: React.ReactNode;
storyHref?: string;
siteName: string | null;
onModerateStory?: React.EventHandler<React.MouseEvent>;
onApprove: () => void;
onReject: () => void;
@@ -72,6 +74,7 @@ interface Props {
selectPrev?: () => void;
selectNext?: () => void;
onBan: () => void;
isQA?: boolean;
}
const ModerateCard: FunctionComponent<Props> = ({
@@ -85,8 +88,7 @@ const ModerateCard: FunctionComponent<Props> = ({
viewContextHref,
status,
featured,
suspectWords,
bannedWords,
phrases,
onApprove,
onReject,
onFeature,
@@ -96,6 +98,7 @@ const ModerateCard: FunctionComponent<Props> = ({
storyTitle,
storyHref,
onModerateStory,
siteName,
moderatedBy,
selected,
onFocusOrClick,
@@ -106,6 +109,7 @@ const ModerateCard: FunctionComponent<Props> = ({
selectNext,
selectPrev,
onBan,
isQA,
}) => {
const div = useRef<HTMLDivElement>(null);
useEffect(() => {
@@ -204,7 +208,7 @@ const ModerateCard: FunctionComponent<Props> = ({
<FeatureButton
featured={featured}
onClick={onFeature}
enabled={!deleted}
enabled={!deleted && !isQA}
/>
</Flex>
{inReplyTo && inReplyTo.username && (
@@ -216,11 +220,7 @@ const ModerateCard: FunctionComponent<Props> = ({
)}
</div>
<div className={styles.contentArea}>
<CommentContent
suspectWords={suspectWords}
bannedWords={bannedWords}
className={styles.content}
>
<CommentContent phrases={phrases} className={styles.content}>
{commentBody}
</CommentContent>
<div className={styles.viewContext}>
@@ -250,7 +250,15 @@ const ModerateCard: FunctionComponent<Props> = ({
</Localized>
<span>:</span>
</div>
<div className={styles.storyTitle}>{storyTitle}</div>
<div className={styles.commentOn}>
{siteName && (
<span className={styles.siteName}>
{siteName}
<Icon>keyboard_arrow_right</Icon>
</span>
)}
<span className={styles.storyTitle}>{storyTitle}</span>
</div>
<div>
<Localized id="moderate-comment-moderateStory">
<TextLink
@@ -4,18 +4,18 @@ 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 { getModerationLink } from "coral-framework/helpers";
import {
MutationProp,
withFragmentContainer,
withMutation,
} from "coral-framework/lib/relay";
import { GQLTAG, GQLUSER_STATUS } from "coral-framework/schema";
import { GQLSTORY_MODE, GQLTAG, GQLUSER_STATUS } from "coral-framework/schema";
import {
COMMENT_STATUS,
@@ -162,7 +162,7 @@ const ModerateCardContainer: FunctionComponent<Props> = ({
const handleModerateStory = useCallback(
(e: React.MouseEvent) => {
router.push(getModerationLink("default", comment.story.id));
router.push(getModerationLink({ storyID: comment.story.id }));
if (e.preventDefault) {
e.preventDefault();
}
@@ -191,9 +191,13 @@ const ModerateCardContainer: FunctionComponent<Props> = ({
}, [comment]);
const handleBanConfirm = useCallback(
async (message: string) => {
async (rejectExistingComments: boolean, message: string) => {
if (comment.author) {
await banUser({ userID: comment.author.id, message });
await banUser({
userID: comment.author.id,
message,
rejectExistingComments,
});
}
setShowBanModal(false);
},
@@ -218,8 +222,7 @@ const ModerateCardContainer: FunctionComponent<Props> = ({
status={getStatus(comment)}
featured={isFeatured(comment)}
viewContextHref={comment.permalink}
suspectWords={settings.wordList.suspect}
bannedWords={settings.wordList.banned}
phrases={settings}
onApprove={handleApprove}
onReject={handleReject}
onFeature={onFeature}
@@ -227,6 +230,7 @@ const ModerateCardContainer: FunctionComponent<Props> = ({
selected={selected}
selectPrev={selectPrev}
selectNext={selectNext}
siteName={settings.multisite ? comment.site.name : null}
onBan={openBanModal}
moderatedBy={
<ModeratedByContainer
@@ -242,12 +246,13 @@ const ModerateCardContainer: FunctionComponent<Props> = ({
<NotAvailable />
)
}
storyHref={getModerationLink("default", comment.story.id)}
storyHref={getModerationLink({ storyID: comment.story.id })}
onModerateStory={handleModerateStory}
mini={mini}
hideUsername={hideUsername}
deleted={comment.deleted ? comment.deleted : false}
edited={comment.editing.edited}
isQA={comment.story.settings.mode === GQLSTORY_MODE.QA}
/>
</FadeInTransition>
<BanModal
@@ -299,6 +304,13 @@ const enhanced = withFragmentContainer<Props>({
metadata {
title
}
settings {
mode
}
}
site {
id
name
}
permalink
enteredLive
@@ -310,10 +322,13 @@ const enhanced = withFragmentContainer<Props>({
`,
settings: graphql`
fragment ModerateCardContainer_settings on Settings {
locale
wordList {
banned
suspect
}
multisite
featureFlags
...MarkersContainer_settings
}
`,
@@ -47,16 +47,19 @@ exports[`renders approved correctly 1`] = `
className="ModerateCard-contentArea"
>
<CommentContent
bannedWords={
Array [
"banned",
]
}
className="ModerateCard-content"
suspectWords={
Array [
"suspect",
]
phrases={
Object {
"locale": "en-US",
"wordList": Object {
"banned": Array [
"banned",
],
"suspect": Array [
"suspect",
],
},
}
}
>
content
@@ -177,16 +180,19 @@ exports[`renders correctly 1`] = `
className="ModerateCard-contentArea"
>
<CommentContent
bannedWords={
Array [
"banned",
]
}
className="ModerateCard-content"
suspectWords={
Array [
"suspect",
]
phrases={
Object {
"locale": "en-US",
"wordList": Object {
"banned": Array [
"banned",
],
"suspect": Array [
"suspect",
],
},
}
}
>
content
@@ -307,16 +313,19 @@ exports[`renders dangling correctly 1`] = `
className="ModerateCard-contentArea"
>
<CommentContent
bannedWords={
Array [
"banned",
]
}
className="ModerateCard-content"
suspectWords={
Array [
"suspect",
]
phrases={
Object {
"locale": "en-US",
"wordList": Object {
"banned": Array [
"banned",
],
"suspect": Array [
"suspect",
],
},
}
}
>
content
@@ -437,16 +446,19 @@ exports[`renders rejected correctly 1`] = `
className="ModerateCard-contentArea"
>
<CommentContent
bannedWords={
Array [
"banned",
]
}
className="ModerateCard-content"
suspectWords={
Array [
"suspect",
]
phrases={
Object {
"locale": "en-US",
"wordList": Object {
"banned": Array [
"banned",
],
"suspect": Array [
"suspect",
],
},
}
}
>
content
@@ -576,16 +588,19 @@ exports[`renders reply correctly 1`] = `
className="ModerateCard-contentArea"
>
<CommentContent
bannedWords={
Array [
"banned",
]
}
className="ModerateCard-content"
suspectWords={
Array [
"suspect",
]
phrases={
Object {
"locale": "en-US",
"wordList": Object {
"banned": Array [
"banned",
],
"suspect": Array [
"suspect",
],
},
}
}
>
content
@@ -706,16 +721,19 @@ exports[`renders story info 1`] = `
className="ModerateCard-contentArea"
>
<CommentContent
bannedWords={
Array [
"banned",
]
}
className="ModerateCard-content"
suspectWords={
Array [
"suspect",
]
phrases={
Object {
"locale": "en-US",
"wordList": Object {
"banned": Array [
"banned",
],
"suspect": Array [
"suspect",
],
},
}
}
>
content
@@ -761,9 +779,13 @@ exports[`renders story info 1`] = `
</span>
</div>
<div
className="ModerateCard-storyTitle"
className="ModerateCard-commentOn"
>
Cancer cured!
<span
className="ModerateCard-storyTitle"
>
Cancer cured!
</span>
</div>
<div>
<Localized
@@ -870,16 +892,19 @@ exports[`renders tombstoned when comment is deleted 1`] = `
className="ModerateCard-contentArea"
>
<CommentContent
bannedWords={
Array [
"banned",
]
}
className="ModerateCard-content"
suspectWords={
Array [
"suspect",
]
phrases={
Object {
"locale": "en-US",
"wordList": Object {
"banned": Array [
"banned",
],
"suspect": Array [
"suspect",
],
},
}
}
>
<Localized
@@ -0,0 +1,45 @@
.root {
margin-top: var(--v2-spacing-2);
}
.dropdown {
max-height: 15em;
width: calc(20 * var(--mini-unit));
overflow: auto;
}
.wrapper {
overflow-x: hidden;
/* adjust for button line-height being > 1 */
margin-top: -2px;
}
.button {
color: var(--v2-colors-mono-500) !important;
border-width: 0;
width: calc(20 * var(--mini-unit));
margin-right: calc(var(--v2-spacing-1) / 2);
font-size: var(--v2-font-size-3);
line-height: var(--v2-line-height-min);
justify-content: space-between;
}
.buttonIconLeft {
width: 20px;
margin-right: calc(var(--v2-spacing-1) / 2);
}
.buttonIconRight {
width: 16px;
}
.buttonIconLeft,
.buttonIconRight {
/* adjust for button line-height being > 1 */
margin-top: -2px;
}
.buttonText {
overflow-x: hidden;
text-overflow: ellipsis;
}
@@ -0,0 +1,98 @@
import cn from "classnames";
import React, { FunctionComponent } from "react";
import AutoLoadMore from "coral-admin/components/AutoLoadMore";
import { IntersectionProvider } from "coral-framework/lib/intersection";
import {
Button,
ButtonIcon,
ClickOutside,
Dropdown,
Flex,
Popover,
Spinner,
} from "coral-ui/components/v2";
import styles from "./PaginatedSelect.css";
interface Props {
onLoadMore: () => void;
icon?: string;
hasMore: boolean;
disableLoadMore: boolean;
loading: boolean;
selected: React.ReactNode;
className?: string;
}
const PaginatedSelect: FunctionComponent<Props> = ({
loading,
onLoadMore,
disableLoadMore,
hasMore,
children,
icon,
selected,
className,
}) => {
return (
<Popover
id=""
placement="bottom-end"
modifiers={{ arrow: { enabled: false }, offset: { offset: "0, 4" } }}
body={({ toggleVisibility }) => (
<ClickOutside onClickOutside={toggleVisibility}>
<IntersectionProvider>
<Dropdown className={styles.dropdown}>
{children}
{loading && (
<Flex justifyContent="center">
<Spinner />
</Flex>
)}
{hasMore && (
<Flex justifyContent="center">
<AutoLoadMore
disableLoadMore={disableLoadMore}
onLoadMore={onLoadMore}
/>
</Flex>
)}
</Dropdown>
</IntersectionProvider>
</ClickOutside>
)}
>
{({ toggleVisibility, ref, visible }) => (
<Button
className={cn(styles.button, className)}
variant="flat"
adornmentLeft
color="mono"
onClick={toggleVisibility}
ref={ref}
uppercase={false}
>
{icon && (
<ButtonIcon className={styles.buttonIconLeft}>{icon}</ButtonIcon>
)}
<Flex alignItems="center" className={styles.wrapper}>
{selected}
</Flex>
{!visible && (
<ButtonIcon className={styles.buttonIconRight}>
keyboard_arrow_down
</ButtonIcon>
)}
{visible && (
<ButtonIcon className={styles.buttonIconRight}>
keyboard_arrow_up
</ButtonIcon>
)}
</Button>
)}
</Popover>
);
};
export default PaginatedSelect;
@@ -0,0 +1 @@
export { default } from "./PaginatedSelect";
@@ -22,7 +22,7 @@ interface Props {
username: string | null;
open: boolean;
onClose: () => void;
onConfirm: (message?: string) => void;
onConfirm: (rejectExistingComments: boolean, message?: string) => void;
getMessage: GetMessage;
}
@@ -44,8 +44,8 @@ const BanModal: FunctionComponent<Props> = ({
}, [getMessage, username]);
const onFormSubmit = useCallback(
({ emailMessage }) => {
onConfirm(emailMessage);
({ emailMessage, rejectExistingComments }) => {
onConfirm(rejectExistingComments, emailMessage);
},
[onConfirm]
);
@@ -83,12 +83,22 @@ const BanModal: FunctionComponent<Props> = ({
onSubmit={onFormSubmit}
initialValues={{
showMessage: false,
rejectExistingComments: false,
emailMessage: getDefaultMessage,
}}
>
{({ handleSubmit }) => (
<form onSubmit={handleSubmit}>
<HorizontalGutter spacing={3}>
<Field type="checkbox" name="rejectExistingComments">
{({ input }) => (
<Localized id="community-banModal-reject-existing">
<CheckBox {...input} id="banModal-rejectExisting">
Reject all comments by this user
</CheckBox>
</Localized>
)}
</Field>
<Field type="checkbox" name="showMessage">
{({ input }) => (
<Localized id="community-banModal-customize">
@@ -111,8 +111,8 @@ const UserStatusChangeContainer: FunctionComponent<Props> = props => {
);
const handleBanConfirm = useCallback(
message => {
banUser({ userID: user.id, message });
(rejectExistingComments, message) => {
banUser({ userID: user.id, message, rejectExistingComments });
setShowBanned(false);
},
[user, setShowBanned]
@@ -0,0 +1,5 @@
import { urls } from "coral-framework/helpers";
export default function getEndpointLink(endpointID: string) {
return `${urls.admin.configureWebhookEndpoint}/${endpointID}`;
}
@@ -1,10 +0,0 @@
const basePath = "/admin/moderate";
export default function getModerationLink(
queue?: "default" | "reported" | "pending" | "unmoderated" | "rejected",
storyID?: string | null
) {
const queuePart = queue && queue !== "default" ? `/${queue}` : "";
const storyPart = storyID ? `/${encodeURIComponent(storyID)}` : "";
return `${basePath}${queuePart}${storyPart}`;
}
@@ -0,0 +1,64 @@
import { LanguageCode } from "coral-common/helpers";
import { createWordListRegExp } from "coral-common/utils";
export interface GetPhrasesRegExpOptions {
locale: string;
wordList: {
banned: ReadonlyArray<string>;
suspect: ReadonlyArray<string>;
};
}
export function getPhrasesRegExp({
locale,
wordList: { banned, suspect },
}: GetPhrasesRegExpOptions) {
if (banned.length === 0 && suspect.length === 0) {
return null;
}
return createWordListRegExp(locale as LanguageCode, [...banned, ...suspect]);
}
// cache is used as a global validator to the cached RegExp used by the
// application. We expect that generally, there is only ever one word list used
// by the client at a time, so this ensures that we only re-create the word list
// if we must.
const cache = {
keys: {
locale: "",
suspect: [] as ReadonlyArray<string>,
banned: [] as ReadonlyArray<string>,
},
value: null as RegExp | null,
};
export default function(options: GetPhrasesRegExpOptions) {
// We assume that the cache is valid unless one of the below checks fails.
let expired = false;
// Check the locale.
if (cache.keys.locale !== options.locale) {
cache.keys.locale = options.locale;
expired = true;
}
// Check the banned words list.
if (cache.keys.banned !== options.wordList.banned) {
cache.keys.banned = options.wordList.banned;
expired = true;
}
// Check the suspect words list.
if (cache.keys.suspect !== options.wordList.suspect) {
cache.keys.suspect = options.wordList.suspect;
expired = true;
}
// If the cache is expired, or the value doesn't exist, regenerate it.
if (expired) {
cache.value = getPhrasesRegExp(options);
}
return cache.value;
}
@@ -12,17 +12,29 @@ import {
export default function getQueueConnection(
store: RecordSourceSelectorProxy | RecordSourceProxy,
queue: GQLMODERATION_QUEUE_RL | "REJECTED",
storyID?: string | null
queue: GQLMODERATION_QUEUE_RL | "REJECTED" | "APPROVED",
storyID?: string | null,
siteID?: string | null
): RecordProxy | null | undefined {
const root = store.getRoot();
if (queue === "REJECTED") {
return ConnectionHandler.getConnection(root, "RejectedQueue_comments", {
status: GQLCOMMENT_STATUS.REJECTED,
storyID,
siteID,
});
}
const queuesRecord = root.getLinkedRecord("moderationQueues", { storyID })!;
if (queue === "APPROVED") {
return ConnectionHandler.getConnection(root, "ApprovedQueue_comments", {
status: GQLCOMMENT_STATUS.APPROVED,
storyID,
siteID,
});
}
const queuesRecord = root.getLinkedRecord("moderationQueues", {
storyID,
siteID,
})!;
if (!queuesRecord) {
return undefined;
}
+4 -1
View File
@@ -1,2 +1,5 @@
export { default as getQueueConnection } from "./getQueueConnection";
export { default as getModerationLink } from "./getModerationLink";
export {
default as getPhrasesRegExp,
GetPhrasesRegExpOptions,
} from "./getPhrasesRegExp";
@@ -0,0 +1,36 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`get access token from url 1`] = `
"{
\\"__id\\": \\"client:root.local\\",
\\"__typename\\": \\"Local\\",
\\"accessToken\\": \\"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiIzMWIyNjU5MS00ZTlhLTQzODgtYTdmZi1lMWJkYzVkOTdjY2UifQ==\\",
\\"accessTokenExp\\": null,
\\"accessTokenJTI\\": \\"31b26591-4e9a-4388-a7ff-e1bdc5d97cce\\",
\\"redirectPath\\": null,
\\"authView\\": \\"SIGN_IN\\",
\\"authError\\": null
}"
`;
exports[`init local state 1`] = `
"{
\\"client:root\\": {
\\"__id\\": \\"client:root\\",
\\"__typename\\": \\"__Root\\",
\\"local\\": {
\\"__ref\\": \\"client:root.local\\"
}
},
\\"client:root.local\\": {
\\"__id\\": \\"client:root.local\\",
\\"__typename\\": \\"Local\\",
\\"accessToken\\": \\"\\",
\\"accessTokenExp\\": null,
\\"accessTokenJTI\\": null,
\\"redirectPath\\": null,
\\"authView\\": \\"SIGN_IN\\",
\\"authError\\": null
}
}"
`;
@@ -0,0 +1,49 @@
import { Environment, RecordSource } from "relay-runtime";
import { LOCAL_ID } from "coral-framework/lib/relay";
import {
createAccessToken,
createRelayEnvironment,
replaceHistoryLocation,
} from "coral-framework/testHelpers";
import initLocalState from "./initLocalState";
let environment: Environment;
let source: RecordSource;
const context = {
localStorage: window.localStorage,
sessionStorage: window.sessionStorage,
};
beforeEach(() => {
source = new RecordSource();
environment = createRelayEnvironment({
source,
initLocalState: false,
});
});
it("init local state", async () => {
await initLocalState(environment, context as any);
expect(JSON.stringify(source.toJSON(), null, 2)).toMatchSnapshot();
});
it("get access token from url", async () => {
const restoreHistoryLocation = replaceHistoryLocation(
`http://localhost/#accessToken=${createAccessToken()}`
);
await initLocalState(environment, context as any);
expect(JSON.stringify(source.get(LOCAL_ID), null, 2)).toMatchSnapshot();
restoreHistoryLocation();
});
it("get error from url", async () => {
const restoreHistoryLocation = replaceHistoryLocation(
`http://localhost/#error=error`
);
await initLocalState(environment, context as any);
expect(source.get(LOCAL_ID)!.authError).toBe("error");
restoreHistoryLocation();
});
@@ -8,6 +8,7 @@ enum View {
CREATE_USERNAME
CREATE_PASSWORD
ADD_EMAIL_ADDRESS
LINK_ACCOUNT
}
extend type Comment {
@@ -31,6 +32,9 @@ type Local {
redirectPath: String
authView: View
authError: String
# Duplicate email found when adding email during auth.
authDuplicateEmail: String
siteID: String
}
extend type Query {
@@ -81,6 +81,7 @@ const RejectCommentMutation = createMutation(
getQueueConnection(store, "REPORTED", input.storyID),
getQueueConnection(store, "PENDING", input.storyID),
getQueueConnection(store, "UNMODERATED", input.storyID),
getQueueConnection(store, "APPROVED", input.storyID),
].filter(c => c);
connections.forEach(con =>
ConnectionHandler.deleteNode(con!, input.commentID)
+67 -5
View File
@@ -9,20 +9,28 @@ import { createAuthCheckRoute } from "./routes/AuthCheck";
import CommunityRoute from "./routes/Community";
import ConfigureRoute from "./routes/Configure";
import {
AddWebhookEndpointRoute,
AdvancedConfigRoute,
AuthConfigRoute,
ConfigureWebhookEndpointRoute,
EmailConfigRoute,
GeneralConfigRoute,
ModerationConfigRoute,
OrganizationConfigRoute,
SlackConfigRoute,
WebhookEndpointsConfigRoute,
WordListConfigRoute,
} from "./routes/Configure/sections";
import { Sites } from "./routes/Configure/sections/Sites";
import AddSiteRoute from "./routes/Configure/sections/Sites/AddSiteRoute";
import SiteRoute from "./routes/Configure/sections/Sites/SiteRoute";
import WebhookEndpointsLayout from "./routes/Configure/sections/WebhookEndpoints/WebhookEndpointsLayout";
import ForgotPasswordRoute from "./routes/ForgotPassword";
import InviteRoute from "./routes/Invite";
import LoginRoute from "./routes/Login";
import ModerateRoute from "./routes/Moderate";
import {
ApprovedQueueRoute,
PendingQueueRoute,
RejectedQueueRoute,
ReportedQueueRoute,
@@ -45,17 +53,58 @@ export default makeRouteConfig(
<Route path="moderate" {...ModerateRoute.routeConfig}>
<Redirect from="/" to="/admin/moderate/reported" />
<Route path="reported" {...ReportedQueueRoute.routeConfig} />
<Route path="reported/:storyID" {...ReportedQueueRoute.routeConfig} />
<Route
path="reported/stories/:storyID"
{...ReportedQueueRoute.routeConfig}
/>
<Route
path="reported/sites/:siteID"
{...ReportedQueueRoute.routeConfig}
/>
<Route path="pending" {...PendingQueueRoute.routeConfig} />
<Route path="pending/:storyID" {...PendingQueueRoute.routeConfig} />
<Route
path="pending/stories/:storyID"
{...PendingQueueRoute.routeConfig}
/>
<Route
path="pending/sites/:siteID"
{...PendingQueueRoute.routeConfig}
/>
<Route path="unmoderated" {...UnmoderatedQueueRoute.routeConfig} />
<Route
path="unmoderated/:storyID"
path="unmoderated/stories/:storyID"
{...UnmoderatedQueueRoute.routeConfig}
/>
<Route
path="unmoderated/sites/:siteID"
{...UnmoderatedQueueRoute.routeConfig}
/>
<Route path="approved" {...ApprovedQueueRoute.routeConfig} />
<Route path="rejected" {...RejectedQueueRoute.routeConfig} />
<Route path="rejected/:storyID" {...RejectedQueueRoute.routeConfig} />
<Redirect from=":storyID" to="/admin/moderate/reported/:storyID" />
<Route
path="rejected/stories/:storyID"
{...RejectedQueueRoute.routeConfig}
/>
<Route
path="rejected/sites/:siteID"
{...RejectedQueueRoute.routeConfig}
/>
<Route
path="approved/stories/:storyID"
{...ApprovedQueueRoute.routeConfig}
/>
<Route
path="approved/sites/:siteID"
{...ApprovedQueueRoute.routeConfig}
/>
<Redirect
from="stories/:storyID"
to="/admin/moderate/reported/stories/:storyID"
/>
<Redirect
from="sites/:siteID"
to="/admin/moderate/reported/sites/:siteID"
/>
</Route>
<Route path="stories" {...StoriesRoute.routeConfig} />
<Route path="community" {...CommunityRoute.routeConfig} />
@@ -78,6 +127,19 @@ export default makeRouteConfig(
<Route path="email" {...EmailConfigRoute.routeConfig} />
<Route path="slack" {...SlackConfigRoute.routeConfig} />
</Route>
<Route path="configure/webhooks" Component={WebhookEndpointsLayout}>
<Route path="/" {...WebhookEndpointsConfigRoute.routeConfig} />
<Route path="add" {...AddWebhookEndpointRoute.routeConfig} />
<Route
path="endpoint/:webhookEndpointID"
{...ConfigureWebhookEndpointRoute.routeConfig}
/>
</Route>
<Route path="configure/organization/sites" Component={Sites}>
<Redirect from="/" to="/admin/configure/organization/sites/new" />
<Route path="new" {...AddSiteRoute.routeConfig} />
<Route path=":siteID" {...SiteRoute.routeConfig} />
</Route>
</Route>
</Route>
</Route>
@@ -50,7 +50,7 @@ function createAuthCheckRoute(check: CheckParams) {
}
private shouldRedirectTo(props: Props = this.props) {
if (!props.data || props.data.viewer) {
if (!props.data || (props.data.viewer && props.data.viewer.email)) {
return false;
}
return true;
@@ -7,10 +7,9 @@ import { Form, FormSpy } from "react-final-form";
import MainLayout from "coral-admin/components/MainLayout";
import { Button, CallOut, HorizontalGutter } from "coral-ui/components/v2";
import ConfigureLinks from "./ConfigureLinks";
import Layout from "./Layout";
import Link from "./Link";
import Main from "./Main";
import Navigation from "./Navigation";
import SideBar from "./SideBar";
interface Props {
@@ -32,34 +31,7 @@ const Configure: FunctionComponent<Props> = ({
<Layout>
<SideBar>
<HorizontalGutter size="double">
<Navigation>
<Localized id="configure-sideBarNavigation-general">
<Link to="/admin/configure/general">General</Link>
</Localized>
<Localized id="configure-sideBarNavigation-organization">
<Link to="/admin/configure/organization">Organization</Link>
</Localized>
<Localized id="configure-sideBarNavigation-moderation">
<Link to="/admin/configure/moderation">Moderation</Link>
</Localized>
<Localized id="configure-sideBarNavigation-bannedAndSuspectWords">
<Link to="/admin/configure/wordList">
Banned and Suspect Words
</Link>
</Localized>
<Localized id="configure-sideBarNavigation-authentication">
<Link to="/admin/configure/auth">Authentication</Link>
</Localized>
<Localized id="configure-sideBarNavigation-email">
<Link to="/admin/configure/email">Email</Link>
</Localized>
<Localized id="configure-sideBarNavigation-slack">
<Link to="/admin/configure/slack">Slack</Link>
</Localized>
<Localized id="configure-sideBarNavigation-advanced">
<Link to="/admin/configure/advanced">Advanced</Link>
</Localized>
</Navigation>
<ConfigureLinks />
</HorizontalGutter>
<HorizontalGutter size="double">
<Localized id="configure-sideBar-saveChanges">
@@ -0,0 +1,41 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent } from "react";
import Link from "./Link";
import Navigation from "./Navigation";
const ConfigureLinks: FunctionComponent<{}> = () => {
return (
<Navigation>
<Localized id="configure-sideBarNavigation-general">
<Link to="/admin/configure/general">General</Link>
</Localized>
<Localized id="configure-sideBarNavigation-organization">
<Link to="/admin/configure/organization">Organization</Link>
</Localized>
<Localized id="configure-sideBarNavigation-moderation">
<Link to="/admin/configure/moderation">Moderation</Link>
</Localized>
<Localized id="configure-sideBarNavigation-bannedAndSuspectWords">
<Link to="/admin/configure/wordList">Banned and Suspect Words</Link>
</Localized>
<Localized id="configure-sideBarNavigation-authentication">
<Link to="/admin/configure/auth">Authentication</Link>
</Localized>
<Localized id="configure-sideBarNavigation-email">
<Link to="/admin/configure/email">Email</Link>
</Localized>
<Localized id="configure-sideBarNavigation-slack">
<Link to="/admin/configure/slack">Slack</Link>
</Localized>
<Localized id="configure-sideBarNavigation-webhooks">
<Link to="/admin/configure/webhooks">Webhooks</Link>
</Localized>
<Localized id="configure-sideBarNavigation-advanced">
<Link to="/admin/configure/advanced">Advanced</Link>
</Localized>
</Navigation>
);
};
export default ConfigureLinks;
@@ -24,7 +24,7 @@ class NavigationWarningContainer extends React.Component<Props> {
);
this.removeTransitionHook = props.router.addTransitionHook(() =>
this.props.active ? warningMessage : true
this.props.active ? warningMessage : undefined
);
}
@@ -12,8 +12,6 @@ import { AdvancedConfigContainer_settings } from "coral-admin/__generated__/Adva
import CommentStreamLiveUpdatesContainer from "./CommentStreamLiveUpdatesContainer";
import CustomCSSConfig from "./CustomCSSConfig";
import EmbedCodeContainer from "./EmbedCodeContainer";
import PermittedDomainsConfig from "./PermittedDomainsConfig";
import StoryCreationConfig from "./StoryCreationConfig";
interface Props {
@@ -29,13 +27,11 @@ const AdvancedConfigContainer: React.FunctionComponent<Props> = ({
useMemo(() => form.initialize(purgeMetadata(settings)), []);
return (
<HorizontalGutter size="double" data-testid="configure-advancedContainer">
<EmbedCodeContainer settings={settings} />
<CustomCSSConfig disabled={submitting} />
<CommentStreamLiveUpdatesContainer
disabled={submitting}
settings={settings}
/>
<PermittedDomainsConfig disabled={submitting} />
<StoryCreationConfig disabled={submitting} />
</HorizontalGutter>
);
@@ -45,11 +41,8 @@ const enhanced = withFragmentContainer<Props>({
settings: graphql`
fragment AdvancedConfigContainer_settings on Settings {
...CustomCSSConfig_formValues @relay(mask: false)
...PermittedDomainsConfig_formValues @relay(mask: false)
...CommentStreamLiveUpdates_formValues @relay(mask: false)
...StoryCreationConfig_formValues @relay(mask: false)
...EmbedCodeContainer_settings
...CommentStreamLiveUpdatesContainer_settings
}
`,
@@ -1,26 +0,0 @@
import React, { FunctionComponent } from "react";
import { graphql } from "react-relay";
import { withFragmentContainer } from "coral-framework/lib/relay";
import { EmbedCodeContainer_settings } from "coral-admin/__generated__/EmbedCodeContainer_settings.graphql";
import EmbedCode from "./EmbedCode";
interface Props {
settings: EmbedCodeContainer_settings;
}
const EmbedCodeContainer: FunctionComponent<Props> = ({ settings }) => {
return <EmbedCode staticURI={settings.staticURI} />;
};
const enhanced = withFragmentContainer<Props>({
settings: graphql`
fragment EmbedCodeContainer_settings on Settings {
staticURI
}
`,
})(EmbedCodeContainer);
export default enhanced;
@@ -1,71 +0,0 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent } from "react";
import { Field } from "react-final-form";
import { graphql } from "react-relay";
import { formatStringList, parseStringList } from "coral-framework/lib/form";
import { validateStrictURLList } from "coral-framework/lib/validation";
import { FormField, FormFieldDescription } from "coral-ui/components/v2";
import ConfigBox from "../../ConfigBox";
import Header from "../../Header";
import TextFieldWithValidation from "../../TextFieldWithValidation";
// eslint-disable-next-line no-unused-expressions
graphql`
fragment PermittedDomainsConfig_formValues on Settings {
allowedDomains
}
`;
interface Props {
disabled: boolean;
}
const PermittedDomainsConfig: FunctionComponent<Props> = ({ disabled }) => (
<ConfigBox
title={
<Localized id="configure-advanced-permittedDomains">
<Header htmlFor="configure-advanced-allowedDomains">
Permitted domains
</Header>
</Localized>
}
>
<FormField>
<Localized
id="configure-advanced-permittedDomains-description"
strong={<strong />}
>
<FormFieldDescription>
The domains you would like to permit for Coral, e.g. your local,
staging and production environments including the scheme (ex.
http://localhost:3000, https://staging.domain.com,
https://domain.com).
</FormFieldDescription>
</Localized>
<Field
name="allowedDomains"
parse={parseStringList}
format={formatStringList}
validate={validateStrictURLList}
>
{({ input, meta }) => (
<TextFieldWithValidation
{...input}
id={`configure-advanced-${input.name}`}
disabled={disabled}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck={false}
meta={meta}
fullWidth
/>
)}
</Field>
</FormField>
</ConfigBox>
);
export default PermittedDomainsConfig;
@@ -33,6 +33,7 @@ const ClientSecretField: FunctionComponent<Props> = ({
{...input}
id={input.name}
disabled={disabled || meta.submitting}
autoComplete="new-password"
// TODO: (wyattjoh) figure out how to add translations to these props
hidePasswordTitle="Show Client Secret"
showPasswordTitle="Hide Client Secret"
@@ -162,6 +162,7 @@ const SMTP: FunctionComponent<Props> = ({ disabled }) => (
{...input}
id={input.name}
disabled={disabled || !enabled}
autoComplete="new-password"
fullWidth
color={colorFromMeta(meta)}
/>
@@ -32,9 +32,10 @@ const APIKeyField: FunctionComponent<Props> = ({
{...input}
id={`configure-moderation-${input.name}`}
disabled={disabled}
autoComplete="new-password"
// TODO: (wyattjoh) figure out how to add translations to these props
hidePasswordTitle="Show API Key"
showPasswordTitle="Hide API Key"
hidePasswordTitle="Hide API Key"
showPasswordTitle="Show API Key"
color={colorFromMeta(meta)}
fullWidth
/>
@@ -156,7 +156,7 @@ const PerspectiveConfig: FunctionComponent<Props> = ({ disabled }) => {
externalLink={
<ExternalLink
href={
"https://github.com/conversationai/perspectiveapi/blob/master/api_reference.md#models"
"https://github.com/conversationai/perspectiveapi/blob/master/2-api/models.md"
}
/>
}
@@ -0,0 +1,10 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent } from "react";
const EmptySitesMessage: FunctionComponent = props => (
<Localized id="sites-emptyMessage">
<div>We could not find any sites matching your criteria.</div>
</Localized>
);
export default EmptySitesMessage;
@@ -6,7 +6,6 @@ import {
purgeMetadata,
withFragmentContainer,
} from "coral-framework/lib/relay";
import { HorizontalGutter } from "coral-ui/components";
import { OrganizationConfigContainer_settings as SettingsData } from "coral-admin/__generated__/OrganizationConfigContainer_settings.graphql";
@@ -25,22 +24,19 @@ const OrganizationConfigContainer: React.FunctionComponent<Props> = ({
const form = useForm();
useMemo(() => form.initialize(purgeMetadata(settings)), []);
return (
<HorizontalGutter
size="double"
data-testid="configure-organizationContainer"
>
<>
<OrganizationNameConfig disabled={submitting} />
<OrganizationContactEmailConfig disabled={submitting} />
<OrganizationURLConfig disabled={submitting} />
</HorizontalGutter>
<OrganizationContactEmailConfig disabled={submitting} />
</>
);
};
const enhanced = withFragmentContainer<Props>({
settings: graphql`
fragment OrganizationConfigContainer_settings on Settings {
...OrganizationNameConfig_formValues @relay(mask: false)
...OrganizationContactEmailConfig_formValues @relay(mask: false)
...OrganizationURLConfig_formValues @relay(mask: false)
...OrganizationContactEmailConfig_formValues @relay(mask: false)
}
`,
})(OrganizationConfigContainer);
@@ -2,11 +2,12 @@ import React from "react";
import { graphql } from "react-relay";
import { withRouteConfig } from "coral-framework/lib/router";
import { Delay, Spinner } from "coral-ui/components/v2";
import { Delay, HorizontalGutter, Spinner } from "coral-ui/components/v2";
import { OrganizationConfigRouteQueryResponse } from "coral-admin/__generated__/OrganizationConfigRouteQuery.graphql";
import OrganizationConfigContainer from "./OrganizationConfigContainer";
import SitesConfigContainer from "./SitesConfigContainer";
interface Props {
data: OrganizationConfigRouteQueryResponse | null;
@@ -23,10 +24,16 @@ class OrganizationConfigRoute extends React.Component<Props> {
);
}
return (
<OrganizationConfigContainer
settings={this.props.data.settings}
submitting={this.props.submitting}
/>
<HorizontalGutter
spacing={4}
data-testid="configure-organizationContainer"
>
<OrganizationConfigContainer
settings={this.props.data.settings}
submitting={this.props.submitting}
/>
<SitesConfigContainer query={this.props.data} />
</HorizontalGutter>
);
}
}
@@ -37,6 +44,7 @@ const enhanced = withRouteConfig<Props>({
settings {
...OrganizationConfigContainer_settings
}
...SitesConfigContainer_query
}
`,
cacheConfig: { force: true },
@@ -0,0 +1,49 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent } from "react";
import { graphql, withFragmentContainer } from "coral-framework/lib/relay";
import { Button, Flex, Icon } from "coral-ui/components/v2";
import { TableCell, TableRow } from "coral-ui/components/v2/Table";
import { SiteRowContainer_site } from "coral-admin/__generated__/SiteRowContainer_site.graphql";
interface Props {
site: SiteRowContainer_site;
}
const SiteRowContainer: FunctionComponent<Props> = ({ site }) => {
return (
<TableRow>
<TableCell>{site.name}</TableCell>
<TableCell>
<Flex justifyContent="flex-end">
<Localized
id="configure-sites-site-details"
icon={<Icon>keyboard_arrow_right</Icon>}
>
<Button
variant="text"
to={`/admin/configure/organization/sites/${site.id}`}
iconRight
>
Details
<Icon>keyboard_arrow_right</Icon>
</Button>
</Localized>
</Flex>
</TableCell>
</TableRow>
);
};
const enhanced = withFragmentContainer<Props>({
site: graphql`
fragment SiteRowContainer_site on Site {
id
name
createdAt
}
`,
})(SiteRowContainer);
export default enhanced;
@@ -0,0 +1,63 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent } from "react";
import { PropTypesOf } from "coral-framework/types";
import { Button, FormFieldDescription, Icon } from "coral-ui/components/v2";
import ConfigBox from "../../ConfigBox";
import Header from "../../Header";
import SiteRowContainer from "./SiteRowContainer";
import SitesTable from "./SitesTable";
interface Props {
sites: Array<{ id: string } & PropTypesOf<typeof SiteRowContainer>["site"]>;
onLoadMore: () => void;
hasMore: boolean;
disableLoadMore: boolean;
loading: boolean;
}
const SitesConfig: FunctionComponent<Props> = ({
sites,
loading,
disableLoadMore,
onLoadMore,
hasMore,
}) => {
return (
<ConfigBox
title={
<Localized id="configure-organization-sites">
<Header htmlFor="configure-organization-organization.sites">
Sites
</Header>
</Localized>
}
>
<Localized id="configure-organization-sites-explanation">
<FormFieldDescription>
Add a new site to your organization or edit an existing site's
details.
</FormFieldDescription>
</Localized>
<Localized
id="configure-organization-sites-add-site"
icon={<Icon>add</Icon>}
>
<Button to="/admin/configure/organization/sites/new" iconLeft>
<Icon>add</Icon>
Add a site
</Button>
</Localized>
<SitesTable
sites={sites}
loading={loading}
onLoadMore={onLoadMore}
hasMore={hasMore}
disableLoadMore={disableLoadMore}
/>
</ConfigBox>
);
};
export default SitesConfig;
@@ -0,0 +1,94 @@
import React from "react";
import { graphql, RelayPaginationProp } from "react-relay";
import { IntersectionProvider } from "coral-framework/lib/intersection";
import {
useLoadMore,
useRefetch,
withPaginationContainer,
} from "coral-framework/lib/relay";
import { SitesConfigContainer_query as QueryData } from "coral-admin/__generated__/SitesConfigContainer_query.graphql";
import { SitesConfigContainerPaginationQueryVariables } from "coral-admin/__generated__/SitesConfigContainerPaginationQuery.graphql";
import SitesConfig from "./SitesConfig";
interface Props {
query: QueryData | null;
relay: RelayPaginationProp;
}
const SitesConfigContainer: React.FunctionComponent<Props> = props => {
const sites = props.query
? props.query.sites.edges.map(edge => edge.node)
: [];
const [loadMore, isLoadingMore] = useLoadMore(props.relay, 10);
const [, isRefetching] = useRefetch<
SitesConfigContainerPaginationQueryVariables
>(props.relay);
return (
<IntersectionProvider>
<SitesConfig
loading={!props.query || isRefetching}
sites={sites}
onLoadMore={loadMore}
hasMore={!isRefetching && props.relay.hasMore()}
disableLoadMore={isLoadingMore}
/>
</IntersectionProvider>
);
};
type FragmentVariables = SitesConfigContainerPaginationQueryVariables;
const enhanced = withPaginationContainer<
Props,
SitesConfigContainerPaginationQueryVariables,
FragmentVariables
>(
{
query: graphql`
fragment SitesConfigContainer_query on Query
@argumentDefinitions(
count: { type: "Int!", defaultValue: 20 }
cursor: { type: "Cursor" }
) {
sites(first: $count, after: $cursor)
@connection(key: "SitesConfig_sites") {
edges {
node {
id
...SiteRowContainer_site
}
}
}
}
`,
},
{
direction: "forward",
getConnectionFromProps(props) {
return props.query && props.query.sites;
},
// This is also the default implementation of `getFragmentVariables` if it isn't provided.
getFragmentVariables(prevVars, totalCount) {
return {
...prevVars,
count: totalCount,
};
},
getVariables(props, { count, cursor }, fragmentVariables) {
return {
count,
cursor,
};
},
query: graphql`
# Pagination query to be fetched upon calling 'loadMore'.
# Notice that we re-use our fragment, and the shape of this query matches our fragment spec.
query SitesConfigContainerPaginationQuery($count: Int!, $cursor: Cursor) {
...SitesConfigContainer_query @arguments(count: $count, cursor: $cursor)
}
`,
}
)(SitesConfigContainer);
export default enhanced;
@@ -0,0 +1,63 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent } from "react";
import AutoLoadMore from "coral-admin/components/AutoLoadMore";
import { PropTypesOf } from "coral-framework/types";
import {
Flex,
Spinner,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
} from "coral-ui/components/v2";
import EmptySitesMessage from "./EmptySitesMessage";
import SiteRowContainer from "./SiteRowContainer";
interface Props {
sites: Array<{ id: string } & PropTypesOf<typeof SiteRowContainer>["site"]>;
onLoadMore: () => void;
hasMore: boolean;
disableLoadMore: boolean;
loading: boolean;
}
const SitesTable: FunctionComponent<Props> = props => {
return (
<>
<Table fullWidth>
<TableHead>
<TableRow>
<Localized id="site-table-siteName">
<TableCell>Site name</TableCell>
</Localized>
<TableCell />
</TableRow>
</TableHead>
<TableBody>
{props.sites.map(site => (
<SiteRowContainer site={site} key={site.id} />
))}
</TableBody>
</Table>
{!props.loading && props.sites.length === 0 && <EmptySitesMessage />}
{props.loading && (
<Flex justifyContent="center">
<Spinner />
</Flex>
)}
{props.hasMore && (
<Flex justifyContent="center">
<AutoLoadMore
disableLoadMore={props.disableLoadMore}
onLoadMore={props.onLoadMore}
/>
</Flex>
)}
</>
);
};
export default SitesTable;
@@ -0,0 +1,75 @@
import { Localized } from "@fluent/react/compat";
import { useRouter } from "found";
import React, { FunctionComponent, useCallback } from "react";
import { useNotification } from "coral-admin/App/GlobalNotification";
import { graphql } from "coral-framework/lib/relay";
import { withRouteConfig } from "coral-framework/lib/router";
import { AppNotification } from "coral-ui/components/v2";
import { AddSiteRouteQueryResponse } from "coral-admin/__generated__/AddSiteRouteQuery.graphql";
import ConfigBox from "../../ConfigBox";
import Header from "../../Header";
import CreateSiteForm from "./CreateSiteForm";
interface Props {
data: AddSiteRouteQueryResponse | null;
}
const AddSiteRoute: FunctionComponent<Props> = props => {
const { router } = useRouter();
const { setMessage, clearMessage } = useNotification();
const onSiteCreate = useCallback(
(id: string, name: string) => {
router.replace(`/admin/configure/organization/sites/${id}`);
setMessage(
<Localized
id="configure-sites-add-success"
$site={name}
$org={props.data && props.data.settings.organization.name}
>
<AppNotification icon="check_circle_outline" onClose={clearMessage}>
{name} has been added to{" "}
{props.data && props.data.settings.organization.name}
</AppNotification>
</Localized>
);
},
[props.data]
);
if (!props.data) {
return null;
}
return (
<ConfigBox
title={
<Localized
id="configure-sites-add-new-site"
$site={props.data.settings.organization.name}
>
<Header>
Add a new site to {props.data.settings.organization.name}
</Header>
</Localized>
}
>
<CreateSiteForm onCreate={onSiteCreate} />
</ConfigBox>
);
};
const enhanced = withRouteConfig<Props>({
query: graphql`
query AddSiteRouteQuery {
settings {
organization {
name
}
}
}
`,
cacheConfig: { force: true },
})(AddSiteRoute);
export default enhanced;
@@ -0,0 +1,147 @@
import { Localized } from "@fluent/react/compat";
import { FormApi } from "final-form";
import React, { FunctionComponent, useCallback, useState } from "react";
import { Field, Form } from "react-final-form";
import { formatStringList, parseStringList } from "coral-framework/lib/form";
import { useMutation } from "coral-framework/lib/relay";
import {
required,
validateStrictURLList,
} from "coral-framework/lib/validation";
import {
Button,
ButtonIcon,
CallOut,
Flex,
FormField,
FormFieldHeader,
HorizontalGutter,
Label,
} from "coral-ui/components/v2";
import HelperText from "../../HelperText";
import TextFieldWithValidation from "../../TextFieldWithValidation";
import CreateSiteMutation from "./CreateSiteMutation";
interface Props {
onCreate: (id: string, name: string) => void;
}
const CreateSiteForm: FunctionComponent<Props> = ({ onCreate }) => {
const createSite = useMutation(CreateSiteMutation);
const [submitError, setSubmitError] = useState<null | string>(null);
const onSubmit = useCallback(
async (input, form: FormApi) => {
try {
const response = await createSite({ site: input });
if (response && response.site) {
onCreate(response.site.id, response.site.name);
}
} catch (error) {
setSubmitError(error.message);
}
},
[onCreate]
);
return (
<div>
<Form onSubmit={onSubmit}>
{({ handleSubmit, invalid, submitting, ...formProps }) => (
<form onSubmit={handleSubmit}>
<HorizontalGutter spacing={4}>
<FormField>
<FormFieldHeader>
<Localized id="configure-sites-site-form-name">
<Label>Site name</Label>
</Localized>
<Localized id="configure-sites-site-form-name-explanation">
<HelperText>
Site name will appear on emails sent by Coral to your
community and organization members.
</HelperText>
</Localized>
</FormFieldHeader>
<Field name="name" validate={required}>
{({ input, meta }) => (
<TextFieldWithValidation
{...input}
id={input.name}
fullWidth
meta={meta}
/>
)}
</Field>
</FormField>
<FormField>
<FormFieldHeader>
<Localized id="configure-sites-site-form-domains">
<Label>Site permitted domains</Label>
</Localized>
<Localized id="configure-sites-site-form-domains-explanation">
<HelperText>
Domains where your Coral comment streams are allowed to be
embedded (ex. http://localhost:3000,
https://staging.domain.com, https://domain.com).
</HelperText>
</Localized>
</FormFieldHeader>
<Field
name="allowedOrigins"
parse={parseStringList}
format={formatStringList}
validate={validateStrictURLList}
>
{({ input, meta }) => (
<TextFieldWithValidation
{...input}
id={`configure-advanced-${input.name}`}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck={false}
meta={meta}
fullWidth
/>
)}
</Field>
</FormField>
{submitError && (
<CallOut fullWidth color="error">
{submitError}
</CallOut>
)}
<Flex itemGutter justifyContent="flex-end">
<Localized id="configure-sites-site-form-cancel">
<Button
variant="outline"
size="large"
color="mono"
to="/admin/configure/organization"
>
Cancel
</Button>
</Localized>
<Localized
id="configure-sites-site-form-submit"
icon={<ButtonIcon>add</ButtonIcon>}
>
<Button
disabled={submitting}
iconLeft
type="submit"
size="large"
>
Add site
</Button>
</Localized>
</Flex>
</HorizontalGutter>
</form>
)}
</Form>
</div>
);
};
export default CreateSiteForm;
@@ -0,0 +1,57 @@
import { graphql } from "react-relay";
import { Environment } from "relay-runtime";
import { CoralContext } from "coral-framework/lib/bootstrap";
import {
commitMutationPromiseNormalized,
createMutation,
MutationInput,
} from "coral-framework/lib/relay";
import { CreateSiteMutation as MutationTypes } from "coral-admin/__generated__/CreateSiteMutation.graphql";
let clientMutationId = 0;
const CreateSiteMutation = createMutation(
"createSite",
(
environment: Environment,
input: MutationInput<MutationTypes>,
{ uuidGenerator }: CoralContext
) => {
const id = uuidGenerator();
const now = new Date();
return commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation: graphql`
mutation CreateSiteMutation($input: CreateSiteInput!) {
createSite(input: $input) {
site {
id
name
createdAt
}
clientMutationId
}
}
`,
variables: {
input: {
...input,
clientMutationId: clientMutationId.toString(),
},
},
optimisticResponse: {
createSite: {
site: {
id,
createdAt: now.toISOString(),
name: input.site.name,
},
clientMutationId: (clientMutationId++).toString(),
},
},
});
}
);
export default CreateSiteMutation;
@@ -0,0 +1,167 @@
import { Localized } from "@fluent/react/compat";
import { FormApi } from "final-form";
import React, { FunctionComponent, useCallback, useState } from "react";
import { Field, Form } from "react-final-form";
import { graphql } from "react-relay";
import { formatStringList, parseStringList } from "coral-framework/lib/form";
import { useMutation, withFragmentContainer } from "coral-framework/lib/relay";
import {
required,
validateStrictURLList,
} from "coral-framework/lib/validation";
import {
Button,
CallOut,
Flex,
FormField,
FormFieldHeader,
HorizontalGutter,
Label,
} from "coral-ui/components/v2";
import { EditSiteForm_settings as SettingsData } from "coral-admin/__generated__/EditSiteForm_settings.graphql";
import { EditSiteForm_site as SiteData } from "coral-admin/__generated__/EditSiteForm_site.graphql";
import HelperText from "../../HelperText";
import TextFieldWithValidation from "../../TextFieldWithValidation";
import EmbedCode from "./EmbedCode";
import UpdateSiteMutation from "./UpdateSiteMutation";
interface Props {
site: SiteData;
settings: SettingsData;
onEditSuccess: (name: string) => void;
}
const EditSiteForm: FunctionComponent<Props> = ({
site,
settings,
onEditSuccess,
}) => {
const updateSite = useMutation(UpdateSiteMutation);
const [submitError, setSubmitError] = useState<null | string>(null);
const onSubmit = useCallback(async (input, form: FormApi) => {
try {
const result = await updateSite({ site: input, id: site.id });
if (result) {
onEditSuccess(result.site.name);
}
} catch (error) {
setSubmitError(error.message);
}
}, []);
return (
<div>
<Form onSubmit={onSubmit}>
{({ handleSubmit, invalid, submitting, ...formProps }) => (
<form onSubmit={handleSubmit}>
<HorizontalGutter spacing={4}>
<FormField>
<FormFieldHeader>
<Localized id="configure-sites-site-form-name">
<Label>Site name</Label>
</Localized>
<Localized id="configure-sites-site-form-name-explanation">
<HelperText>
Site name will appear on emails sent by Coral to your
community and organization members.
</HelperText>
</Localized>
</FormFieldHeader>
<Field name="name" validate={required} defaultValue={site.name}>
{({ input, meta }) => (
<TextFieldWithValidation
{...input}
id={input.name}
fullWidth
meta={meta}
/>
)}
</Field>
</FormField>
<FormField>
<FormFieldHeader>
<Localized id="configure-sites-site-form-domains">
<Label>Site permitted domains</Label>
</Localized>
<Localized id="configure-sites-site-form-domains-explanation">
<HelperText>
Domains where your Coral comment streams are allowed to be
embedded (ex. http://localhost:3000,
https://staging.domain.com, https://domain.com).
</HelperText>
</Localized>
</FormFieldHeader>
<Field
name="allowedOrigins"
defaultValue={site.allowedOrigins}
parse={parseStringList}
format={formatStringList}
validate={validateStrictURLList}
>
{({ input, meta }) => (
<TextFieldWithValidation
{...input}
id={`configure-advanced-${input.name}`}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck={false}
meta={meta}
fullWidth
/>
)}
</Field>
</FormField>
<FormField>
<FormFieldHeader>
<Localized id="configure-sites-site-form-embed-code">
<Label>Embed code</Label>
</Localized>
</FormFieldHeader>
<EmbedCode staticURI={settings.staticURI} />
</FormField>
{submitError && (
<CallOut fullWidth color="error">
{submitError}
</CallOut>
)}
<Flex itemGutter justifyContent="flex-end">
<Localized id="configure-sites-site-form-save">
<Button
disabled={submitting}
iconLeft
type="submit"
size="large"
>
Save changes
</Button>
</Localized>
</Flex>
</HorizontalGutter>
</form>
)}
</Form>
</div>
);
};
const enhanced = withFragmentContainer<Props>({
site: graphql`
fragment EditSiteForm_site on Site {
name
createdAt
id
allowedOrigins
}
`,
settings: graphql`
fragment EditSiteForm_settings on Settings {
staticURI
}
`,
})(EditSiteForm);
export default enhanced;
@@ -1,19 +1,10 @@
import { Localized } from "@fluent/react/compat";
import { stripIndent } from "common-tags";
import React, { FunctionComponent, useMemo } from "react";
import { CopyButton } from "coral-framework/components";
import { GetMessage, withGetMessage } from "coral-framework/lib/i18n";
import { getLocationOrigin } from "coral-framework/utils";
import {
FieldSet,
FormFieldDescription,
HorizontalGutter,
Textarea,
} from "coral-ui/components/v2";
import ConfigBox from "../../ConfigBox";
import Header from "../../Header";
import { HorizontalGutter, Textarea } from "coral-ui/components/v2";
import styles from "./EmbedCode.css";
@@ -84,20 +75,7 @@ const EmbedCode: FunctionComponent<Props> = ({ staticURI, getMessage }) => {
}, [staticURI]);
return (
<ConfigBox
title={
<Localized id="configure-advanced-embedCode-title">
<Header container={<legend />}>Embed code</Header>
</Localized>
}
container={<FieldSet />}
>
<Localized id="configure-advanced-embedCode-explanation">
<FormFieldDescription>
Copy and paste the code below into your CMS to embed Coral comment
streams in each of your sites stories.
</FormFieldDescription>
</Localized>
<>
<Textarea
rows={embed.rows}
className={styles.textArea}
@@ -105,9 +83,9 @@ const EmbedCode: FunctionComponent<Props> = ({ staticURI, getMessage }) => {
value={embed.text}
/>
<HorizontalGutter className={styles.copyArea}>
<CopyButton text={embed.text} />
<CopyButton variant="regular" color="mono" text={embed.text} />
</HorizontalGutter>
</ConfigBox>
</>
);
};
@@ -0,0 +1,66 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent, useCallback } from "react";
import { useNotification } from "coral-admin/App/GlobalNotification";
import { graphql } from "coral-framework/lib/relay";
import { withRouteConfig } from "coral-framework/lib/router";
import { AppNotification } from "coral-ui/components/v2";
import { SiteRouteQueryResponse } from "coral-admin/__generated__/SiteRouteQuery.graphql";
import ConfigBox from "../../ConfigBox";
import Header from "../../Header";
import EditSiteForm from "./EditSiteForm";
interface Props {
data: SiteRouteQueryResponse;
}
const AddSiteRoute: FunctionComponent<Props> = ({ data }) => {
if (!data || !data.site) {
return null;
}
const { site } = data;
const { setMessage, clearMessage } = useNotification();
const onSiteEdit = useCallback((name: string) => {
setMessage(
<Localized id="configure-sites-edit-success" $site={name}>
<AppNotification icon="check_circle_outline" onClose={clearMessage}>
Changes to {name} have been saved
</AppNotification>
</Localized>
);
}, []);
return (
<ConfigBox
title={
<Localized id="configure-sites-site-edit" $site={site.name}>
<Header>Edit {site.name} details</Header>
</Localized>
}
>
<EditSiteForm
onEditSuccess={onSiteEdit}
site={site}
settings={data.settings}
/>
</ConfigBox>
);
};
const enhanced = withRouteConfig<Props>({
query: graphql`
query SiteRouteQuery($siteID: ID!) {
site(id: $siteID) {
name
...EditSiteForm_site
}
settings {
...EditSiteForm_settings
}
}
`,
cacheConfig: { force: true },
})(AddSiteRoute);
export default enhanced;
@@ -0,0 +1,27 @@
import React, { FunctionComponent } from "react";
import MainLayout from "coral-admin/components/MainLayout";
import ConfigureLinks from "../../ConfigureLinks";
import Layout from "../../Layout";
import Main from "../../Main";
import SideBar from "../../SideBar";
interface Props {
children: React.ReactElement;
}
const SitesRoute: FunctionComponent<Props> = props => {
return (
<MainLayout>
<Layout>
<SideBar>
<ConfigureLinks />
</SideBar>
<Main>{props.children}</Main>
</Layout>
</MainLayout>
);
};
export default SitesRoute;
@@ -0,0 +1,41 @@
import { graphql } from "react-relay";
import { Environment } from "relay-runtime";
import {
commitMutationPromiseNormalized,
createMutation,
MutationInput,
} from "coral-framework/lib/relay";
import { UpdateSiteMutation as MutationTypes } from "coral-admin/__generated__/UpdateSiteMutation.graphql";
const clientMutationId = 0;
const UpdateSiteMutation = createMutation(
"updateSite",
(environment: Environment, input: MutationInput<MutationTypes>) => {
return commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation: graphql`
mutation UpdateSiteMutation($input: UpdateSiteInput!) {
updateSite(input: $input) {
site {
id
name
createdAt
allowedOrigins
}
clientMutationId
}
}
`,
variables: {
input: {
...input,
clientMutationId: clientMutationId.toString(),
},
},
});
}
);
export default UpdateSiteMutation;
@@ -0,0 +1 @@
export { default, default as Sites } from "./SitesRoute";
@@ -107,7 +107,7 @@ const SlackConfigContainer: FunctionComponent<Props> = ({ form, settings }) => {
on how to create a Slack App see our documentation.
</FormFieldDescription>
</Localized>
<Button color="dark" onClick={onAddChannel}>
<Button iconLeft onClick={onAddChannel}>
<ButtonIcon size="md" className={styles.icon}>
add
</ButtonIcon>
@@ -0,0 +1,60 @@
import { Localized } from "@fluent/react/compat";
import { Match, Router, withRouter } from "found";
import React, { FunctionComponent, useCallback } from "react";
import ConfigBox from "coral-admin/routes/Configure/ConfigBox";
import Header from "coral-admin/routes/Configure/Header";
import { urls } from "coral-framework/helpers";
import { graphql, withFragmentContainer } from "coral-framework/lib/relay";
import { HorizontalGutter } from "coral-ui/components/v2";
import { AddWebhookEndpointContainer_settings } from "coral-admin/__generated__/AddWebhookEndpointContainer_settings.graphql";
import { ConfigureWebhookEndpointForm } from "../ConfigureWebhookEndpointForm";
import ExperimentalWebhooksCallOut from "../ExperimentalWebhooksCallOut";
interface Props {
router: Router;
match: Match;
settings: AddWebhookEndpointContainer_settings;
}
const AddWebhookEndpointContainer: FunctionComponent<Props> = ({
settings,
router,
}) => {
const onCancel = useCallback(() => {
router.push(urls.admin.webhooks);
}, [router]);
return (
<HorizontalGutter size="double">
<ExperimentalWebhooksCallOut />
<ConfigBox
title={
<Localized id="configure-webhooks-addEndpoint">
<Header>Add a webhook endpoint</Header>
</Localized>
}
>
<ConfigureWebhookEndpointForm
settings={settings}
webhookEndpoint={null}
onCancel={onCancel}
/>
</ConfigBox>
</HorizontalGutter>
);
};
const enhanced = withRouter(
withFragmentContainer<Props>({
settings: graphql`
fragment AddWebhookEndpointContainer_settings on Settings {
...ConfigureWebhookEndpointForm_settings
}
`,
})(AddWebhookEndpointContainer)
);
export default enhanced;
@@ -0,0 +1,38 @@
import React, { FunctionComponent } from "react";
import { graphql } from "react-relay";
import { withRouteConfig } from "coral-framework/lib/router";
import { Delay, Spinner } from "coral-ui/components/v2";
import { AddWebhookEndpointRouteQueryResponse } from "coral-admin/__generated__/AddWebhookEndpointRouteQuery.graphql";
import AddWebhookEndpointContainer from "./AddWebhookEndpointContainer";
interface Props {
data: AddWebhookEndpointRouteQueryResponse | null;
}
const AddWebhookEndpointRoute: FunctionComponent<Props> = ({ data }) => {
if (!data) {
return (
<Delay>
<Spinner />
</Delay>
);
}
return <AddWebhookEndpointContainer settings={data.settings} />;
};
const enhanced = withRouteConfig<Props>({
query: graphql`
query AddWebhookEndpointRouteQuery {
settings {
...AddWebhookEndpointContainer_settings
}
}
`,
cacheConfig: { force: true },
})(AddWebhookEndpointRoute);
export default enhanced;
@@ -0,0 +1,4 @@
export {
default,
default as AddWebhookEndpointRoute,
} from "./AddWebhookEndpointRoute";
@@ -0,0 +1,64 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent } from "react";
import ConfigBox from "coral-admin/routes/Configure/ConfigBox";
import Header from "coral-admin/routes/Configure/Header";
import { graphql, withFragmentContainer } from "coral-framework/lib/relay";
import { HorizontalGutter } from "coral-ui/components/v2";
import { ConfigureWebhookEndpointContainer_settings } from "coral-admin/__generated__/ConfigureWebhookEndpointContainer_settings.graphql";
import { ConfigureWebhookEndpointContainer_webhookEndpoint } from "coral-admin/__generated__/ConfigureWebhookEndpointContainer_webhookEndpoint.graphql";
import ExperimentalWebhooksCallOut from "../ExperimentalWebhooksCallOut";
import EndpointDangerZone from "./EndpointDangerZone";
import EndpointDetails from "./EndpointDetails";
import EndpointStatus from "./EndpointStatus";
interface Props {
webhookEndpoint: ConfigureWebhookEndpointContainer_webhookEndpoint;
settings: ConfigureWebhookEndpointContainer_settings;
}
const ConfigureWebhookEndpointContainer: FunctionComponent<Props> = ({
webhookEndpoint,
settings,
}) => {
return (
<HorizontalGutter size="double" data-testid="webhook-endpoint-container">
<ExperimentalWebhooksCallOut />
<ConfigBox
title={
<Localized id="configure-webhooks-configureWebhookEndpoint">
<Header htmlFor="configure-webhooks-header.title">
Configure webhook endpoint
</Header>
</Localized>
}
>
<EndpointDetails
webhookEndpoint={webhookEndpoint}
settings={settings}
/>
<EndpointStatus webhookEndpoint={webhookEndpoint} />
<EndpointDangerZone webhookEndpoint={webhookEndpoint} />
</ConfigBox>
</HorizontalGutter>
);
};
const enhanced = withFragmentContainer<Props>({
webhookEndpoint: graphql`
fragment ConfigureWebhookEndpointContainer_webhookEndpoint on WebhookEndpoint {
...EndpointDangerZone_webhookEndpoint
...EndpointDetails_webhookEndpoint
...EndpointStatus_webhookEndpoint
}
`,
settings: graphql`
fragment ConfigureWebhookEndpointContainer_settings on Settings {
...EndpointDetails_settings
}
`,
})(ConfigureWebhookEndpointContainer);
export default enhanced;
@@ -0,0 +1,62 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent } from "react";
import { graphql } from "react-relay";
import { withRouteConfig } from "coral-framework/lib/router";
import { CallOut, Delay, Spinner } from "coral-ui/components/v2";
import { ConfigureWebhookEndpointRouteQueryResponse } from "coral-admin/__generated__/ConfigureWebhookEndpointRouteQuery.graphql";
import ConfigureWebhookContainer from "./ConfigureWebhookEndpointContainer";
interface Props {
data: ConfigureWebhookEndpointRouteQueryResponse | null;
}
const ConfigureWebhookEndpointRoute: FunctionComponent<Props> = ({ data }) => {
if (!data) {
return (
<Delay>
<Spinner />
</Delay>
);
}
if (!data.webhookEndpoint) {
return (
<Localized id="configure-webhooks-webhookEndpointNotFound">
<CallOut color="error" fullWidth>
Webhook endpoint not found
</CallOut>
</Localized>
);
}
return (
<ConfigureWebhookContainer
webhookEndpoint={data.webhookEndpoint}
settings={data.settings}
/>
);
};
const enhanced = withRouteConfig<Props>({
query: graphql`
query ConfigureWebhookEndpointRouteQuery($webhookEndpointID: ID!) {
webhookEndpoint(id: $webhookEndpointID) {
...ConfigureWebhookEndpointContainer_webhookEndpoint
}
settings {
...ConfigureWebhookEndpointContainer_settings
}
}
`,
cacheConfig: { force: true },
prepareVariables: (params, match) => {
return {
webhookEndpointID: match.params.webhookEndpointID,
};
},
})(ConfigureWebhookEndpointRoute);
export default enhanced;
@@ -0,0 +1,38 @@
import { graphql } from "react-relay";
import { Environment } from "relay-runtime";
import {
commitMutationPromiseNormalized,
createMutation,
MutationInput,
} from "coral-framework/lib/relay";
import { DeleteWebhookEndpointMutation as MutationTypes } from "coral-admin/__generated__/DeleteWebhookEndpointMutation.graphql";
let clientMutationId = 0;
const DeleteWebhookEndpointMutation = createMutation(
"deleteWebhookEndpoint",
(environment: Environment, { id }: MutationInput<MutationTypes>) =>
commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation: graphql`
mutation DeleteWebhookEndpointMutation(
$input: DeleteWebhookEndpointInput!
) {
deleteWebhookEndpoint(input: $input) {
endpoint {
id
}
}
}
`,
variables: {
input: {
id,
clientMutationId: (clientMutationId++).toString(),
},
},
})
);
export default DeleteWebhookEndpointMutation;
@@ -0,0 +1,38 @@
import { graphql } from "react-relay";
import { Environment } from "relay-runtime";
import {
commitMutationPromiseNormalized,
createMutation,
MutationInput,
} from "coral-framework/lib/relay";
import { DisableWebhookEndpointMutation as MutationTypes } from "coral-admin/__generated__/DisableWebhookEndpointMutation.graphql";
let clientMutationId = 0;
const DisableWebhookEndpointMutation = createMutation(
"disableWebhookEndpoint",
(environment: Environment, input: MutationInput<MutationTypes>) =>
commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation: graphql`
mutation DisableWebhookEndpointMutation(
$input: DisableWebhookEndpointInput!
) {
disableWebhookEndpoint(input: $input) {
endpoint {
...ConfigureWebhookEndpointContainer_webhookEndpoint
}
}
}
`,
variables: {
input: {
...input,
clientMutationId: (clientMutationId++).toString(),
},
},
})
);
export default DisableWebhookEndpointMutation;
@@ -0,0 +1,38 @@
import { graphql } from "react-relay";
import { Environment } from "relay-runtime";
import {
commitMutationPromiseNormalized,
createMutation,
MutationInput,
} from "coral-framework/lib/relay";
import { EnableWebhookEndpointMutation as MutationTypes } from "coral-admin/__generated__/EnableWebhookEndpointMutation.graphql";
let clientMutationId = 0;
const EnableWebhookEndpointMutation = createMutation(
"enableWebhookEndpoint",
(environment: Environment, input: MutationInput<MutationTypes>) =>
commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation: graphql`
mutation EnableWebhookEndpointMutation(
$input: EnableWebhookEndpointInput!
) {
enableWebhookEndpoint(input: $input) {
endpoint {
...ConfigureWebhookEndpointContainer_webhookEndpoint
}
}
}
`,
variables: {
input: {
...input,
clientMutationId: (clientMutationId++).toString(),
},
},
})
);
export default EnableWebhookEndpointMutation;
@@ -0,0 +1,181 @@
import { Localized } from "@fluent/react/compat";
import { Match, Router, withRouter } from "found";
import React, { FunctionComponent, useCallback, useState } from "react";
import Subheader from "coral-admin/routes/Configure/Subheader";
import { urls } from "coral-framework/helpers";
import { useCoralContext } from "coral-framework/lib/bootstrap";
import { getMessage } from "coral-framework/lib/i18n";
import {
graphql,
useMutation,
withFragmentContainer,
} from "coral-framework/lib/relay";
import {
Button,
FormField,
FormFieldDescription,
Label,
} from "coral-ui/components/v2";
import { EndpointDangerZone_webhookEndpoint } from "coral-admin/__generated__/EndpointDangerZone_webhookEndpoint.graphql";
import DeleteWebhookEndpointMutation from "./DeleteWebhookEndpointMutation";
import DisableWebhookEndpointMutation from "./DisableWebhookEndpointMutation";
import EnableWebhookEndpointMutation from "./EnableWebhookEndpointMutation";
import RotateSigningSecretModal from "./RotateSigningSecretModal";
interface Props {
webhookEndpoint: EndpointDangerZone_webhookEndpoint;
router: Router;
match: Match;
}
const EndpointDangerZone: FunctionComponent<Props> = ({
webhookEndpoint,
router,
}) => {
const { localeBundles } = useCoralContext();
const enableWebhookEndpoint = useMutation(EnableWebhookEndpointMutation);
const disableWebhookEndpoint = useMutation(DisableWebhookEndpointMutation);
const deleteWebhookEndpoint = useMutation(DeleteWebhookEndpointMutation);
const [rotateSecretOpen, setRotateSecretOpen] = useState<boolean>(false);
const onRotateSecret = useCallback(async () => {
setRotateSecretOpen(true);
}, []);
const onHideRotateSecret = useCallback(async () => {
setRotateSecretOpen(false);
}, [setRotateSecretOpen]);
const onEnable = useCallback(async () => {
const message = getMessage(
localeBundles,
"configure-webhooks-confirmEnable",
"Enabling the webhook endpoint will start to send events to this URL. Are you sure you want to continue?"
);
if (window.confirm(message)) {
await enableWebhookEndpoint({ id: webhookEndpoint.id });
}
}, [webhookEndpoint, enableWebhookEndpoint]);
const onDisable = useCallback(async () => {
const message = getMessage(
localeBundles,
"configure-webhooks-confirmDisable",
"Disabling this webhook endpoint will stop any new events from being sent to this URL. Are you sure you want to continue?"
);
if (window.confirm(message)) {
await disableWebhookEndpoint({ id: webhookEndpoint.id });
}
}, [webhookEndpoint, disableWebhookEndpoint]);
const onDelete = useCallback(async () => {
const message = getMessage(
localeBundles,
"configure-webhooks-confirmDelete",
"Deleting this webhook endpoint will stop any new events from being sent to this URL, and remove all the associated settings with this webhook endpoint. Are you sure you want to continue?"
);
if (window.confirm(message)) {
await deleteWebhookEndpoint({ id: webhookEndpoint.id });
// Send the user back to the webhook endpoints listing.
router.push(urls.admin.webhooks);
}
}, [webhookEndpoint, disableWebhookEndpoint, router]);
return (
<>
<Localized id="configure-webhooks-dangerZone">
<Subheader>Danger Zone</Subheader>
</Localized>
<FormField>
<Localized id="configure-webhooks-rotateSigningSecret">
<Label>Rotate signing secret</Label>
</Localized>
<Localized id="configure-webhooks-rotateSigningSecretDescription">
<FormFieldDescription>
Rotating the signing secret will allow to you to safely replace a
signing secret used in production with a delay.
</FormFieldDescription>
</Localized>
<Localized id="configure-webhooks-rotateSigningSecretButton">
<Button color="alert" onClick={onRotateSecret}>
Rotate signing secret
</Button>
</Localized>
</FormField>
<RotateSigningSecretModal
endpointID={webhookEndpoint.id}
onHide={onHideRotateSecret}
open={rotateSecretOpen}
/>
{webhookEndpoint.enabled ? (
<FormField>
<Localized id="configure-webhooks-disableEndpoint">
<Label>Disable endpoint</Label>
</Localized>
<Localized id="configure-webhooks-disableEndpointDescription">
<FormFieldDescription>
This endpoint is current enabled. By disabling this endpoint no
new events will be sent to the URL provided.
</FormFieldDescription>
</Localized>
<Localized id="configure-webhooks-disableEndpointButton">
<Button color="alert" onClick={onDisable}>
Disable endpoint
</Button>
</Localized>
</FormField>
) : (
<FormField>
<Localized id="configure-webhooks-enableEndpoint">
<Label>Enable endpoint</Label>
</Localized>
<Localized id="configure-webhooks-enableEndpointDescription">
<FormFieldDescription>
This endpoint is current disabled. By enabling this endpoint new
events will be sent to the URL provided.
</FormFieldDescription>
</Localized>
<Localized id="configure-webhooks-enableEndpointButton">
<Button color="regular" onClick={onEnable}>
Enable endpoint
</Button>
</Localized>
</FormField>
)}
<FormField>
<Localized id="configure-webhooks-deleteEndpoint">
<Label>Delete endpoint</Label>
</Localized>
<Localized id="configure-webhooks-deleteEndpointDescription">
<FormFieldDescription>
Deleting the endpoint will prevent any new events from being sent to
the URL provided.
</FormFieldDescription>
</Localized>
<Localized id="configure-webhooks-deleteEndpointButton">
<Button color="alert" onClick={onDelete}>
Delete endpoint
</Button>
</Localized>
</FormField>
</>
);
};
const enhanced = withRouter(
withFragmentContainer<Props>({
webhookEndpoint: graphql`
fragment EndpointDangerZone_webhookEndpoint on WebhookEndpoint {
id
enabled
}
`,
})(EndpointDangerZone)
);
export default enhanced;
@@ -0,0 +1,42 @@
import React, { FunctionComponent } from "react";
import Subheader from "coral-admin/routes/Configure/Subheader";
import { graphql, withFragmentContainer } from "coral-framework/lib/relay";
import { EndpointDetails_settings } from "coral-admin/__generated__/EndpointDetails_settings.graphql";
import { EndpointDetails_webhookEndpoint } from "coral-admin/__generated__/EndpointDetails_webhookEndpoint.graphql";
import ConfigureWebhookEndpointForm from "../ConfigureWebhookEndpointForm";
interface Props {
webhookEndpoint: EndpointDetails_webhookEndpoint;
settings: EndpointDetails_settings;
}
const EndpointDetails: FunctionComponent<Props> = ({
webhookEndpoint,
settings,
}) => (
<>
<Subheader>Endpoint details</Subheader>
<ConfigureWebhookEndpointForm
settings={settings}
webhookEndpoint={webhookEndpoint}
/>
</>
);
const enhanced = withFragmentContainer<Props>({
webhookEndpoint: graphql`
fragment EndpointDetails_webhookEndpoint on WebhookEndpoint {
...ConfigureWebhookEndpointForm_webhookEndpoint
}
`,
settings: graphql`
fragment EndpointDetails_settings on Settings {
...ConfigureWebhookEndpointForm_settings
}
`,
})(EndpointDetails);
export default enhanced;
@@ -0,0 +1,85 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent } from "react";
import Subheader from "coral-admin/routes/Configure/Subheader";
import { CopyButton } from "coral-framework/components";
import { ExternalLink } from "coral-framework/lib/i18n/components";
import { graphql, withFragmentContainer } from "coral-framework/lib/relay";
import {
Flex,
FormField,
FormFieldDescription,
HelperText,
Label,
PasswordField,
} from "coral-ui/components/v2";
import { EndpointStatus_webhookEndpoint } from "coral-admin/__generated__/EndpointStatus_webhookEndpoint.graphql";
import StatusMarker from "../StatusMarker";
interface Props {
webhookEndpoint: EndpointStatus_webhookEndpoint;
}
const EndpointStatus: FunctionComponent<Props> = ({ webhookEndpoint }) => {
return (
<>
<Localized id="configure-webhooks-endpointStatus">
<Subheader>Endpoint status</Subheader>
</Localized>
<FormField>
<Localized id="configure-webhooks-status">
<Label>Status</Label>
</Localized>
<StatusMarker enabled={webhookEndpoint.enabled} />
</FormField>
<FormField>
<Localized id="configure-webhooks-signingSecret">
<Label>Signing secret</Label>
</Localized>
<Localized
id="configure-webhooks-signingSecretDescription"
externalLink={
<ExternalLink href="https://github.com/coralproject/talk/blob/master/WEBHOOKS.md#webhook-signing" />
}
>
<FormFieldDescription>
The following signing secret is used to sign request payloads sent
to the URL. To learn more about webhook signing, visit our{" "}
<ExternalLink href="https://github.com/coralproject/talk/blob/master/WEBHOOKS.md#webhook-signing">
Webhook Guide
</ExternalLink>
.
</FormFieldDescription>
</Localized>
<Flex direction="row" itemGutter="half" alignItems="center">
<PasswordField
value={webhookEndpoint.signingSecret.secret}
fullWidth
readOnly
/>
<CopyButton text={webhookEndpoint.signingSecret.secret} />
</Flex>
<HelperText>
KEY GENERATED AT: {webhookEndpoint.signingSecret.createdAt}
</HelperText>
</FormField>
</>
);
};
const enhanced = withFragmentContainer<Props>({
webhookEndpoint: graphql`
fragment EndpointStatus_webhookEndpoint on WebhookEndpoint {
id
enabled
signingSecret {
secret
createdAt
}
}
`,
})(EndpointStatus);
export default enhanced;
@@ -0,0 +1,10 @@
.root {
width: 500px;
}
.title {
font-size: var(--v2-font-size-5);
font-family: var(--v2-font-family-primary);
font-weight: var(--v2-font-weight-primary-semi-bold);
line-height: var(--v2-line-height-title);
}
@@ -0,0 +1,165 @@
import { Localized } from "@fluent/react/compat";
import { FORM_ERROR } from "final-form";
import React, { FunctionComponent, useCallback } from "react";
import { Field, Form } from "react-final-form";
import { useNotification } from "coral-admin/App/GlobalNotification";
import { InvalidRequestError } from "coral-framework/lib/errors";
import { useMutation } from "coral-framework/lib/relay";
import {
Button,
CallOut,
Card,
CardCloseButton,
Flex,
FormField,
HelperText,
HorizontalGutter,
Label,
Modal,
Option,
SelectField,
} from "coral-ui/components/v2";
import AppNotification from "coral-ui/components/v2/AppNotification";
import RotateWebhookEndpointSecretMutation from "./RotateWebhookEndpointSecretMutation";
import styles from "./RotateSigningSecretModal.css";
interface Props {
endpointID: string;
onHide: () => void;
open: boolean;
}
const RotateWebhookEndpointSecretModal: FunctionComponent<Props> = ({
onHide,
open,
endpointID,
}) => {
const rotateWebhookEndpointSecret = useMutation(
RotateWebhookEndpointSecretMutation
);
const { setMessage, clearMessage } = useNotification();
const onRotateSecret = useCallback(
async ({ inactiveIn: inactiveInString }) => {
try {
const inactiveIn = parseInt(inactiveInString, 10);
await rotateWebhookEndpointSecret({ id: endpointID, inactiveIn });
// Post a notification about the successful change.
setMessage(
<Localized id="configure-webhooks-rotateSigningSecretSuccessUseNewSecret">
<AppNotification icon="check_circle_outline" onClose={clearMessage}>
Webhook endpoint signing secret has been rotated. Please ensure
you update your integrations to use the new secret below.
</AppNotification>
</Localized>
);
window.scroll(0, 0);
} catch (err) {
if (err instanceof InvalidRequestError) {
return err.invalidArgs;
}
return { [FORM_ERROR]: err.message };
}
// Dismiss the modal.
onHide();
return;
},
[endpointID, rotateWebhookEndpointSecret]
);
return (
<Modal open={open}>
{({ firstFocusableRef, lastFocusableRef }) => (
<Card className={styles.root}>
<Flex justifyContent="flex-end">
<CardCloseButton onClick={onHide} ref={firstFocusableRef} />
</Flex>
<Form onSubmit={onRotateSecret} initialValues={{ inactiveIn: 0 }}>
{({ handleSubmit, submitting, submitError }) => (
<form onSubmit={handleSubmit}>
<HorizontalGutter size="double">
<Localized id="configure-webhooks-rotateSigningSecret">
<h2 className={styles.title}>Rotate signing secret</h2>
</Localized>
{submitError && (
<CallOut color="error" fullWidth>
{submitError}
</CallOut>
)}
<Localized id="configure-webhooks-rotateSigningSecretHelper">
<HelperText>
After it expires, signatures will no longer be generated
with the old secret.
</HelperText>
</Localized>
<Field name="inactiveIn">
{({ input }) => (
<FormField>
<Localized id="configure-webhooks-expiresOldSecret">
<Label>Expire the old secret</Label>
</Localized>
<SelectField {...input} fullWidth>
<Localized id="configure-webhooks-expiresOldSecretImmediately">
<Option value="0">Immediately</Option>
</Localized>
<Localized
id="configure-webhooks-expiresOldSecretHoursFromNow"
$hours={1}
>
<Option value="3600">1 hour from now</Option>
</Localized>
<Localized
id="configure-webhooks-expiresOldSecretHoursFromNow"
$hours={2}
>
<Option value="7200">2 hours from now</Option>
</Localized>
<Localized
id="configure-webhooks-expiresOldSecretHoursFromNow"
$hours={12}
>
<Option value="43200">12 hours from now</Option>
</Localized>
<Localized
id="configure-webhooks-expiresOldSecretHoursFromNow"
$hours={24}
>
<Option value="86400">24 hours from now</Option>
</Localized>
</SelectField>
</FormField>
)}
</Field>
<Flex direction="row" justifyContent="flex-end" itemGutter>
<Localized id="configure-webhooks-cancelButton">
<Button color="regular" onClick={onHide}>
Cancel
</Button>
</Localized>
<Localized id="configure-webhooks-rotateSigningSecretButton">
<Button
type="submit"
color="alert"
disabled={submitting}
ref={lastFocusableRef}
>
Rotate signing secret
</Button>
</Localized>
</Flex>
</HorizontalGutter>
</form>
)}
</Form>
</Card>
)}
</Modal>
);
};
export default RotateWebhookEndpointSecretModal;
@@ -0,0 +1,38 @@
import { graphql } from "react-relay";
import { Environment } from "relay-runtime";
import {
commitMutationPromiseNormalized,
createMutation,
MutationInput,
} from "coral-framework/lib/relay";
import { RotateWebhookEndpointSecretMutation as MutationTypes } from "coral-admin/__generated__/RotateWebhookEndpointSecretMutation.graphql";
let clientMutationId = 0;
const RotateWebhookEndpointSecretMutation = createMutation(
"rotateWebhookEndpointSecret",
(environment: Environment, input: MutationInput<MutationTypes>) =>
commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation: graphql`
mutation RotateWebhookEndpointSecretMutation(
$input: RotateWebhookEndpointSecretInput!
) {
rotateWebhookEndpointSecret(input: $input) {
endpoint {
...ConfigureWebhookEndpointContainer_webhookEndpoint
}
}
}
`,
variables: {
input: {
...input,
clientMutationId: (clientMutationId++).toString(),
},
},
})
);
export default RotateWebhookEndpointSecretMutation;
@@ -0,0 +1,4 @@
export {
default,
default as ConfigureWebhookEndpointRoute,
} from "./ConfigureWebhookEndpointRoute";
@@ -0,0 +1,162 @@
import { Localized } from "@fluent/react/compat";
import { FORM_ERROR } from "final-form";
import { Match, Router, withRouter } from "found";
import React, { FunctionComponent, useCallback } from "react";
import { Field, Form } from "react-final-form";
import getEndpointLink from "coral-admin/helpers/getEndpointLink";
import { InvalidRequestError } from "coral-framework/lib/errors";
import { colorFromMeta, ValidationMessage } from "coral-framework/lib/form";
import {
graphql,
useMutation,
withFragmentContainer,
} from "coral-framework/lib/relay";
import {
composeValidators,
required,
validateURL,
} from "coral-framework/lib/validation";
import {
Button,
CallOut,
Flex,
FormField,
HorizontalGutter,
Label,
TextField,
} from "coral-ui/components/v2";
import { ConfigureWebhookEndpointForm_settings } from "coral-admin/__generated__/ConfigureWebhookEndpointForm_settings.graphql";
import { ConfigureWebhookEndpointForm_webhookEndpoint } from "coral-admin/__generated__/ConfigureWebhookEndpointForm_webhookEndpoint.graphql";
import CreateWebhookEndpointMutation from "./CreateWebhookEndpointMutation";
import EventsSelectField from "./EventsSelectField";
import UpdateWebhookEndpointMutation from "./UpdateWebhookEndpointMutation";
interface Props {
onCancel?: () => void;
router: Router;
match: Match;
webhookEndpoint: ConfigureWebhookEndpointForm_webhookEndpoint | null;
settings: ConfigureWebhookEndpointForm_settings;
}
const ConfigureWebhookEndpointForm: FunctionComponent<Props> = ({
onCancel,
settings,
webhookEndpoint,
router,
}) => {
const create = useMutation(CreateWebhookEndpointMutation);
const update = useMutation(UpdateWebhookEndpointMutation);
const onSubmit = useCallback(
async values => {
try {
if (webhookEndpoint) {
// The webhook endpoint was defined, update it.
await update(values);
} else {
// The webhook endpoint wasn't defined, created it.
const result = await create(values);
// Redirect the user to the new webhook endpoint page.
router.push(getEndpointLink(result.endpoint.id));
// We don't need to close this modal because we are navigating...
}
return;
} catch (err) {
if (err instanceof InvalidRequestError) {
return err.invalidArgs;
}
return { [FORM_ERROR]: err.message };
}
},
[webhookEndpoint, create, update, router]
);
return (
<Form
onSubmit={onSubmit}
initialValues={
webhookEndpoint ? webhookEndpoint : { events: [], all: false, url: "" }
}
>
{({ handleSubmit, submitting, submitError, pristine }) => (
<form autoComplete="off" onSubmit={handleSubmit}>
<HorizontalGutter size="double">
{submitError && (
<CallOut color="error" fullWidth>
{submitError}
</CallOut>
)}
<Field
name="url"
validate={composeValidators(required, validateURL)}
>
{({ input, meta }) => (
<FormField>
<Localized id="configure-webhooks-endpointURL">
<Label>Endpoint URL</Label>
</Localized>
<TextField
{...input}
placeholder="https://"
color={colorFromMeta(meta)}
fullWidth
/>
<ValidationMessage meta={meta} fullWidth />
</FormField>
)}
</Field>
<EventsSelectField settings={settings} />
<Flex direction="row" justifyContent="flex-end" itemGutter>
{onCancel && (
<Localized id="configure-webhooks-cancelButton">
<Button type="button" color="mono" onClick={onCancel}>
Cancel
</Button>
</Localized>
)}
{webhookEndpoint ? (
<Localized id="configure-webhooks-updateWebhookEndpointButton">
<Button type="submit" disabled={submitting || pristine}>
Update details
</Button>
</Localized>
) : (
<Localized id="configure-webhooks-addEndpointButton">
<Button type="submit" disabled={submitting}>
Add webhook endpoint
</Button>
</Localized>
)}
</Flex>
</HorizontalGutter>
</form>
)}
</Form>
);
};
const enhanced = withRouter(
withFragmentContainer<Props>({
webhookEndpoint: graphql`
fragment ConfigureWebhookEndpointForm_webhookEndpoint on WebhookEndpoint {
id
url
events
all
}
`,
settings: graphql`
fragment ConfigureWebhookEndpointForm_settings on Settings {
...EventsSelectField_settings
}
`,
})(ConfigureWebhookEndpointForm)
);
export default enhanced;
@@ -0,0 +1,41 @@
import { graphql } from "react-relay";
import { Environment } from "relay-runtime";
import {
commitMutationPromiseNormalized,
createMutation,
MutationInput,
} from "coral-framework/lib/relay";
import { CreateWebhookEndpointMutation as MutationTypes } from "coral-admin/__generated__/CreateWebhookEndpointMutation.graphql";
let clientMutationId = 0;
const CreateWebhookEndpointMutation = createMutation(
"createWebhookEndpoint",
(environment: Environment, input: MutationInput<MutationTypes>) =>
commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation: graphql`
mutation CreateWebhookEndpointMutation(
$input: CreateWebhookEndpointInput!
) {
createWebhookEndpoint(input: $input) {
endpoint {
id
}
settings {
...WebhookEndpointsConfigContainer_settings
}
}
}
`,
variables: {
input: {
...input,
clientMutationId: (clientMutationId++).toString(),
},
},
})
);
export default CreateWebhookEndpointMutation;
@@ -0,0 +1,7 @@
.list {
max-height: 295px;
}
.event {
font-family: monospace;
}
@@ -0,0 +1,152 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent, useCallback } from "react";
import { useField } from "react-final-form";
import { ValidationMessage } from "coral-framework/lib/form";
import { ExternalLink } from "coral-framework/lib/i18n/components";
import { graphql, withFragmentContainer } from "coral-framework/lib/relay";
import { validateWebhookEventSelection } from "coral-framework/lib/validation";
import { Typography } from "coral-ui/components";
import {
Button,
CheckBox,
Flex,
FormField,
FormFieldDescription,
HelperText,
Label,
ListGroup,
ListGroupRow,
} from "coral-ui/components/v2";
import {
EventsSelectField_settings,
WEBHOOK_EVENT_NAME,
} from "coral-admin/__generated__/EventsSelectField_settings.graphql";
import styles from "./EventsSelectField.css";
interface Props {
settings: EventsSelectField_settings;
}
const EventsSelectField: FunctionComponent<Props> = ({ settings }) => {
const { input: all } = useField<boolean>("all");
const { input: events, meta } = useField<WEBHOOK_EVENT_NAME[]>("events", {
validate: validateWebhookEventSelection,
});
const onClear = useCallback(() => {
if (all.value) {
all.onChange(false);
} else {
events.onChange([]);
}
}, [all, events]);
const onCheckChange = useCallback(
(event: WEBHOOK_EVENT_NAME, selectedIndex: number) => () => {
const changed = [...events.value];
if (selectedIndex >= 0) {
changed.splice(selectedIndex, 1);
} else {
changed.push(event);
}
events.onChange(changed);
},
[events]
);
const onRecieveAll = useCallback(() => {
all.onChange(true);
}, [all]);
return (
<FormField>
<Flex justifyContent="space-between">
<Localized id="configure-webhooks-eventsToSend">
<Label>Events to send</Label>
</Localized>
{(all.value || events.value.length > 0) && (
<Localized id="configure-webhooks-clearEventsToSend">
<Button variant="text" onClick={onClear}>
Clear
</Button>
</Localized>
)}
</Flex>
<Localized
id="configure-webhooks-eventsToSendDescription"
externalLink={
<ExternalLink href="https://github.com/coralproject/talk/blob/master/WEBHOOKS.md#events-listing" />
}
>
<FormFieldDescription>
These are the events that are registered to this particular endpoint.
Visit our{" "}
<ExternalLink href="https://github.com/coralproject/talk/blob/master/WEBHOOKS.md#events-listing">
Webhook Guide
</ExternalLink>{" "}
for the schema of these events. Any event matching the following will
be sent to the endpoint if it is enabled:
</FormFieldDescription>
</Localized>
<ListGroup className={styles.list}>
{settings.webhookEvents.map(event => {
const selectedIndex = events.value.indexOf(event);
return (
<ListGroupRow key={event}>
<CheckBox
disabled={all.value}
checked={all.value || selectedIndex >= 0}
onChange={onCheckChange(event, selectedIndex)}
>
<Typography className={styles.event}>{event}</Typography>
</CheckBox>
</ListGroupRow>
);
})}
</ListGroup>
{all.value ? (
<Localized id="configure-webhooks-allEvents">
<HelperText>
The endpoint will receive all events, including any added in the
future.
</HelperText>
</Localized>
) : events.value.length > 0 ? (
<Localized
id="configure-webhooks-selectedEvents"
$count={events.value.length}
>
<HelperText>{events.value.length} event selected.</HelperText>
</Localized>
) : (
<Localized
id="configure-webhooks-selectAnEvent"
button={<Button variant="text" onClick={onRecieveAll} />}
>
<HelperText>
Select events above or{" "}
<Button variant="text" onClick={onRecieveAll}>
receive all events
</Button>
.
</HelperText>
</Localized>
)}
<ValidationMessage meta={meta} fullWidth />
</FormField>
);
};
const enhanced = withFragmentContainer<Props>({
settings: graphql`
fragment EventsSelectField_settings on Settings {
webhookEvents
}
`,
})(EventsSelectField);
export default enhanced;
@@ -0,0 +1,38 @@
import { graphql } from "react-relay";
import { Environment } from "relay-runtime";
import {
commitMutationPromiseNormalized,
createMutation,
MutationInput,
} from "coral-framework/lib/relay";
import { UpdateWebhookEndpointMutation as MutationTypes } from "coral-admin/__generated__/UpdateWebhookEndpointMutation.graphql";
let clientMutationId = 0;
const UpdateWebhookEndpointMutation = createMutation(
"updateWebhookEndpoint",
(environment: Environment, input: MutationInput<MutationTypes>) =>
commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation: graphql`
mutation UpdateWebhookEndpointMutation(
$input: UpdateWebhookEndpointInput!
) {
updateWebhookEndpoint(input: $input) {
endpoint {
...ConfigureWebhookEndpointContainer_webhookEndpoint
}
}
}
`,
variables: {
input: {
...input,
clientMutationId: (clientMutationId++).toString(),
},
},
})
);
export default UpdateWebhookEndpointMutation;
@@ -0,0 +1,4 @@
export {
default,
default as ConfigureWebhookEndpointForm,
} from "./ConfigureWebhookEndpointForm";
@@ -0,0 +1,23 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent } from "react";
import ExperimentalCallOut from "coral-admin/components/ExperimentalCallOut";
import { ExternalLink } from "coral-framework/lib/i18n/components";
const ExperimentalWebhooksCallOut: FunctionComponent = () => (
<Localized
id="configure-webhooks-experimentalFeature"
ContactUsLink={<ExternalLink href="https://coralproject.net/contact/" />}
>
<ExperimentalCallOut>
The webhook feature is currently in active development. Events may be
added or removed. Please{" "}
<ExternalLink href="https://coralproject.net/contact/">
contact us with any feedback or requests
</ExternalLink>
.
</ExperimentalCallOut>
</Localized>
);
export default ExperimentalWebhooksCallOut;
@@ -0,0 +1,12 @@
.success {
background-color: var(--v2-palette-success-main);
border-color: var(--v2-palette-success-main);
color: var(--v2-colors-pure-white);
}
.error {
background-color: var(--v2-palette-error-darkest);
border-color: var(--v2-palette-error-darkest);
color: var(--v2-colors-pure-white);
font-weight: var(--v2-font-weight-primary-semi-bold);
}
@@ -0,0 +1,23 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent } from "react";
import { Marker } from "coral-ui/components/v2";
import styles from "./StatusMarker.css";
interface Props {
enabled: boolean;
}
const StatusMarker: FunctionComponent<Props> = ({ enabled }) =>
enabled ? (
<Localized id="configure-webhooks-enabledWebhookEndpoint">
<Marker className={styles.success}>Enabled</Marker>
</Localized>
) : (
<Localized id="configure-webhooks-disabledWebhookEndpoint">
<Marker className={styles.error}>Disabled</Marker>
</Localized>
);
export default StatusMarker;
@@ -0,0 +1,10 @@
.urlColumn {
width: 100%;
}
.detailsButton {
font-family: var(--v2-font-family-primary);
font-weight: var(--v2-font-weight-primary-semi-bold);
line-height: var(--v2-line-height-reset);
font-size: var(--v2-font-size-2);
}
@@ -0,0 +1,56 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent } from "react";
import getEndpointLink from "coral-admin/helpers/getEndpointLink";
import { graphql, withFragmentContainer } from "coral-framework/lib/relay";
import {
Button,
Flex,
Icon,
TableCell,
TableRow,
} from "coral-ui/components/v2";
import { WebhookEndpointRow_webhookEndpoint } from "coral-admin/__generated__/WebhookEndpointRow_webhookEndpoint.graphql";
import StatusMarker from "./StatusMarker";
import styles from "./WebhookEndpointRow.css";
interface Props {
endpoint: WebhookEndpointRow_webhookEndpoint;
}
const WebhookEndpointRow: FunctionComponent<Props> = ({ endpoint }) => (
<TableRow data-testid={`webhook-endpoint-${endpoint.id}`}>
<TableCell className={styles.urlColumn}>{endpoint.url}</TableCell>
<TableCell>
<StatusMarker enabled={endpoint.enabled} />
</TableCell>
<TableCell>
<Flex justifyContent="flex-end">
<Localized
id="configure-webhooks-detailsButton"
icon={<Icon>keyboard_arrow_right</Icon>}
>
<Button variant="text" to={getEndpointLink(endpoint.id)} iconRight>
Details
<Icon>keyboard_arrow_right</Icon>
</Button>
</Localized>
</Flex>
</TableCell>
</TableRow>
);
const enhanced = withFragmentContainer<Props>({
endpoint: graphql`
fragment WebhookEndpointRow_webhookEndpoint on WebhookEndpoint {
id
enabled
url
}
`,
})(WebhookEndpointRow);
export default enhanced;
@@ -0,0 +1,117 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent } from "react";
import { urls } from "coral-framework/helpers";
import { ExternalLink } from "coral-framework/lib/i18n/components";
import { graphql, withFragmentContainer } from "coral-framework/lib/relay";
import {
Button,
CallOut,
FormFieldDescription,
HorizontalGutter,
Icon,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
} from "coral-ui/components/v2";
import { WebhookEndpointsConfigContainer_settings } from "coral-admin/__generated__/WebhookEndpointsConfigContainer_settings.graphql";
import ConfigBox from "../../ConfigBox";
import Header from "../../Header";
import Subheader from "../../Subheader";
import ExperimentalWebhooksCallOut from "./ExperimentalWebhooksCallOut";
import WebhookEndpointRow from "./WebhookEndpointRow";
interface Props {
settings: WebhookEndpointsConfigContainer_settings;
}
const WebhookEndpointsConfigContainer: FunctionComponent<Props> = ({
settings,
}) => {
return (
<HorizontalGutter size="double" data-testid="webhooks-container">
<ExperimentalWebhooksCallOut />
<ConfigBox
title={
<Localized id="configure-webhooks-header-title">
<Header htmlFor="configure-webhooks-header.title">Webhooks</Header>
</Localized>
}
>
<Localized
id="configure-webhooks-description"
externalLink={
<ExternalLink href="https://github.com/coralproject/talk/blob/master/WEBHOOKS.md" />
}
>
<FormFieldDescription>
Configure an endpoint to send events to when events occur within
Coral. These events will be JSON encoded and signed. To learn more
about webhook signing, visit our{" "}
<ExternalLink href="https://github.com/coralproject/talk/blob/master/WEBHOOKS.md">
our docs
</ExternalLink>
.
</FormFieldDescription>
</Localized>
<Button
to={urls.admin.addWebhookEndpoint}
iconLeft
data-testid="add-webhook-endpoint"
>
<Icon size="md">add</Icon>
<Localized id="configure-webhooks-addEndpointButton">
Add webhook endpoint
</Localized>
</Button>
<Localized id="configure-webhooks-endpoints">
<Subheader>Endpoints</Subheader>
</Localized>
{settings.webhooks.endpoints.length > 0 ? (
<Table fullWidth>
<TableHead>
<TableRow>
<Localized id="configure-webhooks-url">
<TableCell>URL</TableCell>
</Localized>
<Localized id="configure-webhooks-status">
<TableCell>Status</TableCell>
</Localized>
<TableCell />
</TableRow>
</TableHead>
<TableBody>
{settings.webhooks.endpoints.map((endpoint, idx) => (
<WebhookEndpointRow key={idx} endpoint={endpoint} />
))}
</TableBody>
</Table>
) : (
<Localized id="configure-webhooks-noEndpoints">
<CallOut color="regular" fullWidth>
There are no webhook endpoints configured, add one above.
</CallOut>
</Localized>
)}
</ConfigBox>
</HorizontalGutter>
);
};
const enhanced = withFragmentContainer<Props>({
settings: graphql`
fragment WebhookEndpointsConfigContainer_settings on Settings {
webhooks {
endpoints {
...WebhookEndpointRow_webhookEndpoint
}
}
}
`,
})(WebhookEndpointsConfigContainer);
export default enhanced;
@@ -0,0 +1,37 @@
import React, { FunctionComponent } from "react";
import { graphql } from "react-relay";
import { withRouteConfig } from "coral-framework/lib/router";
import { Delay, Spinner } from "coral-ui/components/v2";
import { WebhookEndpointsConfigRouteQueryResponse } from "coral-admin/__generated__/WebhookEndpointsConfigRouteQuery.graphql";
import WebhookEndpointsConfigContainer from "./WebhookEndpointsConfigContainer";
interface Props {
data: WebhookEndpointsConfigRouteQueryResponse | null;
}
const WebhookEndpointsConfigRoute: FunctionComponent<Props> = ({ data }) => {
if (!data) {
return (
<Delay>
<Spinner />
</Delay>
);
}
return <WebhookEndpointsConfigContainer settings={data.settings} />;
};
const enhanced = withRouteConfig<Props>({
query: graphql`
query WebhookEndpointsConfigRouteQuery {
settings {
...WebhookEndpointsConfigContainer_settings
}
}
`,
})(WebhookEndpointsConfigRoute);
export default enhanced;

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