mirror of
https://github.com/wassname/talk.git
synced 2026-09-13 13:10:43 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d513e3807a | ||
|
|
faf4ad9480 | ||
|
|
e5ea1ee179 | ||
|
|
75c0bad073 | ||
|
|
8b92878e9b | ||
|
|
2b2ff7b9c4 | ||
|
|
2057b750f3 | ||
|
|
7abd703f72 | ||
|
|
6f1014289b | ||
|
|
45b778c522 | ||
|
|
7d967fc93b | ||
|
|
ae7b0fbb37 | ||
|
|
2478e9a250 | ||
|
|
fe8b6535a9 | ||
|
|
7989f87d02 | ||
|
|
769ab2a910 | ||
|
|
c052d37a6f | ||
|
|
a059e70d05 | ||
|
|
1a3401710f | ||
|
|
184f145c4a | ||
|
|
dcb2a10e72 | ||
|
|
7497e33046 | ||
|
|
330c30f1b3 | ||
|
|
e2185a33f5 | ||
|
|
d6db287c55 | ||
|
|
ca52cc3253 | ||
|
|
c2dadcc034 | ||
|
|
6865a71684 | ||
|
|
33556044a8 | ||
|
|
d883ab029c | ||
|
|
4b637a2dd5 | ||
|
|
e42c2b925d | ||
|
|
34ba2da88d | ||
|
|
cec76e0046 | ||
|
|
707d65a119 | ||
|
|
014aa2d86a | ||
|
|
94e1c75c0b | ||
|
|
3014017976 | ||
|
|
1388e0bc5e | ||
|
|
86191e388a | ||
|
|
2bf3e34218 | ||
|
|
75818cf808 | ||
|
|
503eb0b06a | ||
|
|
9cc3a57295 | ||
|
|
e42cb59661 | ||
|
|
a1a8652f7e | ||
|
|
a7b2af85fc | ||
|
|
516611e289 | ||
|
|
190010c898 | ||
|
|
2b1c9aea78 | ||
|
|
9c4a9753bb | ||
|
|
e43a3ddf75 | ||
|
|
6364eced63 | ||
|
|
62ce3a2c49 | ||
|
|
48d815aebb | ||
|
|
38ccbd0ef8 | ||
|
|
cfebe7733e | ||
|
|
e7b83c6b39 | ||
|
|
5c46884a97 | ||
|
|
4bd929f3f0 | ||
|
|
785735d36a | ||
|
|
20da16331e |
@@ -65,11 +65,10 @@ 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
|
||||
|
||||
# unit_tests will run the unit tests.
|
||||
unit_tests:
|
||||
|
||||
@@ -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.
|
||||
|
||||
-->
|
||||
|
||||
Vendored
+7
-10
@@ -12,10 +12,10 @@
|
||||
},
|
||||
"tslint.enable": false,
|
||||
"eslint.validate": [
|
||||
{ "language": "javascript", "autoFix": true },
|
||||
{ "language": "typescript", "autoFix": true },
|
||||
{ "language": "typescriptreact", "autoFix": true }
|
||||
],
|
||||
{ "language": "javascript", "autoFix": true },
|
||||
{ "language": "typescript", "autoFix": true },
|
||||
{ "language": "typescriptreact", "autoFix": true }
|
||||
],
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"postcss.validate": false,
|
||||
"javascript.preferences.importModuleSpecifier": "non-relative",
|
||||
@@ -44,18 +44,15 @@
|
||||
{
|
||||
"regex": "^[.]",
|
||||
"orderLevel": 50
|
||||
},
|
||||
}
|
||||
],
|
||||
"importSorter.importStringConfiguration.maximumNumberOfImportExpressionsPerLine.type": "newLineEachExpressionAfterCountLimitExceptIfOnlyOne",
|
||||
"importSorter.importStringConfiguration.trailingComma": "multiLine",
|
||||
"importSorter.importStringConfiguration.tabSize": 2,
|
||||
"eslint.enable": true,
|
||||
"importSorter.generalConfiguration.exclude": [
|
||||
"d\\.ts$",
|
||||
"__generated__"
|
||||
],
|
||||
"debug.node.autoAttach": "on",
|
||||
"importSorter.generalConfiguration.exclude": ["d\\.ts$", "__generated__"],
|
||||
"search.exclude": {
|
||||
"package-lock.json": true
|
||||
},
|
||||
"debug.node.autoAttach": "on"
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 don’t accidentally duplicate your effort.
|
||||
|
||||
If somebody claims an issue but doesn’t follow up for more than two weeks, it’s
|
||||
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
@@ -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;
|
||||
}
|
||||
```
|
||||
+10
-18
@@ -1,28 +1,20 @@
|
||||
/**
|
||||
* This is a project wide babel configuration.
|
||||
* https://babeljs.io/docs/en/config-files#project-wide-configuration
|
||||
*
|
||||
* We use this file to apply babel configuration to packages in `node_modules`
|
||||
*/
|
||||
|
||||
const lodashOptimizations =
|
||||
// Note: If Webpack env is set, we are building for the client.
|
||||
|
||||
const plugins =
|
||||
process.env.WEBPACK === "true" ? ["use-lodash-es", "lodash"] : [];
|
||||
|
||||
const environment =
|
||||
process.env.WEBPACK === "true"
|
||||
? { targets: "IE 11", modules: false }
|
||||
: { targets: { node: "current" }, modules: "commonjs" };
|
||||
|
||||
module.exports = {
|
||||
babelrcRoots: ["./src/core/client/*"],
|
||||
env: {
|
||||
production: {
|
||||
plugins: [...lodashOptimizations],
|
||||
},
|
||||
development: {
|
||||
plugins: [...lodashOptimizations],
|
||||
},
|
||||
test: {
|
||||
presets: [
|
||||
["@babel/env", { targets: { node: "current" } }],
|
||||
"@babel/react",
|
||||
],
|
||||
plugins: ["dynamic-import-node"],
|
||||
},
|
||||
},
|
||||
plugins,
|
||||
presets: [["@babel/env", environment]],
|
||||
};
|
||||
|
||||
@@ -7,7 +7,7 @@ module.exports = {
|
||||
collectCoverageFrom: ["**/*.{js,jsx,mjs,ts,tsx}"],
|
||||
coveragePathIgnorePatterns: ["/node_modules/"],
|
||||
setupFiles: [
|
||||
"<rootDir>/src/core/build/polyfills.js",
|
||||
"<rootDir>/src/core/client/test/polyfills.ts",
|
||||
"<rootDir>/src/core/client/test/setup.ts",
|
||||
],
|
||||
setupFilesAfterEnv: ["<rootDir>/src/core/client/test/setupTestFramework.ts"],
|
||||
|
||||
@@ -58,7 +58,7 @@ module.exports = {
|
||||
"@babel/typescript",
|
||||
[
|
||||
"@babel/env",
|
||||
{ targets: { node: "12.0.0" }, modules: "commonjs" },
|
||||
{ targets: { node: "current" }, modules: "commonjs" },
|
||||
],
|
||||
],
|
||||
// This is a feature of `babel-loader` for webpack (not Babel itself).
|
||||
|
||||
Generated
+2548
-1505
File diff suppressed because it is too large
Load Diff
+36
-30
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@coralproject/talk",
|
||||
"version": "5.4.2",
|
||||
"version": "6.0.0",
|
||||
"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/",
|
||||
@@ -131,7 +131,7 @@
|
||||
"permit": "^0.2.4",
|
||||
"prom-client": "^11.3.0",
|
||||
"proxy-agent": "^3.1.1",
|
||||
"querystringify": "^2.1.0",
|
||||
"querystringify": "^2.1.1",
|
||||
"react-helmet": "^5.2.1",
|
||||
"source-map-support": "^0.5.12",
|
||||
"stack-utils": "^1.0.2",
|
||||
@@ -143,15 +143,13 @@
|
||||
"verror": "^1.10.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.4.5",
|
||||
"@babel/plugin-syntax-dynamic-import": "^7.2.0",
|
||||
"@babel/plugin-transform-modules-commonjs": "^7.4.4",
|
||||
"@babel/polyfill": "^7.4.4",
|
||||
"@babel/preset-env": "^7.4.5",
|
||||
"@babel/preset-react": "^7.0.0",
|
||||
"@babel/preset-typescript": "^7.3.3",
|
||||
"@babel/core": "^7.8.3",
|
||||
"@babel/preset-env": "^7.8.3",
|
||||
"@babel/preset-react": "^7.8.3",
|
||||
"@babel/preset-typescript": "^7.8.3",
|
||||
"@babel/runtime-corejs3": "^7.8.3",
|
||||
"@coralproject/npm-run-all": "^4.1.5",
|
||||
"@coralproject/rte": "^0.11.0",
|
||||
"@coralproject/rte": "^0.11.1",
|
||||
"@fluent/react": "^0.10.0",
|
||||
"@intervolga/optimize-cssnano-plugin": "^1.0.6",
|
||||
"@types/agent-base": "^4.2.0",
|
||||
@@ -220,12 +218,12 @@
|
||||
"@types/react-copy-to-clipboard": "^4.2.6",
|
||||
"@types/react-dom": "^16.8.4",
|
||||
"@types/react-helmet": "^5.0.10",
|
||||
"@types/react-relay": "^1.3.14",
|
||||
"@types/react-relay": "^5.0.1",
|
||||
"@types/react-responsive": "^3.0.1",
|
||||
"@types/react-test-renderer": "^16.8.1",
|
||||
"@types/react-transition-group": "^2.0.14",
|
||||
"@types/recompose": "^0.26.5",
|
||||
"@types/relay-runtime": "^1.3.12",
|
||||
"@types/relay-runtime": "^5.0.5",
|
||||
"@types/sane": "^2.0.0",
|
||||
"@types/shallow-equals": "^1.0.0",
|
||||
"@types/simplemde": "^1.11.7",
|
||||
@@ -249,14 +247,12 @@
|
||||
"ansi-styles": "^3.2.0",
|
||||
"autoprefixer": "^9.5.1",
|
||||
"babel-core": "^7.0.0-bridge.0",
|
||||
"babel-jest": "^24.8.0",
|
||||
"babel-loader": "^8.0.5",
|
||||
"babel-plugin-dynamic-import-node": "^2.2.0",
|
||||
"babel-jest": "^24.9.0",
|
||||
"babel-loader": "^8.0.6",
|
||||
"babel-plugin-lodash": "^3.3.4",
|
||||
"babel-plugin-module-resolver": "^3.2.0",
|
||||
"babel-plugin-relay": "^4.0.0",
|
||||
"babel-plugin-relay": "^5.0.0",
|
||||
"babel-plugin-use-lodash-es": "^0.2.0",
|
||||
"babel-preset-react-optimize": "^1.0.1",
|
||||
"bowser": "^1.9.4",
|
||||
"case-sensitive-paths-webpack-plugin": "^2.2.0",
|
||||
"chalk": "^2.4.2",
|
||||
@@ -267,8 +263,10 @@
|
||||
"common-tags": "^1.8.0",
|
||||
"compression-webpack-plugin": "^2.0.0",
|
||||
"copy-webpack-plugin": "^5.0.3",
|
||||
"core-js": "^3.6.4",
|
||||
"cross-spawn": "^6.0.5",
|
||||
"css-loader": "^1.0.1",
|
||||
"css-vars-ponyfill": "^2.1.2",
|
||||
"del": "^4.1.1",
|
||||
"doctoc": "^1.4.0",
|
||||
"docz": "^0.13.7",
|
||||
@@ -289,8 +287,8 @@
|
||||
"flat": "^4.1.0",
|
||||
"fluent-intl-polyfill": "^0.1.0",
|
||||
"fork-ts-checker-webpack-plugin": "^1.5.0",
|
||||
"found": "^0.4.0-alpha.17",
|
||||
"found-relay": "^0.4.0-alpha.8",
|
||||
"found": "^0.4.9",
|
||||
"found-relay": "^0.4.3",
|
||||
"graphql-schema-linter": "^0.2.0",
|
||||
"graphql-schema-typescript": "^1.2.9",
|
||||
"gulp": "^4.0.2",
|
||||
@@ -298,7 +296,7 @@
|
||||
"gulp-cli": "^2.2.0",
|
||||
"gulp-sourcemaps": "^2.6.5",
|
||||
"gulp-typescript": "^5.0.1",
|
||||
"html-webpack-plugin": "^4.0.0-beta.5",
|
||||
"html-webpack-plugin": "^4.0.0-beta.11",
|
||||
"husky": "^2.2.0",
|
||||
"intersection-observer": "^0.6.0",
|
||||
"jest": "^24.8.0",
|
||||
@@ -314,6 +312,7 @@
|
||||
"mini-css-extract-plugin": "^0.6.0",
|
||||
"object-diff": "0.0.4",
|
||||
"postcss-advanced-variables": "^3.0.0",
|
||||
"postcss-calc-function": "^1.1.0",
|
||||
"postcss-css-variables": "^0.11.0",
|
||||
"postcss-flexbugs-fixes": "^4.1.0",
|
||||
"postcss-font-magician": "^2.2.1",
|
||||
@@ -326,32 +325,35 @@
|
||||
"postcss-preset-env": "^6.5.0",
|
||||
"prettier": "^1.18.2",
|
||||
"prop-types": "^15.6.2",
|
||||
"proxy-polyfill": "^0.3.0",
|
||||
"pstree.remy": "^1.1.6",
|
||||
"pym.js": "^1.3.2",
|
||||
"raw-loader": "^0.5.1",
|
||||
"react": "^16.9.0-alpha.0",
|
||||
"react": "^16.9.0",
|
||||
"react-axe": "^3.3.0",
|
||||
"react-copy-to-clipboard": "^5.0.1",
|
||||
"react-dev-utils": "^9.0.0",
|
||||
"react-dom": "^16.9.0-alpha.0",
|
||||
"react-dom": "^16.9.0",
|
||||
"react-error-overlay": "^5.1.6",
|
||||
"react-final-form": "6.3.0",
|
||||
"react-final-form-arrays": "3.1.0",
|
||||
"react-popper": "^1.3.2",
|
||||
"react-relay": "^4.0.0",
|
||||
"react-relay": "^5.0.0",
|
||||
"react-relay-network-modern": "^4.0.4",
|
||||
"react-responsive": "^7.0.0",
|
||||
"react-test-renderer": "^16.9.0-alpha.0",
|
||||
"react-test-renderer": "^16.9.0",
|
||||
"react-timeago": "^4.1.9",
|
||||
"react-transition-group": "^2.9.0",
|
||||
"react-with-state-props": "^2.0.4",
|
||||
"recompose": "0.27.1",
|
||||
"relay-compiler": "^4.0.0",
|
||||
"regenerator-runtime": "^0.13.3",
|
||||
"relay-compiler": "^5.0.0",
|
||||
"relay-compiler-language-typescript": "^4.1.0",
|
||||
"relay-local-schema": "^0.8.0",
|
||||
"relay-runtime": "^4.0.0",
|
||||
"relay-runtime": "^5.0.0",
|
||||
"resize-observer-polyfill": "^1.5.1",
|
||||
"sane": "^4.1.0",
|
||||
"scheduler": "^0.15.0",
|
||||
"shallow-equals": "^1.0.0",
|
||||
"simplemde": "^1.11.2",
|
||||
"simulant": "^0.2.2",
|
||||
@@ -379,7 +381,7 @@
|
||||
"typescript": "3.3.4000",
|
||||
"typescript-snapshots-plugin": "^1.6.0",
|
||||
"wait-for-expect": "^1.1.1",
|
||||
"webpack": "^4.30.0",
|
||||
"webpack": "^4.41.5",
|
||||
"webpack-assets-manifest": "^3.1.1",
|
||||
"webpack-bundle-analyzer": "^3.3.2",
|
||||
"webpack-cli": "^3.3.2",
|
||||
@@ -398,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": [
|
||||
|
||||
@@ -52,6 +52,9 @@ const args = [
|
||||
"--language",
|
||||
"typescript",
|
||||
"--no-watchman",
|
||||
"--customScalars.Time=String",
|
||||
"--customScalars.Cursor=unknown",
|
||||
"--customScalars.Locale=string",
|
||||
"--src",
|
||||
program.src,
|
||||
"--artifactDirectory",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -24,7 +24,8 @@ var url = require("url");
|
||||
var launchEditorEndpoint = require("react-dev-utils/launchEditorEndpoint");
|
||||
var formatWebpackMessages = require("react-dev-utils/formatWebpackMessages");
|
||||
var ErrorOverlay = require("react-error-overlay");
|
||||
var { debounce } = require("lodash");
|
||||
var lodash = require("lodash");
|
||||
var debounce = lodash.debounce;
|
||||
|
||||
ErrorOverlay.setEditorHandler(function editorHandler(errorLocation) {
|
||||
// Keep this sync with errorOverlayMiddleware.js
|
||||
@@ -124,7 +125,7 @@ function handleWarnings(warnings) {
|
||||
|
||||
// TODO: remove this workaround when we can upgrade to WebpackDevServer >= v3.3.0,
|
||||
// which includes proper `warningsFilter` support.
|
||||
warnings = warnings.filter(w => !/export .* was not found in/.test(w));
|
||||
warnings = warnings.filter(function(w){ return !/export .* was not found in/.test(w)});
|
||||
|
||||
function printWarnings() {
|
||||
// Print warnings to the console.
|
||||
@@ -198,7 +199,7 @@ function handleAvailableHash(hash) {
|
||||
mostRecentCompilationHash = hash;
|
||||
}
|
||||
|
||||
const debouncedReload = debounce(() => {
|
||||
const debouncedReload = debounce(function() {
|
||||
window.location.reload();
|
||||
}, 1000);
|
||||
|
||||
|
||||
@@ -18,8 +18,7 @@ import { BundleAnalyzerPlugin } from "webpack-bundle-analyzer";
|
||||
// TODO: import form coral-common/version, for some reason this fails currently.
|
||||
// Try again when we have a chance to upgrade typescript.
|
||||
import { version } from "../common/version";
|
||||
import { Config } from "./config";
|
||||
import { createClientEnv } from "./config";
|
||||
import { Config, createClientEnv } from "./config";
|
||||
import paths from "./paths";
|
||||
|
||||
/**
|
||||
@@ -392,7 +391,7 @@ export default function createWebpackConfig(
|
||||
"@babel/typescript",
|
||||
[
|
||||
"@babel/env",
|
||||
{ targets: { node: "12.0.0" }, modules: "commonjs" },
|
||||
{ targets: { node: "current" }, modules: "commonjs" },
|
||||
],
|
||||
],
|
||||
// This is a feature of `babel-loader` for webpack (not Babel itself).
|
||||
@@ -450,19 +449,11 @@ export default function createWebpackConfig(
|
||||
{
|
||||
test: /\.js$/,
|
||||
include: /node_modules\//,
|
||||
exclude: /node_modules\/(@babel|babel|core-js|regenerator-runtime)/,
|
||||
use: [
|
||||
{
|
||||
loader: require.resolve("babel-loader"),
|
||||
options: {
|
||||
// This will ensure that all packages in node_modules that
|
||||
// import lodash do so in a way that supports tree shaking.
|
||||
plugins: ["lodash"],
|
||||
presets: [
|
||||
[
|
||||
"@babel/env",
|
||||
{ targets: "last 2 versions", modules: false },
|
||||
],
|
||||
],
|
||||
cacheDirectory: true,
|
||||
},
|
||||
},
|
||||
@@ -510,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]",
|
||||
@@ -712,8 +698,7 @@ export default function createWebpackConfig(
|
||||
sideEffects: true,
|
||||
},
|
||||
entry: [
|
||||
/* Use minimal amount of polyfills (for IE) */
|
||||
"intersection-observer", // also for Safari
|
||||
// Polyfills are in the index.ts file.
|
||||
paths.appEmbedIndex,
|
||||
],
|
||||
output: {
|
||||
|
||||
@@ -16,7 +16,7 @@ export default {
|
||||
appLoaders: resolveSrc("core/build/loaders"),
|
||||
appSrc: resolveSrc("."),
|
||||
appTsconfig: resolveSrc("core/client/tsconfig.json"),
|
||||
appPolyfill: resolveSrc("core/build/polyfills.js"),
|
||||
appPolyfill: resolveSrc("core/build/polyfills.ts"),
|
||||
appPublicPath: resolveSrc("core/build/publicPath.js"),
|
||||
appLocales: resolveSrc("locales"),
|
||||
appThemeVariables: resolveSrc("core/client/ui/theme/variables.ts"),
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
require("@babel/polyfill");
|
||||
require("intersection-observer");
|
||||
@@ -0,0 +1,6 @@
|
||||
import polyfillNodeListForEach from "../client/framework/helpers/polyfillNodeListForEach";
|
||||
|
||||
import "core-js/stable";
|
||||
import "regenerator-runtime/runtime";
|
||||
|
||||
polyfillNodeListForEach();
|
||||
@@ -7,6 +7,7 @@ const pickBy = require("lodash/pickBy");
|
||||
const flat = require("flat");
|
||||
const paths = require("./paths").default;
|
||||
const autoprefixer = require("autoprefixer");
|
||||
const postcssCalcFunction = require("postcss-calc-function").default;
|
||||
const postcssFontMagician = require("postcss-font-magician");
|
||||
const postcssFlexbugsFixes = require("postcss-flexbugs-fixes");
|
||||
const postcssPresetEnv = require("postcss-preset-env");
|
||||
@@ -52,6 +53,8 @@ module.exports = {
|
||||
postcssNested(),
|
||||
// Sass style variables to be used in media queries.
|
||||
postcssAdvancedVariables({ variables: mediaQueryVariables }),
|
||||
// Reduce some calc()
|
||||
postcssCalcFunction(),
|
||||
// Provides a modern CSS environment.
|
||||
postcssPresetEnv(),
|
||||
// Does all the font handling logic.
|
||||
|
||||
@@ -1,20 +1,3 @@
|
||||
const lodashOptimizations = ["use-lodash-es", "lodash"];
|
||||
|
||||
module.exports = {
|
||||
presets: ["@babel/react"],
|
||||
plugins: ["@babel/syntax-dynamic-import"],
|
||||
env: {
|
||||
production: {
|
||||
presets: [["@babel/env", { targets: "last 2 versions", modules: false }]],
|
||||
plugins: [...lodashOptimizations],
|
||||
},
|
||||
development: {
|
||||
presets: [["@babel/env", { targets: "last 2 versions", modules: false }]],
|
||||
plugins: [...lodashOptimizations],
|
||||
},
|
||||
test: {
|
||||
presets: [["@babel/env", { targets: { node: "current" } }]],
|
||||
plugins: ["@babel/transform-modules-commonjs"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -2,4 +2,7 @@
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
input::-ms-clear, input::-ms-reveal {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { FunctionComponent } from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
|
||||
import injectConditionalPolyfills from "coral-framework/helpers/injectConditionalPolyfills";
|
||||
import potentiallyInjectAxe from "coral-framework/helpers/potentiallyInjectAxe";
|
||||
import { createManaged } from "coral-framework/lib/bootstrap";
|
||||
|
||||
@@ -12,6 +13,7 @@ import localesData from "./locales";
|
||||
import "coral-ui/theme/variables.css";
|
||||
|
||||
async function main() {
|
||||
await injectConditionalPolyfills();
|
||||
// Potentially inject react-axe for runtime a11y checks.
|
||||
await potentiallyInjectAxe();
|
||||
const ManagedCoralContextProvider = await createManaged({
|
||||
|
||||
@@ -90,7 +90,9 @@ exports[`renders missing confirm token 1`] = `
|
||||
<div
|
||||
className="CallOut-root CallOut-colorError CallOut-fullWidth"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
className="CallOut-inner"
|
||||
>
|
||||
<span
|
||||
data-testid="invalid-link"
|
||||
>
|
||||
|
||||
@@ -147,7 +147,9 @@ exports[`renders missing reset token 1`] = `
|
||||
<div
|
||||
className="CallOut-root CallOut-colorError CallOut-fullWidth"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
className="CallOut-inner"
|
||||
>
|
||||
<span
|
||||
data-testid="invalid-link"
|
||||
>
|
||||
|
||||
@@ -80,7 +80,9 @@ exports[`renders missing confirm token 1`] = `
|
||||
<div
|
||||
className="CallOut-root CallOut-colorError CallOut-fullWidth"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
className="CallOut-inner"
|
||||
>
|
||||
<span
|
||||
data-testid="invalid-link"
|
||||
>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
$decisionHistoryBorderColor: var(--v2-colors-grey-300);
|
||||
|
||||
.root {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style-type: none;
|
||||
|
||||
& > * {
|
||||
border-bottom: 1px solid var(--v2-palette-decision-history-border);
|
||||
border-bottom: 1px solid $decisionHistoryBorderColor;
|
||||
}
|
||||
& > *:last-child {
|
||||
border-bottom: 0;
|
||||
|
||||
@@ -2,6 +2,6 @@
|
||||
height: 100%;
|
||||
font-size: var(--v2-font-size-1);
|
||||
line-height: var(--v2-line-height-reset);
|
||||
color: var(--v2-palette-timestamp);
|
||||
color: var(--v2-colors-mono-100);
|
||||
padding: 0 var(--v2-spacing-1);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
$moderateCardDecisionHistoryLinkTextColor: var(--v2-colors-teal-700);
|
||||
|
||||
.root {
|
||||
line-height: var(--v2-line-height-reset);
|
||||
font-weight: var(--v2-font-weight-primary-bold);
|
||||
font-family: var(--v2-font-family-primary);
|
||||
font-size: var(--v2-font-size-1);
|
||||
color: var(--v2-palette-link-text);
|
||||
color: $moderateCardDecisionHistoryLinkTextColor;
|
||||
font-style: var(--v2-font-style-underline);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
$moderateCardDecisionHistoryLinkTextColor: var(--v2-colors-teal-700);
|
||||
$moderateCardDecisionHistoryBorderColor: var(--v2-colors-grey-300);
|
||||
|
||||
.root {
|
||||
font-size: var(--v2-font-size-2);
|
||||
font-weight: var(--v2-font-weight-primary-bold);
|
||||
font-family: var(--v2-font-family-primary);
|
||||
line-height: var(--v2-line-height-reset);
|
||||
text-transform: uppercase;
|
||||
color: var(--v2-palette-link-text);
|
||||
color: $moderateCardDecisionHistoryLinkTextColor;
|
||||
width: 100%;
|
||||
border-top: 1px solid var(--v2-palette-decision-history-border);
|
||||
border-top: 1px solid $moderateCardDecisionHistoryBorderColor;
|
||||
text-align: center;
|
||||
padding-top: var(--v2-spacing-1);
|
||||
padding-bottom: var(--v2-spacing-1);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
$timestampColor: var(--v2-colors-mono-100);
|
||||
|
||||
.root {
|
||||
color: var(--v2-palette-timestamp);
|
||||
color: $timestampColor;
|
||||
font-family: var(--v2-font-family-primary);
|
||||
font-weight: var(--v2-font-weight-primary-bold);
|
||||
font-size: var(--v2-font-size-1);
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
$decisionHistoryBorderColor: var(--v2-colors-grey-300);
|
||||
$decisionHistoryTitleBackgroundColor: var(--v2-colors-grey-200);
|
||||
|
||||
.root {
|
||||
background: var(--v2-palette-decision-history-title-background);
|
||||
background: $decisionHistoryTitleBackgroundColor;
|
||||
padding: var(--v2-spacing-1);
|
||||
color: var(--v2-palette-text-primary);
|
||||
border-bottom: 1px solid var(--v2-palette-decision-history-border);
|
||||
border-bottom: 1px solid $decisionHistoryBorderColor;
|
||||
font-size: var(--v2-font-size-icon-medium);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
$moderateCardDecisionHistoryColor: var(--v2-colors-mono-900);
|
||||
|
||||
.root {
|
||||
color: var(--v2-palette-username-emphasis);
|
||||
color: $moderateCardDecisionHistoryColor;
|
||||
font-weight: var(--v2-font-weight-secondary-bold);
|
||||
font-family: var(--v2-font-family-secondary);
|
||||
line-height: var(--v2-line-height-body-short);
|
||||
|
||||
@@ -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;
|
||||
@@ -3,6 +3,9 @@
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
input::-ms-clear, input::-ms-reveal {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.root {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -9,7 +9,7 @@ interface Props {
|
||||
}
|
||||
|
||||
const NavigationLink: FunctionComponent<Props> = props => (
|
||||
<Link to={props.to} as={AppBarNavigationItem} activePropName="active">
|
||||
<Link to={props.to} as={AppBarNavigationItem as any} activePropName="active">
|
||||
{props.children}
|
||||
</Link>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
$userMenuButtonColor: var(--v2-colors-mono-100);
|
||||
|
||||
.button {
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
@@ -5,12 +7,12 @@
|
||||
}
|
||||
|
||||
.icon {
|
||||
color: var(--v2-palette-user-menu-button);
|
||||
color: $userMenuButtonColor;
|
||||
}
|
||||
|
||||
.buttonText {
|
||||
font-family: var(--v2-font-family-secondary);
|
||||
color: var(--v2-palette-user-menu-button);
|
||||
color: $userMenuButtonColor;
|
||||
font-size: var(--v2-font-size-4);
|
||||
margin-left: 2px;
|
||||
margin-right: 1px;
|
||||
|
||||
@@ -28,7 +28,10 @@ exports[`renders correctly 1`] = `
|
||||
/>
|
||||
</withPropsOnChange(End)>
|
||||
</withPropsOnChange(AppBar)>
|
||||
child
|
||||
<NotificationProvider>
|
||||
<NotificationContainer />
|
||||
child
|
||||
</NotificationProvider>
|
||||
<Version />
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -1,17 +1,8 @@
|
||||
.container {
|
||||
width: 350px;
|
||||
background-color: #f5f5f5;
|
||||
border: 1px solid var(--palette-grey-lighter);
|
||||
width: 385px;
|
||||
background-color: var(--v2-colors-grey-100);
|
||||
border: 1px solid var(--v2-colors-grey-300);
|
||||
margin-top: 70px;
|
||||
padding: 0 calc(3 * var(--mini-unit)) calc(4 * var(--mini-unit))
|
||||
calc(3 * var(--mini-unit));
|
||||
padding: var(--v2-spacing-6);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.brandIcon {
|
||||
display: inline-flex;
|
||||
background: var(--palette-common-white);
|
||||
padding: calc(0.5 * var(--mini-unit));
|
||||
border: 1px solid var(--palette-grey-lighter);
|
||||
margin-top: -42px;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import React, { FunctionComponent } from "react";
|
||||
|
||||
import {
|
||||
BrandMark,
|
||||
BrandName,
|
||||
Flex,
|
||||
HorizontalGutter,
|
||||
Typography,
|
||||
} from "coral-ui/components";
|
||||
import { BrandName, Flex, HorizontalGutter } from "coral-ui/components/v2";
|
||||
|
||||
import styles from "./AuthBox.css";
|
||||
|
||||
@@ -20,15 +14,10 @@ const AuthBox: FunctionComponent<Props> = ({ title, children }) => {
|
||||
<div data-testid="authBox">
|
||||
<Flex justifyContent="center">
|
||||
<HorizontalGutter className={styles.container} size="double">
|
||||
<Flex justifyContent="center">
|
||||
<div className={styles.brandIcon}>
|
||||
<BrandMark size="lg" />
|
||||
</div>
|
||||
</Flex>
|
||||
<div>
|
||||
<Typography align="center" variant="heading3">
|
||||
{title}
|
||||
</Typography>
|
||||
<Flex justifyContent="center">
|
||||
<div className="adminSignInTitle">{title}</div>
|
||||
</Flex>
|
||||
<BrandName size="lg" align="center" />
|
||||
</div>
|
||||
{children}
|
||||
|
||||
@@ -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;
|
||||
@@ -1,5 +1,7 @@
|
||||
$moderateCardButtonOutlineApproveColor: var(--v2-colors-green-500);
|
||||
|
||||
.root {
|
||||
border: 1px solid var(--v2-palette-button-outline-approve);
|
||||
border: 1px solid $moderateCardButtonOutlineApproveColor;
|
||||
box-sizing: border-box;
|
||||
border-radius: var(--v2-round-corners);
|
||||
width: 65px;
|
||||
@@ -7,15 +9,15 @@
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: var(--v2-palette-button-outline-approve);
|
||||
color: $moderateCardButtonOutlineApproveColor;
|
||||
&:not(:disabled):active {
|
||||
background-color: var(--v2-palette-button-outline-approve);
|
||||
background-color: $moderateCardButtonOutlineApproveColor;
|
||||
color: var(--v2-colors-pure-white);
|
||||
}
|
||||
}
|
||||
|
||||
.invert {
|
||||
background-color: var(--v2-palette-button-outline-approve);
|
||||
background-color: $moderateCardButtonOutlineApproveColor;
|
||||
color: var(--v2-colors-pure-white);
|
||||
}
|
||||
|
||||
|
||||
@@ -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, "'");
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ const FeatureCommentMutation = createMutation(
|
||||
getQueueConnection(store, "REJECTED", input.storyID),
|
||||
].filter(c => c);
|
||||
connections.forEach(con =>
|
||||
ConnectionHandler.deleteNode(con, input.commentID)
|
||||
ConnectionHandler.deleteNode(con!, input.commentID)
|
||||
);
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
$moderateCardFlagsCategoryColor: var(--v2-colors-mono-500);
|
||||
|
||||
.category {
|
||||
font-size: var(--v2-font-size-4);
|
||||
font-weight: var(--v2-font-weight-primary-regular);
|
||||
font-family: var(--v2-font-family-primary);
|
||||
line-height: var(--v2-line-height-body-short);
|
||||
color: var(--v2-palette-accordion-label);
|
||||
color: $moderateCardFlagsCategoryColor;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
$moderateCardFlagUsernameColor: var(--v2-colors-mono-900);
|
||||
$moderateCardReasonTextColor: var(--v2-colors-mono-500);
|
||||
|
||||
.user {
|
||||
color: var(--v2-palette-username-emphasis);
|
||||
color: $moderateCardFlagUsernameColor;
|
||||
font-size: var(--v2-font-size-2);
|
||||
font-weight: var(--v2-font-weight-secondary-bold);
|
||||
font-family: var(--v2-font-family-secondary);
|
||||
@@ -8,7 +11,7 @@
|
||||
}
|
||||
|
||||
.details {
|
||||
color: var(--v2-palette-reason-text);
|
||||
color: $moderateCardReasonTextColor;
|
||||
font-size: var(--v2-font-size-2);
|
||||
font-weight: var(--v2-font-weight-primary-regular);
|
||||
font-family: var(--v2-font-family-primary);
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
$moderateCardIconColor: var(--v2-colors-mono-100);
|
||||
$moderateCardInReplyToColor: var(--v2-colors-mono-100);
|
||||
$moderateCardInReplyToUsernameColor: var(--v2-colors-mono-500);
|
||||
$moderateCardUsernameBackgroundHover: var(--v2-colors-grey-200);
|
||||
|
||||
.icon {
|
||||
color: var(--v2-palette-label-regular);
|
||||
color: $moderateCardIconColor;
|
||||
}
|
||||
.inReplyTo {
|
||||
color: var(--v2-palette-label-regular);
|
||||
color: $moderateCardInReplyToColor;
|
||||
font-family: var(--v2-font-family-primary);
|
||||
font-weight: var(--v2-font-weight-primary-regular);
|
||||
font-size: var(--v2-font-size-2);
|
||||
line-height: var(--v2-line-height-reset);
|
||||
}
|
||||
.username {
|
||||
color: var(--v2-palette-username-regular);
|
||||
color: $moderateCardInReplyToUsernameColor;
|
||||
font-family: var(--v2-font-family-secondary);
|
||||
font-weight: var(--v2-font-weight-secondary-bold);
|
||||
font-size: var(--v2-font-size-3);
|
||||
@@ -18,13 +23,13 @@
|
||||
padding: var(--spacing-1);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--v2-palette-username-background-hover);
|
||||
background-color: $moderateCardUsernameBackgroundHover;
|
||||
border-radius: var(--round-corners);
|
||||
border-style: none;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: var(--v2-palette-username-background-hover);
|
||||
background-color: $moderateCardUsernameBackgroundHover;
|
||||
border-radius: var(--round-corners);
|
||||
border-style: none;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
$moderateCardDetailsHeaderColor: var(--v2-colors-mono-500);
|
||||
$moderateCardDetailsDividerColor: var(--v2-colors-grey-100);
|
||||
|
||||
.detailsButton {
|
||||
font-size: var(--v2-font-size-1);
|
||||
font-family: var(--v2-font-family-primary);
|
||||
@@ -12,7 +15,7 @@
|
||||
}
|
||||
|
||||
.detailsButtonColorRegular {
|
||||
border-color: var(--v2-palette-details-header);
|
||||
border-color: $moderateCardDetailsHeaderColor;
|
||||
}
|
||||
|
||||
.detailsText {
|
||||
@@ -20,5 +23,5 @@
|
||||
}
|
||||
|
||||
.detailsDivider {
|
||||
border-color: var(--v2-palette-details-divider);
|
||||
border-color: $moderateCardDetailsDividerColor;
|
||||
}
|
||||
|
||||
@@ -56,9 +56,7 @@ const markers: Array<
|
||||
(c.revision &&
|
||||
c.revision.actionCounts.flag.reasons.COMMENT_DETECTED_SUSPECT_WORD && (
|
||||
<Localized id="moderate-marker-suspectWord" key={keyCounter++}>
|
||||
<Marker color="reported" variant="filled">
|
||||
Suspect Word
|
||||
</Marker>
|
||||
<Marker color="reported">Suspect Word</Marker>
|
||||
</Localized>
|
||||
)) ||
|
||||
null,
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
$moderateCardTimestampColor: var(--v2-colors-mono-100);
|
||||
$moderateCardEditedColor: var(--v2-colors-mono-100);
|
||||
$moderateCardAuthorStatusColor: var(--v2-colors-mono-100);
|
||||
$moderateCardDecisionColor: var(--v2-colors-mono-500);
|
||||
$moderateCardStoryTitleColor: var(--v2-colors-mono-500);
|
||||
$moderateCardUsernameBackgroundHover: var(--v2-colors-grey-200);
|
||||
$moderateCardLinkTextColor: var(--v2-colors-teal-700);
|
||||
|
||||
.topBar {
|
||||
position: relative;
|
||||
margin-bottom: var(--v2-spacing-1);
|
||||
@@ -15,13 +23,13 @@
|
||||
line-height: var(--v2-line-height-reset);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--v2-palette-username-background-hover);
|
||||
background-color: $moderateCardUsernameBackgroundHover;
|
||||
border-radius: var(--v2-round-corners);
|
||||
border-style: none;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: var(--v2-palette-username-background-hover);
|
||||
background-color: $moderateCardUsernameBackgroundHover;
|
||||
border-radius: var(--v2-round-corners);
|
||||
border-style: none;
|
||||
}
|
||||
@@ -76,7 +84,7 @@
|
||||
}
|
||||
|
||||
.decision {
|
||||
color: var(--v2-palette-label-emphasis);
|
||||
color: $moderateCardDecisionColor;
|
||||
font-size: var(--v2-font-size-2);
|
||||
font-weight: var(--v2-font-weight-primary-bold);
|
||||
font-family: var(--v2-font-family-primary);
|
||||
@@ -114,7 +122,7 @@
|
||||
font-weight: var(--v2-font-weight-primary-semi-bold);
|
||||
font-family: var(--v2-font-family-primary);
|
||||
line-height: var(--v2-line-height-reset);
|
||||
color: var(--v2-palette-link-text);
|
||||
color: $moderateCardLinkTextColor;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
@@ -133,14 +141,24 @@
|
||||
}
|
||||
|
||||
.storyTitle {
|
||||
color: var(--v2-palette-story-title);
|
||||
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;
|
||||
@@ -157,7 +175,7 @@
|
||||
}
|
||||
|
||||
.timestamp {
|
||||
color: var(--v2-palette-timestamp);
|
||||
color: $moderateCardTimestampColor;
|
||||
font-family: var(--v2-font-family-primary);
|
||||
font-weight: var(--v2-font-weight-primary-regular);
|
||||
font-size: var(--v2-font-size-1);
|
||||
@@ -165,7 +183,7 @@
|
||||
}
|
||||
|
||||
.edited {
|
||||
color: var(--v2-palette-timestamp);
|
||||
color: $moderateCardEditedColor;
|
||||
font-family: var(--v2-font-family-primary);
|
||||
font-weight: var(--v2-font-weight-primary-regular);
|
||||
font-size: var(--v2-font-size-1);
|
||||
@@ -179,7 +197,7 @@
|
||||
|
||||
.authorStatus {
|
||||
padding-right: var(--spacing-2);
|
||||
color: var(--v2-palette-timestamp);
|
||||
color: $moderateCardAuthorStatusColor;
|
||||
font-family: var(--v2-font-family-primary);
|
||||
font-weight: var(--v2-font-weight-primary-regular);
|
||||
font-size: var(--v2-font-size-1);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
`,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
$moderateCardButtonOutlineRejectColor: var(--v2-colors-red-500);
|
||||
|
||||
.root {
|
||||
border: 1px solid var(--v2-palette-button-outline-reject);
|
||||
border: 1px solid $moderateCardButtonOutlineRejectColor;
|
||||
box-sizing: border-box;
|
||||
border-radius: var(--v2-round-corners);
|
||||
width: 65px;
|
||||
@@ -7,15 +9,15 @@
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: var(--v2-palette-button-outline-reject);
|
||||
color: $moderateCardButtonOutlineRejectColor;
|
||||
&:not(:disabled):active {
|
||||
background-color: var(--v2-palette-button-outline-reject);
|
||||
background-color: $moderateCardButtonOutlineRejectColor;
|
||||
color: var(--v2-colors-pure-white);
|
||||
}
|
||||
}
|
||||
|
||||
.invert {
|
||||
background-color: var(--v2-palette-button-outline-reject);
|
||||
background-color: $moderateCardButtonOutlineRejectColor;
|
||||
color: var(--v2-colors-pure-white);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
$moderateCardCounterBadgeBackgroundAlert: var(--v2-colors-red-500);
|
||||
$moderateCardToxicityColor: var(--v2-colors-mono-500);
|
||||
|
||||
.root {
|
||||
font-size: var(--v2-font-size-2);
|
||||
font-weight: var(--v2-font-weight-primary-semi-bold);
|
||||
font-family: var(--v2-font-family-primary);
|
||||
line-height: var(--v2-line-height-body-short);
|
||||
color: var(--v2-palette-accordion-label);
|
||||
color: $moderateCardToxicityColor;
|
||||
}
|
||||
|
||||
.likely {
|
||||
color: var(--v2-palette-counter-badge-background-alert);
|
||||
color: $moderateCardCounterBadgeBackgroundAlert;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
$moderateCardUsernameColor: var(--v2-colors-mono-900);
|
||||
|
||||
.root {
|
||||
font-size: var(--v2-font-size-3);
|
||||
font-weight: var(--v2-font-weight-secondary-bold);
|
||||
font-family: var(--v2-font-family-secondary);
|
||||
line-height: var(--v2-line-height-reset);
|
||||
color: var(--v2-palette-username-emphasis);
|
||||
color: $moderateCardUsernameColor;
|
||||
}
|
||||
|
||||
-1
@@ -82,7 +82,6 @@ exports[`renders all markers 1`] = `
|
||||
>
|
||||
<withPropsOnChange(Marker)
|
||||
color="reported"
|
||||
variant="filled"
|
||||
>
|
||||
Suspect Word
|
||||
</withPropsOnChange(Marker)>
|
||||
|
||||
+90
-65
@@ -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
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
$tableBodyDisabledColor: var(--v2-colors-mono-100);
|
||||
|
||||
.root {
|
||||
color: var(--v2-palette-table-body-disabled);
|
||||
color: $tableBodyDisabledColor;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
+6
-3
@@ -1,8 +1,11 @@
|
||||
import { Localized } from "@fluent/react/compat";
|
||||
import React, { FunctionComponent } from "react";
|
||||
import { ReadyState } from "react-relay";
|
||||
|
||||
import { graphql, QueryRenderer } from "coral-framework/lib/relay";
|
||||
import {
|
||||
graphql,
|
||||
QueryRenderData,
|
||||
QueryRenderer,
|
||||
} from "coral-framework/lib/relay";
|
||||
import { CallOut, Spinner } from "coral-ui/components/v2";
|
||||
|
||||
import { UserDrawerAccountHistoryQuery as QueryTypes } from "coral-admin/__generated__/UserDrawerAccountHistoryQuery.graphql";
|
||||
@@ -29,7 +32,7 @@ const UserDrawerAccountHistoryQuery: FunctionComponent<Props> = ({
|
||||
`}
|
||||
variables={{ userID }}
|
||||
cacheConfig={{ force: true }}
|
||||
render={({ error, props }: ReadyState<QueryTypes["response"]>) => {
|
||||
render={({ error, props }: QueryRenderData<QueryTypes>) => {
|
||||
if (error) {
|
||||
return (
|
||||
<div className={styles.callout}>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Localized } from "@fluent/react/compat";
|
||||
import React, { FunctionComponent } from "react";
|
||||
import { ReadyState } from "react-relay";
|
||||
|
||||
import { graphql, QueryRenderer } from "coral-framework/lib/relay";
|
||||
import { CallOut, Spinner } from "coral-ui/components/v2";
|
||||
@@ -30,7 +29,7 @@ const UserDrawerNotesQuery: FunctionComponent<Props> = ({ userID }) => {
|
||||
`}
|
||||
variables={{ userID }}
|
||||
cacheConfig={{ force: true }}
|
||||
render={({ error, props }: ReadyState<QueryTypes["response"]>) => {
|
||||
render={({ error, props }) => {
|
||||
if (error) {
|
||||
return (
|
||||
<div className={styles.callout}>
|
||||
|
||||
+1
-2
@@ -1,6 +1,5 @@
|
||||
import { Localized } from "@fluent/react/compat";
|
||||
import React, { FunctionComponent } from "react";
|
||||
import { ReadyState } from "react-relay";
|
||||
|
||||
import { graphql, QueryRenderer } from "coral-framework/lib/relay";
|
||||
import { CallOut, Spinner } from "coral-ui/components/v2";
|
||||
@@ -35,7 +34,7 @@ const UserHistoryDrawerAllCommentsQuery: FunctionComponent<Props> = ({
|
||||
`}
|
||||
variables={{ userID }}
|
||||
cacheConfig={{ force: true }}
|
||||
render={({ error, props }: ReadyState<QueryTypes["response"]>) => {
|
||||
render={({ error, props }) => {
|
||||
if (!props) {
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { Localized } from "@fluent/react/compat";
|
||||
import React, { FunctionComponent } from "react";
|
||||
import { ReadyState } from "react-relay";
|
||||
|
||||
import { graphql, QueryRenderer } from "coral-framework/lib/relay";
|
||||
import {
|
||||
graphql,
|
||||
QueryRenderData,
|
||||
QueryRenderer,
|
||||
} from "coral-framework/lib/relay";
|
||||
import { CallOut, Spinner } from "coral-ui/components/v2";
|
||||
|
||||
import { UserHistoryDrawerQuery as QueryTypes } from "coral-admin/__generated__/UserHistoryDrawerQuery.graphql";
|
||||
@@ -36,7 +39,7 @@ const UserHistoryDrawerQuery: FunctionComponent<Props> = ({
|
||||
`}
|
||||
variables={{ userID }}
|
||||
cacheConfig={{ force: true }}
|
||||
render={({ props }: ReadyState<QueryTypes["response"]>) => {
|
||||
render={({ props }: QueryRenderData<QueryTypes>) => {
|
||||
if (!props) {
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
|
||||
+1
-4
@@ -1,12 +1,9 @@
|
||||
import { Localized } from "@fluent/react/compat";
|
||||
import React, { FunctionComponent } from "react";
|
||||
import { ReadyState } from "react-relay";
|
||||
|
||||
import { graphql, QueryRenderer } from "coral-framework/lib/relay";
|
||||
import { CallOut, Spinner } from "coral-ui/components/v2";
|
||||
|
||||
import { UserHistoryDrawerRejectedCommentsQuery as QueryTypes } from "coral-admin/__generated__/UserHistoryDrawerRejectedCommentsQuery.graphql";
|
||||
|
||||
import UserHistoryDrawerRejectedComments from "./UserHistoryDrawerRejectedComments";
|
||||
|
||||
import styles from "./UserHistoryDrawerRejectedCommentsQuery.css";
|
||||
@@ -35,7 +32,7 @@ const UserHistoryDrawerRejectedCommentsQuery: FunctionComponent<Props> = ({
|
||||
`}
|
||||
variables={{ userID }}
|
||||
cacheConfig={{ force: true }}
|
||||
render={({ error, props }: ReadyState<QueryTypes["response"]>) => {
|
||||
render={({ error, props }) => {
|
||||
if (!props) {
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
$tableBodyTextColor: var(--v2-colors-mono-500);
|
||||
|
||||
.root {
|
||||
font-family: var(--v2-font-family-primary);
|
||||
font-weight: var(--v2-font-weight-primary-semi-bold);
|
||||
color: var(--v2-palette-table-body-text);
|
||||
color: $tableBodyTextColor;
|
||||
}
|
||||
|
||||
.commenter {
|
||||
color: var(--v2-palette-table-body-text);
|
||||
color: $tableBodyTextColor;
|
||||
font-weight: var(--v2-font-weight-primary-regular);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -63,7 +63,7 @@ const BanUserMutation = createMutation(
|
||||
history: [
|
||||
{
|
||||
active: true,
|
||||
createdAt: new Date(),
|
||||
createdAt: new Date().toISOString(),
|
||||
createdBy: {
|
||||
id: viewer.id,
|
||||
username: viewer.username,
|
||||
|
||||
@@ -63,7 +63,7 @@ const PremodUserMutation = createMutation(
|
||||
history: [
|
||||
{
|
||||
active: true,
|
||||
createdAt: new Date(),
|
||||
createdAt: new Date().toISOString(),
|
||||
createdBy: {
|
||||
id: viewer.id,
|
||||
username: viewer.username,
|
||||
|
||||
@@ -63,7 +63,7 @@ const RemoveUserPremodMutation = createMutation(
|
||||
history: [
|
||||
{
|
||||
active: false,
|
||||
createdAt: new Date(),
|
||||
createdAt: new Date().toISOString(),
|
||||
createdBy: {
|
||||
id: viewer.id,
|
||||
username: viewer.username,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -11,23 +11,15 @@ exports[`renders correctly 1`] = `
|
||||
className="AuthBox-container"
|
||||
size="double"
|
||||
>
|
||||
<ForwardRef(forwardRef)
|
||||
justifyContent="center"
|
||||
>
|
||||
<div
|
||||
className="AuthBox-brandIcon"
|
||||
>
|
||||
<withPropsOnChange(BrandMark)
|
||||
size="lg"
|
||||
/>
|
||||
</div>
|
||||
</ForwardRef(forwardRef)>
|
||||
<div>
|
||||
<ForwardRef(forwardRef)
|
||||
align="center"
|
||||
variant="heading3"
|
||||
justifyContent="center"
|
||||
>
|
||||
title
|
||||
<div
|
||||
className="adminSignInTitle"
|
||||
>
|
||||
title
|
||||
</div>
|
||||
</ForwardRef(forwardRef)>
|
||||
<withPropsOnChange(BrandName)
|
||||
align="center"
|
||||
|
||||
@@ -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,22 +12,36 @@ import {
|
||||
|
||||
export default function getQueueConnection(
|
||||
store: RecordSourceSelectorProxy | RecordSourceProxy,
|
||||
queue: GQLMODERATION_QUEUE_RL | "REJECTED",
|
||||
storyID?: string | null
|
||||
): RecordProxy | 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 (!queuesRecord) {
|
||||
return null;
|
||||
if (queue === "APPROVED") {
|
||||
return ConnectionHandler.getConnection(root, "ApprovedQueue_comments", {
|
||||
status: GQLCOMMENT_STATUS.APPROVED,
|
||||
storyID,
|
||||
siteID,
|
||||
});
|
||||
}
|
||||
return ConnectionHandler.getConnection(
|
||||
queuesRecord.getLinkedRecord(queue.toLowerCase()),
|
||||
"Queue_comments"
|
||||
);
|
||||
const queuesRecord = root.getLinkedRecord("moderationQueues", {
|
||||
storyID,
|
||||
siteID,
|
||||
})!;
|
||||
if (!queuesRecord) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const queueRecord = queuesRecord.getLinkedRecord(queue.toLowerCase());
|
||||
if (!queueRecord) {
|
||||
return undefined;
|
||||
}
|
||||
return ConnectionHandler.getConnection(queueRecord, "Queue_comments");
|
||||
}
|
||||
|
||||
@@ -1,2 +1,5 @@
|
||||
export { default as getQueueConnection } from "./getQueueConnection";
|
||||
export { default as getModerationLink } from "./getModerationLink";
|
||||
export {
|
||||
default as getPhrasesRegExp,
|
||||
GetPhrasesRegExpOptions,
|
||||
} from "./getPhrasesRegExp";
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { FunctionComponent } from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
|
||||
import injectConditionalPolyfills from "coral-framework/helpers/injectConditionalPolyfills";
|
||||
import potentiallyInjectAxe from "coral-framework/helpers/potentiallyInjectAxe";
|
||||
import { createManaged } from "coral-framework/lib/bootstrap";
|
||||
|
||||
@@ -13,6 +14,7 @@ import localesData from "./locales";
|
||||
import "coral-ui/theme/variables.css";
|
||||
|
||||
async function main() {
|
||||
await injectConditionalPolyfills();
|
||||
// Potentially inject react-axe for runtime a11y checks.
|
||||
await potentiallyInjectAxe();
|
||||
const ManagedCoralContextProvider = await createManaged({
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -84,7 +84,7 @@ const ApproveCommentMutation = createMutation(
|
||||
getQueueConnection(store, "REJECTED", input.storyID),
|
||||
].filter(c => c);
|
||||
connections.forEach(con =>
|
||||
ConnectionHandler.deleteNode(con, input.commentID)
|
||||
ConnectionHandler.deleteNode(con!, input.commentID)
|
||||
);
|
||||
},
|
||||
})
|
||||
|
||||
@@ -81,9 +81,10 @@ 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)
|
||||
ConnectionHandler.deleteNode(con!, input.commentID)
|
||||
);
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,9 +1,52 @@
|
||||
.title {
|
||||
font-weight: var(--v2-font-weight-secondary-bold);
|
||||
font-family: var(--v2-font-family-secondary);
|
||||
color: var(--v2-colors-mono-100);
|
||||
font-size: var(--v2-font-size-3);
|
||||
line-height: var(--v2-line-height-title);
|
||||
|
||||
padding-bottom: var(--v2-spacing-3);
|
||||
}
|
||||
|
||||
.username {
|
||||
font-size: var(--v2-font-size-6);
|
||||
font-weight: var(--v2-font-weight-secondary-bold);
|
||||
font-family: var(--v2-font-family-secondary);
|
||||
line-height: var(--v2-line-height-reset);
|
||||
color: var(--v2-colors-mono-900);
|
||||
|
||||
text-align: center;
|
||||
|
||||
padding-bottom: var(--v2-spacing-5);
|
||||
}
|
||||
|
||||
.copy {
|
||||
font-size: var(--v2-font-size-3);
|
||||
font-weight: var(--v2-font-weight-primary-regular);
|
||||
font-family: var(--v2-font-family-primary);
|
||||
line-height: var(--v2-line-height-reset);
|
||||
color: var(--v2-colors-mono-500);
|
||||
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.lockIcon {
|
||||
color: var(--palette-grey-dark);
|
||||
color: var(--v2-colors-mono-900);
|
||||
padding-bottom: var(--v2-spacing-2);
|
||||
}
|
||||
|
||||
.noPermission {
|
||||
color: var(--palette-grey-dark);
|
||||
font-size: var(--v2-font-size-4);
|
||||
font-weight: var(--v2-font-weight-primary-semi-bold);
|
||||
font-family: var(--v2-font-family-primary);
|
||||
line-height: var(--v2-line-height-reset);
|
||||
color: var(--v2-colors-mono-900);
|
||||
|
||||
text-align: center;
|
||||
|
||||
padding-bottom: var(--v2-spacing-5);
|
||||
}
|
||||
|
||||
.contactAdmin {
|
||||
color: var(--palette-grey-main);
|
||||
padding-top: var(--v2-spacing-3);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
import { Localized } from "@fluent/react/compat";
|
||||
import cn from "classnames";
|
||||
import React, { FunctionComponent } from "react";
|
||||
|
||||
import AuthBox from "coral-admin/components/AuthBox";
|
||||
import {
|
||||
Button,
|
||||
Flex,
|
||||
HorizontalGutter,
|
||||
Icon,
|
||||
Typography,
|
||||
} from "coral-ui/components";
|
||||
import { Button, Flex, HorizontalGutter, Icon } from "coral-ui/components/v2";
|
||||
|
||||
import styles from "./Restricted.css";
|
||||
|
||||
@@ -18,17 +13,15 @@ interface Props {
|
||||
}
|
||||
|
||||
const SignIn: FunctionComponent<Props> = ({ username, onSignInAs }) => {
|
||||
const Username = () => (
|
||||
<Typography variant="heading1" align="center">
|
||||
{username}
|
||||
</Typography>
|
||||
);
|
||||
const Username = () => <div className={styles.username}>{username}</div>;
|
||||
return (
|
||||
<AuthBox
|
||||
title={
|
||||
<Localized id="restricted-currentlySignedInTo">
|
||||
<span>Currently signed in to</span>
|
||||
</Localized>
|
||||
<div className={styles.title}>
|
||||
<Localized id="restricted-currentlySignedInTo">
|
||||
<span>Currently signed in to</span>
|
||||
</Localized>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<HorizontalGutter size="double">
|
||||
@@ -39,38 +32,35 @@ const SignIn: FunctionComponent<Props> = ({ username, onSignInAs }) => {
|
||||
</Icon>
|
||||
</Flex>
|
||||
<Localized id="restricted-noPermissionInfo">
|
||||
<Typography
|
||||
variant="heading3"
|
||||
align="center"
|
||||
className={styles.noPermission}
|
||||
>
|
||||
<div className={styles.noPermission}>
|
||||
You do not have permission to access this page.
|
||||
</Typography>
|
||||
</div>
|
||||
</Localized>
|
||||
</div>
|
||||
<div>
|
||||
<Localized id="restricted-signedInAs" Username={<Username />}>
|
||||
<Typography variant="bodyCopy" align="center" container="div">
|
||||
<div className={styles.copy}>
|
||||
{"You are signed in as: <Username></Username>"}
|
||||
</Typography>
|
||||
</div>
|
||||
</Localized>
|
||||
</div>
|
||||
<Flex justifyContent="center">
|
||||
<Localized id="restricted-signInWithADifferentAccount">
|
||||
<Button variant="filled" color="primary" onClick={onSignInAs}>
|
||||
<Button
|
||||
variant="regular"
|
||||
color="regular"
|
||||
size="large"
|
||||
onClick={onSignInAs}
|
||||
>
|
||||
Sign in with a different account
|
||||
</Button>
|
||||
</Localized>
|
||||
</Flex>
|
||||
<Localized id="restricted-contactAdmin">
|
||||
<Typography
|
||||
variant="bodyCopy"
|
||||
align="center"
|
||||
className={styles.contactAdmin}
|
||||
>
|
||||
<div className={cn(styles.copy, styles.contactAdmin)}>
|
||||
If you think this is an error, please contact your administrator for
|
||||
assistance.
|
||||
</Typography>
|
||||
</div>
|
||||
</Localized>
|
||||
</HorizontalGutter>
|
||||
</AuthBox>
|
||||
|
||||
@@ -3,13 +3,17 @@
|
||||
exports[`renders correctly 1`] = `
|
||||
<AuthBox
|
||||
title={
|
||||
<Localized
|
||||
id="restricted-currentlySignedInTo"
|
||||
<div
|
||||
className="Restricted-title"
|
||||
>
|
||||
<span>
|
||||
Currently signed in to
|
||||
</span>
|
||||
</Localized>
|
||||
<Localized
|
||||
id="restricted-currentlySignedInTo"
|
||||
>
|
||||
<span>
|
||||
Currently signed in to
|
||||
</span>
|
||||
</Localized>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ForwardRef(forwardRef)
|
||||
@@ -29,13 +33,11 @@ exports[`renders correctly 1`] = `
|
||||
<Localized
|
||||
id="restricted-noPermissionInfo"
|
||||
>
|
||||
<ForwardRef(forwardRef)
|
||||
align="center"
|
||||
<div
|
||||
className="Restricted-noPermission"
|
||||
variant="heading3"
|
||||
>
|
||||
You do not have permission to access this page.
|
||||
</ForwardRef(forwardRef)>
|
||||
</div>
|
||||
</Localized>
|
||||
</div>
|
||||
<div>
|
||||
@@ -43,13 +45,11 @@ exports[`renders correctly 1`] = `
|
||||
Username={<Username />}
|
||||
id="restricted-signedInAs"
|
||||
>
|
||||
<ForwardRef(forwardRef)
|
||||
align="center"
|
||||
container="div"
|
||||
variant="bodyCopy"
|
||||
<div
|
||||
className="Restricted-copy"
|
||||
>
|
||||
You are signed in as: <Username></Username>
|
||||
</ForwardRef(forwardRef)>
|
||||
</div>
|
||||
</Localized>
|
||||
</div>
|
||||
<ForwardRef(forwardRef)
|
||||
@@ -59,9 +59,10 @@ exports[`renders correctly 1`] = `
|
||||
id="restricted-signInWithADifferentAccount"
|
||||
>
|
||||
<ForwardRef(forwardRef)
|
||||
color="primary"
|
||||
color="regular"
|
||||
onClick={[Function]}
|
||||
variant="filled"
|
||||
size="large"
|
||||
variant="regular"
|
||||
>
|
||||
Sign in with a different account
|
||||
</ForwardRef(forwardRef)>
|
||||
@@ -70,13 +71,11 @@ exports[`renders correctly 1`] = `
|
||||
<Localized
|
||||
id="restricted-contactAdmin"
|
||||
>
|
||||
<ForwardRef(forwardRef)
|
||||
align="center"
|
||||
className="Restricted-contactAdmin"
|
||||
variant="bodyCopy"
|
||||
<div
|
||||
className="Restricted-copy Restricted-contactAdmin"
|
||||
>
|
||||
If you think this is an error, please contact your administrator for assistance.
|
||||
</ForwardRef(forwardRef)>
|
||||
</div>
|
||||
</Localized>
|
||||
</ForwardRef(forwardRef)>
|
||||
</AuthBox>
|
||||
|
||||
@@ -19,7 +19,7 @@ interface Props {
|
||||
viewer: PropTypesOf<typeof UserRole>["viewer"];
|
||||
settings: PropTypesOf<typeof UserStatus>["settings"];
|
||||
onUsernameClicked?: (userID: string) => void;
|
||||
deletedAt?: string;
|
||||
deletedAt?: string | null;
|
||||
}
|
||||
|
||||
const UserRow: FunctionComponent<Props> = ({
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
$configureHeadingBackgroundColor: var(--v2-colors-blue-500);
|
||||
|
||||
.root {
|
||||
}
|
||||
|
||||
.title {
|
||||
background: var(--v2-palette-configure-heading-background);
|
||||
background: $configureHeadingBackgroundColor;
|
||||
width: calc(100% - 2 * var(--v2-spacing-2));
|
||||
padding: var(--v2-spacing-2);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user