mirror of
https://github.com/wassname/talk.git
synced 2026-09-14 11:36:51 +08:00
Compare commits
83
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
721b2e7d67 | ||
|
|
f9e1bf144e | ||
|
|
e86e9d2c68 | ||
|
|
d031df38f6 | ||
|
|
8d368b03ec | ||
|
|
3e64aa1193 | ||
|
|
f84c3e4a60 | ||
|
|
36225e443b | ||
|
|
ed8be75774 | ||
|
|
f8cf4e94d3 | ||
|
|
1a69f48087 | ||
|
|
e0ab799c84 | ||
|
|
aa8e1e2d7a | ||
|
|
d2af78d3d7 | ||
|
|
9bd38db61a | ||
|
|
ac203607ae | ||
|
|
e49905c5ad | ||
|
|
15d41f1ae8 | ||
|
|
60a1f43d51 | ||
|
|
3c9ebcf1a1 | ||
|
|
e35978096f | ||
|
|
04c56b3fb5 | ||
|
|
5bf4f22931 | ||
|
|
1550377f39 | ||
|
|
efea0e8e1c | ||
|
|
0fad1070a6 | ||
|
|
ac1ed45e4b | ||
|
|
a696ef9be5 | ||
|
|
aa32fcaea6 | ||
|
|
fcf3640adc | ||
|
|
46f0d08cf7 | ||
|
|
fc039f8186 | ||
|
|
7e7ce9fbd0 | ||
|
|
0e37a474fa | ||
|
|
ffecd5e981 | ||
|
|
07df7ac182 | ||
|
|
2edabf44c7 | ||
|
|
1c75b7659d | ||
|
|
6d803b0179 | ||
|
|
f3e615a610 | ||
|
|
b9f9e9b2d4 | ||
|
|
7809cd3d68 | ||
|
|
add5224338 | ||
|
|
8dae0feb52 | ||
|
|
ee93267d97 | ||
|
|
0f02b05689 | ||
|
|
3baf4e96f5 | ||
|
|
8d91bd7dd1 | ||
|
|
d400c1518c | ||
|
|
d9ad1b2ee4 | ||
|
|
035ee73edd | ||
|
|
43b6a2cdcd | ||
|
|
635e740fc0 | ||
|
|
c37ee12e57 | ||
|
|
e76fddd13c | ||
|
|
abb793b16a | ||
|
|
aa873559d0 | ||
|
|
0296041ce8 | ||
|
|
1a62203d45 | ||
|
|
0eddf9619f | ||
|
|
fda9f40616 | ||
|
|
8d78677154 | ||
|
|
7900b02c40 | ||
|
|
956014b53f | ||
|
|
86bba73919 | ||
|
|
c245e9ba74 | ||
|
|
e3f24811fc | ||
|
|
11fc3697ed | ||
|
|
22188e7c31 | ||
|
|
4c65d43954 | ||
|
|
ff964b54a3 | ||
|
|
3f307002e2 | ||
|
|
b41ddbc22f | ||
|
|
c979dfd7d5 | ||
|
|
1f07c23dea | ||
|
|
3694794b01 | ||
|
|
5df2de6afc | ||
|
|
4e548e8fbf | ||
|
|
fdc6afec42 | ||
|
|
836a2267bf | ||
|
|
290ceee8e9 | ||
|
|
1c5c82286e | ||
|
|
3d9c94d36a |
@@ -0,0 +1,30 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
labels: "bug"
|
||||
---
|
||||
|
||||
<!--
|
||||
Please provide as much detail as possible, including a screenshot or copy-paste of any related error messages, logs, or other output that might be related. Places to look for information include your browser console, server console, and network logs. The more information you can give the better.
|
||||
|
||||
Please help us by doing the following steps before logging an issue:
|
||||
* Search: https://github.com/coralproject/talk/search
|
||||
* Read the docs: https://docs.coralproject.net
|
||||
|
||||
Please fill in the *entire* template below.
|
||||
-->
|
||||
|
||||
**Expected behavior:**
|
||||
|
||||
**Actual behavior:**
|
||||
|
||||
**Related Issues:** <!-- Did you find other bugs that looked similar? -->
|
||||
|
||||
**Versions:**
|
||||
|
||||
- NodeJS:
|
||||
- NPM:
|
||||
- MongoDB:
|
||||
- Redis:
|
||||
- Browser:
|
||||
- OS:
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
labels: "feature idea"
|
||||
---
|
||||
|
||||
<!--
|
||||
|
||||
Please help us by doing the following steps before logging an issue:
|
||||
* Search: https://github.com/coralproject/talk/search
|
||||
|
||||
-->
|
||||
|
||||
## Search Terms
|
||||
|
||||
<!-- List of keywords you searched for before creating this issue. Write them down here so that others can find this suggestion more easily -->
|
||||
|
||||
## Suggestion
|
||||
|
||||
<!-- A summary of what you'd like to see added or changed -->
|
||||
|
||||
## Use Cases
|
||||
|
||||
<!--
|
||||
What do you want to use this for?
|
||||
What shortcomings exist with current approaches?
|
||||
-->
|
||||
@@ -0,0 +1,10 @@
|
||||
<!--
|
||||
Thank you for submitting a pull request!
|
||||
|
||||
Please verify that:
|
||||
* [ ] 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
|
||||
-->
|
||||
@@ -21,3 +21,4 @@ dist
|
||||
*.css.d.ts
|
||||
__generated__
|
||||
README.md.orig
|
||||
persisted-queries.json
|
||||
|
||||
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"ms-vscode.vscode-typescript-tslint-plugin",
|
||||
"kumar-harsh.graphql-for-vscode",
|
||||
"editorconfig.editorconfig",
|
||||
"ms-azuretools.vscode-cosmosdb"
|
||||
]
|
||||
}
|
||||
Vendored
+5
-4
@@ -1,5 +1,4 @@
|
||||
{
|
||||
"editor.formatOnSave": true,
|
||||
"files.associations": {
|
||||
"*.css": "postcss"
|
||||
},
|
||||
@@ -12,9 +11,11 @@
|
||||
".vs": true
|
||||
},
|
||||
"tslint.exclude": "**/node_modules/**",
|
||||
"tslint.autoFixOnSave": true,
|
||||
"tslint.jsEnable": true,
|
||||
"tslint.nodePath": "node_modules/.bin/tslint",
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"postcss.validate": false
|
||||
"postcss.validate": false,
|
||||
"javascript.preferences.importModuleSpecifier": "non-relative",
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.tslint": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# Vox Media Code of Conduct
|
||||
|
||||
## Introduction
|
||||
|
||||
This code of conduct governs the environment of the Vox Product team. We created it not because we anticipate bad behavior, but because we believe that articulating our values and obligations to one another reinforces the already exceptional level of respect among the team and because having a code provides us with clear avenues to correct our culture should it ever stray from that course. We make this code public in the hopes of contributing to the ongoing conversation about inclusion in the tech, design, and media communities and encourage other teams to fork it and make it their own. To our team, we commit to enforce and evolve this code as our team grows.
|
||||
|
||||
Read the rest here: http://code-of-conduct.voxmedia.com/
|
||||
@@ -0,0 +1,66 @@
|
||||
# Contributor's Guide
|
||||
|
||||
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).
|
||||
|
||||
## What should I Contribute?
|
||||
|
||||
There are at least three ways to contribute to Coral:
|
||||
|
||||
- Writing Code
|
||||
- Providing Translations
|
||||
|
||||
## Writing Code
|
||||
|
||||
Conversation surrounding contributions begins in
|
||||
[issues](https://github.com/coralproject/talk/issues).
|
||||
|
||||
### When should I create an issue?
|
||||
|
||||
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?
|
||||
|
||||
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
|
||||
form:
|
||||
|
||||
> As a [type of Coral user] I'd like to [do something] so that I can [get some result/value].
|
||||
|
||||
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
|
||||
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
|
||||
please do keep the initial issue specific to one need and try to avoid including
|
||||
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.
|
||||
|
||||
## Localization
|
||||
|
||||
We use the [fluent](http://projectfluent.org/) library and store our
|
||||
translations in [FTL](http://projectfluent.org/fluent/guide/) files in
|
||||
`src/locales/` and `src/core/server/locales/`.
|
||||
|
||||
Strings are added or removed from localization bundles in the translation files
|
||||
as needed. Strings **MUST NOT** be _changed_ after they've been committed and
|
||||
pushed to master. Changing a string requires creating a new ID with a new name
|
||||
(preferably descriptive instead of incremented) and deletion of the obsolete ID.
|
||||
It's often useful to add a comment above the string with info about how and
|
||||
where the string is used.
|
||||
|
||||
Once a language has enough coverage, it should be added to
|
||||
`src/core/common/helpers/i18n/locales.ts`.
|
||||
@@ -19,11 +19,20 @@ Preview Coral easily by running Coral via a Heroku App:
|
||||
- [Running](#running)
|
||||
- [Docker](#docker)
|
||||
- [Source](#source)
|
||||
- [Embed On Your Site](#embed-on-your-site)
|
||||
- [Single Sign On](#single-sign-on)
|
||||
- [External Integrations](#external-integrations)
|
||||
- [Login Prompts](#login-prompts)
|
||||
- [Development](#development)
|
||||
- [Embed On Your Site](#embed-on-your-site)
|
||||
- [Single Sign On](#single-sign-on)
|
||||
- [Email](#email)
|
||||
- [Design Language System (UI Components)](#design-language-system-ui-components)
|
||||
- [GraphQL API](#graphql-api)
|
||||
- [Making your first request](#making-your-first-request)
|
||||
- [Understanding the response](#understanding-the-response)
|
||||
- [Authorizing a request](#authorizing-a-request)
|
||||
- [Bearer Token](#bearer-token)
|
||||
- [Cookie](#cookie)
|
||||
- [Persisted Queries](#persisted-queries)
|
||||
- [Configuration](#configuration)
|
||||
- [License](#license)
|
||||
|
||||
@@ -132,60 +141,7 @@ npm run start
|
||||
|
||||
Then head on over to http://localhost:3000 to install Coral!
|
||||
|
||||
### Development
|
||||
|
||||
Running Coral for development is very similar to installing Coral via Source as
|
||||
described above.
|
||||
|
||||
Coral requires NodeJS >=10, we recommend using `nvm` to help manage node
|
||||
versions: https://github.com/creationix/nvm.
|
||||
|
||||
```bash
|
||||
# Clone and cd into the Coral directory.
|
||||
git clone https://github.com/coralproject/talk.git
|
||||
cd talk
|
||||
|
||||
# Install dependencies.
|
||||
npm install
|
||||
```
|
||||
|
||||
Running Coral with default settings assumes that you have:
|
||||
|
||||
- MongoDB >=3.6 running on `127.0.0.1:27017`
|
||||
- Redis >=3.2 running on `127.0.0.1:6379`
|
||||
|
||||
If you don't already have these databases running, you can execute the following
|
||||
assuming you have Docker installed on your local machine:
|
||||
|
||||
```bash
|
||||
docker run -d -p 27017:27017 --restart always --name mongo mongo:3.6
|
||||
docker run -d -p 6379:6379 --restart always --name redis redis:3.2
|
||||
```
|
||||
|
||||
Then start Coral with:
|
||||
|
||||
```bash
|
||||
# Run the server in development mode in order to facilitate auto-restarting and
|
||||
# rebuilding when file changes are detected. This might take a while to fully run.
|
||||
npm run watch
|
||||
```
|
||||
|
||||
When the client code has been built, navigate to http://localhost:8080/install
|
||||
to start the installation wizard. **Note: Ensure `localhost:8080` is used in the permitted domains list.**
|
||||
|
||||
To see the comment stream goto http://localhost:8080/.
|
||||
|
||||
To run linting and tests use the following commands:
|
||||
|
||||
```bash
|
||||
# Run the linters.
|
||||
npm run lint
|
||||
|
||||
# Run our unit and integration tests.
|
||||
npm run test
|
||||
```
|
||||
|
||||
#### Embed On Your Site
|
||||
### Embed On Your Site
|
||||
|
||||
With Coral setup and running locally you can test embeding the comment stream with this sample embed script:
|
||||
|
||||
@@ -210,7 +166,7 @@ With Coral setup and running locally you can test embeding the comment stream wi
|
||||
|
||||
> **NOTE:** Replace the value of `{{ CORAL_DOMAIN_NAME }}` with the location of your running instance of Coral.
|
||||
|
||||
#### Single Sign On
|
||||
### Single Sign On
|
||||
|
||||
In order to allow seamless connection to an existing authentication system,
|
||||
Coral utilizes the industry standard [JWT Token](https://jwt.io/) to connect. To
|
||||
@@ -249,6 +205,11 @@ You will then have to generate a JWT with the following claims:
|
||||
about status changes on a user account such as bans or suspensions.
|
||||
- `user.username` (**required**) - the username that should be used when being
|
||||
presented inside Coral to moderators and other users.
|
||||
- `user.badges` (_optional_) - array of strings to be displayed as badges beside
|
||||
username inside Coral, visible to other users and moderators. For example, to indicate
|
||||
a user's subscription status.
|
||||
- `user.role` (_optional_) - one of "COMMENTER", "STAFF", "MODERATOR", "ADMIN". Will create/update
|
||||
Coral user with this role.
|
||||
|
||||
An example of the claims for this token would be:
|
||||
|
||||
@@ -293,6 +254,105 @@ embed.login("{{ SSO_TOKEN }}");
|
||||
embed.logout();
|
||||
```
|
||||
|
||||
#### External Integrations
|
||||
|
||||
You can integrate directly with the Coral GraphQL API in order to facilitate
|
||||
account updates for your users when using Coral SSO. The relevant mutations are
|
||||
as follows:
|
||||
|
||||
- `updateUserUsername` lets you update a given user with a new username using
|
||||
an admin token.
|
||||
- `updateUserEmail` lets you update a given user with a new email address
|
||||
using an admin token.
|
||||
- `deleteUser` lets you delete a given account using an admin token.
|
||||
Note that even with an admin token, you may not delete yourself via
|
||||
this method, and instead must use the `requestAccountDeletion`
|
||||
mutation instead. This differs from the `requestAccountDeletion` as
|
||||
it does the operation immediately instead of scheduling it as
|
||||
`requestAccountDeletion` does.
|
||||
- `requestUserCommentsDownload` lets you retrieve a given account's comments download. This mutation will provide you with a `archiveURL` that can be used to download a ZIP file containing the user's comment export.
|
||||
|
||||
If you're unsure on how to call GraphQL API's, refer to the section here on [Making your first GraphQL request](#making-your-first-request).
|
||||
|
||||
#### Login Prompts
|
||||
|
||||
In order to handle login prompts (e.g. a user clicks on the sign in button) you can listen to the `loginPrompt` event.
|
||||
|
||||
```js
|
||||
var embed = Coral.createStreamEmbed({
|
||||
// Don't forget to include the parameters from the
|
||||
// "Embed On Your Site" section.
|
||||
events: function(events) {
|
||||
events.on("loginPrompt", function() {
|
||||
// Redirect user to a login page.
|
||||
location.href = "http://example.com/login";
|
||||
});
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Development
|
||||
|
||||
Running Coral for development is very similar to installing Coral via Source as
|
||||
described above.
|
||||
|
||||
Coral requires NodeJS >=10, we recommend using `nvm` to help manage node
|
||||
versions: https://github.com/creationix/nvm.
|
||||
|
||||
```bash
|
||||
# Clone and cd into the Coral directory.
|
||||
git clone https://github.com/coralproject/talk.git
|
||||
cd talk
|
||||
|
||||
# Install dependencies.
|
||||
npm install
|
||||
```
|
||||
|
||||
Running Coral with default settings assumes that you have:
|
||||
|
||||
- MongoDB >=3.6 running on `127.0.0.1:27017`
|
||||
- Redis >=3.2 running on `127.0.0.1:6379`
|
||||
|
||||
If you don't already have these databases running, you can execute the following
|
||||
assuming you have Docker installed on your local machine:
|
||||
|
||||
```bash
|
||||
docker run -d -p 27017:27017 --restart always --name mongo mongo:3.6
|
||||
docker run -d -p 6379:6379 --restart always --name redis redis:3.2
|
||||
```
|
||||
|
||||
We recommend installing [watchman](https://facebook.github.io/watchman/docs/install.html) for better watch
|
||||
performance.
|
||||
|
||||
```bash
|
||||
# On macOS, you can run the following with Homebrew.
|
||||
brew update
|
||||
brew install watchman
|
||||
```
|
||||
|
||||
Then start Coral with:
|
||||
|
||||
```bash
|
||||
# Run the server in development mode in order to facilitate auto-restarting and
|
||||
# rebuilding when file changes are detected. This might take a while to fully run.
|
||||
npm run watch
|
||||
```
|
||||
|
||||
When the client code has been built, navigate to http://localhost:8080/install
|
||||
to start the installation wizard. **Note: Ensure `localhost:8080` is used in the permitted domains list.**
|
||||
|
||||
To see the comment stream goto http://localhost:8080/.
|
||||
|
||||
To run linting and tests use the following commands:
|
||||
|
||||
```bash
|
||||
# Run the linters.
|
||||
npm run lint
|
||||
|
||||
# Run our unit and integration tests.
|
||||
npm run test
|
||||
```
|
||||
|
||||
#### Email
|
||||
|
||||
To test out the email sending functionality, you can run [inbucket](https://www.inbucket.org/)
|
||||
@@ -332,6 +392,207 @@ npm run docz -- dev
|
||||
|
||||
After compilation has finished you can access docz at http://localhost:3030/.
|
||||
|
||||
## GraphQL API
|
||||
|
||||
Our API is generally served via GraphQL at `/api/graphql` on your Coral installation. If you're running Coral locally, this would be https://localhost:8080/api/graphql.
|
||||
|
||||
You can enable the GraphiQL interface at https://localhost:3000/graphiql (Note the port number here is not 8080, this is because this route is directly served by the server, and not the webpack development server) when running in development to access a GraphQL playground to use with documentation provided in the sidebar on what edges are available to you. You can do this by setting `ENABLE_GRAPHIQL=true`. **(🚨 Note 🚨) we do not recommend using this in production environments as it disables many safety features used by the application**.
|
||||
|
||||
### Making your first request
|
||||
|
||||
To learn a bit about how to interact with Coral, we'll query for comments on a
|
||||
page of Coral.
|
||||
|
||||
The GraphQL endpoint we have can be used with any HTTP client available, but our
|
||||
examples below will use the common `curl` tool:
|
||||
|
||||
```sh
|
||||
curl --request POST \
|
||||
--url "http://localhost:8080/api/graphql" \
|
||||
--header "content-type: application/json" \
|
||||
--data '{"query":"query GetComments($url: String!) {story(url: $url) { id metadata { title } url comments { nodes { id body author { id username } } } } }","variables":{"url":"http://localhost:8080/"},"operationName":"GetComments"}'
|
||||
```
|
||||
|
||||
When you unpack that, it's really quite simple. We're executing a `POST` request
|
||||
to the `/api/graphql` route of the local Talk server with the GraphQL
|
||||
request we want to make. It's composed of the `query`, `variables`, and
|
||||
`operationName`.
|
||||
|
||||
```graphql
|
||||
query GetComments($url: String!) {
|
||||
story(url: $url) {
|
||||
metadata {
|
||||
title
|
||||
}
|
||||
url
|
||||
comments {
|
||||
nodes {
|
||||
body
|
||||
author {
|
||||
username
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
We are grabbing the asset with the specified `$url`, and grabbing it's title and the comments under it.
|
||||
|
||||
We can then also specify our variables to the query being executed (in this
|
||||
case, the url for the page where we have comments on our local install of Coral):
|
||||
|
||||
```json
|
||||
{
|
||||
"url": "http://localhost:8080/"
|
||||
}
|
||||
```
|
||||
|
||||
It's also sometimes common to have multiple queries within a query, which is
|
||||
where the `operationName` comes into play, where we simply specify the named
|
||||
query that we want to execute (in this case, `GetComments`).
|
||||
|
||||
To get a deeper understanding of GraphQL queries, read up on
|
||||
[GraphQL Queries and Mutations](http://graphql.org/learn/queries/).
|
||||
|
||||
### Understanding the response
|
||||
|
||||
Once you completed the above GraphQL query with `curl`, you'll get a response
|
||||
sort of like this:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"story": {
|
||||
"metadata": {
|
||||
"title": "Coral 5.0 – Embed Stream"
|
||||
},
|
||||
"url": "http://localhost:8080/",
|
||||
"comments": {
|
||||
"nodes": [
|
||||
{
|
||||
"body": "First comment!",
|
||||
"author": {
|
||||
"username": "wyatt.johnson"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
All of the parameters you requested should be available under the `data`
|
||||
property. Any errors that you get would appear in a `errors` array at the top
|
||||
level, like this:
|
||||
|
||||
```json
|
||||
{
|
||||
"errors": [
|
||||
{
|
||||
"message": "The specified story URL does not exist in the permitted domains list.",
|
||||
"locations": [
|
||||
{
|
||||
"line": 2,
|
||||
"column": 3
|
||||
}
|
||||
],
|
||||
"path": ["story"],
|
||||
"extensions": {
|
||||
"code": "STORY_URL_NOT_PERMITTED",
|
||||
"id": "e255e860-d3ab-11e9-acdf-b9e9700f06fa",
|
||||
"type": "INVALID_REQUEST_ERROR",
|
||||
"message": "The specified story URL does not exist in the permitted domains list."
|
||||
}
|
||||
}
|
||||
],
|
||||
"data": {
|
||||
"story": null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
You should know that any property that is marked with a `!` is considered
|
||||
required, and non-nullable, which means you can always guarantee on it being
|
||||
there in your request if there were no errors.
|
||||
|
||||
### Authorizing a request
|
||||
|
||||
Some queries you may notice seem to return an error of
|
||||
`USER_NOT_ENTITLED`. It's likely the case that you are making a request to a
|
||||
route that requires authorization. You can perform authorization a few ways in
|
||||
Talk.
|
||||
|
||||
Essentially, you need to get access to a JWT token that you can use to authorize
|
||||
your requests.
|
||||
|
||||
```sh
|
||||
curl --request POST \
|
||||
--url http://localhost:3000/api/auth/local \
|
||||
--header 'content-type: application/json' \
|
||||
--data '{ "email": "${EMAIL}", "password": "${PASSWORD}"}'
|
||||
```
|
||||
|
||||
Which returns a response similar to:
|
||||
|
||||
```json
|
||||
{
|
||||
"token": "${TOKEN}"
|
||||
}
|
||||
```
|
||||
|
||||
Where `${EMAIL}` is the email address of an admin user, and `${PASSWORD}` is the password for that admin user. This will generate a short term token (valid for 90 days). To generate a long lived access token (or Personal Access Token), you have to exchange the token generated above to create a new long lived token:
|
||||
|
||||
```sh
|
||||
curl --request POST \
|
||||
--url "http://localhost:3000/api/graphql" \
|
||||
--header 'authorization: Bearer ${TOKEN}' \
|
||||
--header 'content-type: application/json' \
|
||||
--data '{"query":"mutation CreateAccessToken { createToken(input: { clientMutationId: \"\", name: \"My PAT\" }) { signedToken }}","operationName":"CreateAccessToken"}'
|
||||
```
|
||||
|
||||
Which returns a response similar to:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"createToken": {
|
||||
"signedToken": "${TOKEN}"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Where `${TOKEN}` in the request being from the previous set of login steps and returning a new `signedToken` as `${TOKEN}` which can be used instead of the previous `${TOKEN}` value in the below examples.
|
||||
|
||||
Once you have your access token, you can substitute it as `${TOKEN}` in your
|
||||
`curl` request as follows:
|
||||
|
||||
#### Bearer Token
|
||||
|
||||
```sh
|
||||
curl --request POST \
|
||||
--url "http://localhost:8080/api/graphql" \
|
||||
--header "content-type: application/json" \
|
||||
--header "Authorization: Bearer ${TOKEN}" \
|
||||
--data '{"query":"query GetComments($url: String!) {story(url: $url) { id metadata { title } url comments { nodes { id body author { id username } } } } }","variables":{"url":"http://localhost:8080/"},"operationName":"GetComments"}'
|
||||
```
|
||||
|
||||
#### Cookie
|
||||
|
||||
```sh
|
||||
curl --request POST \
|
||||
--url "http://localhost:8080/api/graphql" \
|
||||
--header "content-type: application/json" \
|
||||
--cookie "authorization=${TOKEN}"
|
||||
--data '{"query":"query GetComments($url: String!) {story(url: $url) { id metadata { title } url comments { nodes { id body author { id username } } } } }","variables":{"url":"http://localhost:8080/"},"operationName":"GetComments"}'
|
||||
```
|
||||
|
||||
### Persisted Queries
|
||||
|
||||
You might see an error like `RAW_QUERY_NOT_AUTHORIZED`. This means that you attempted to use either no token, or a token from a user without admin privileges. In Coral, we whitelist the GraphQL mutations and queries that are performed by the applications for security reasons meaning that only admin users can make arbitrary queries against the GraphQL API.
|
||||
|
||||
## Configuration
|
||||
|
||||
The following environment variables can be set to configure the Coral Server. You
|
||||
@@ -371,8 +632,7 @@ the variables in a `.env` file in the root of the project in a simple
|
||||
(Default `false`)
|
||||
- `LOCALE` - Specify the default locale to use for all requests without a locale
|
||||
specified. (Default `en-US`)
|
||||
- `ENABLE_GRAPHIQL` - When `true`, it will enable the `/graphiql` even in
|
||||
production, use with care. (Default `false`)
|
||||
- `ENABLE_GRAPHIQL` - When `true`, it will enable the GraphiQL interface at `/graphiql`. **(🚨 Note 🚨) we do not recommend using this in production environments as it disables many safety features used by the application**. (Default `false`)
|
||||
- `CONCURRENCY` - The number of worker nodes to spawn to handle web traffic,
|
||||
this should be tied to the number of CPU's available. (Default
|
||||
`os.cpus().length`)
|
||||
@@ -388,6 +648,7 @@ the variables in a `.env` file in the root of the project in a simple
|
||||
- `WEBSOCKET_KEEP_ALIVE_TIMEOUT` - A duration in a parsable format (e.g. `30 seconds`
|
||||
, `1 minute`) that should be used to send keep alive messages through the
|
||||
websocket to keep the socket alive (Default `30 seconds`)
|
||||
- `TRUST_PROXY` - When provided, it configures the "trust proxy" settings for Express (See https://expressjs.com/en/guide/behind-proxies.html)
|
||||
|
||||
## License
|
||||
|
||||
|
||||
+5
-5
@@ -27,7 +27,7 @@ const config: Config = {
|
||||
"**/test/**/*",
|
||||
"core/**/*.spec.*",
|
||||
],
|
||||
executor: new CommandExecutor("npm run --silent generate:relay-stream", {
|
||||
executor: new CommandExecutor("npm run --silent generate:relay:stream", {
|
||||
runOnInit: true,
|
||||
}),
|
||||
},
|
||||
@@ -44,7 +44,7 @@ const config: Config = {
|
||||
"**/test/**/*",
|
||||
"core/**/*.spec.*",
|
||||
],
|
||||
executor: new CommandExecutor("npm run generate:relay-account", {
|
||||
executor: new CommandExecutor("npm run generate:relay:account", {
|
||||
runOnInit: true,
|
||||
}),
|
||||
},
|
||||
@@ -61,7 +61,7 @@ const config: Config = {
|
||||
"**/test/**/*",
|
||||
"core/**/*.spec.*",
|
||||
],
|
||||
executor: new CommandExecutor("npm run --silent generate:relay-admin", {
|
||||
executor: new CommandExecutor("npm run --silent generate:relay:admin", {
|
||||
runOnInit: true,
|
||||
}),
|
||||
},
|
||||
@@ -78,7 +78,7 @@ const config: Config = {
|
||||
"**/test/**/*",
|
||||
"core/**/*.spec.*",
|
||||
],
|
||||
executor: new CommandExecutor("npm run --silent generate:relay-auth", {
|
||||
executor: new CommandExecutor("npm run --silent generate:relay:auth", {
|
||||
runOnInit: true,
|
||||
}),
|
||||
},
|
||||
@@ -95,7 +95,7 @@ const config: Config = {
|
||||
"**/test/**/*",
|
||||
"core/**/*.spec.*",
|
||||
],
|
||||
executor: new CommandExecutor("npm run --silent generate:relay-install", {
|
||||
executor: new CommandExecutor("npm run --silent generate:relay:install", {
|
||||
runOnInit: true,
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -71,7 +71,7 @@ export default function({
|
||||
{ from: /^\/account/, to: "/account.html" },
|
||||
{ from: /^\/admin/, to: "/admin.html" },
|
||||
{ from: /^\/embed\/stream/, to: "/stream.html" },
|
||||
{ from: /^\/embed\/auth/, to: "/auth.html" },
|
||||
{ from: /^\/embed\/auth$/, to: "/auth.html" },
|
||||
{ from: /^\/embed\/auth\/callback/, to: "/auth-callback.html" },
|
||||
{ from: /^\/install/, to: "/install.html" },
|
||||
],
|
||||
|
||||
+10
-1
@@ -67,9 +67,18 @@ gulp.task("server:static", () =>
|
||||
.pipe(gulp.dest(resolveDistFolder() + "/core/server"))
|
||||
);
|
||||
|
||||
gulp.task("locales", () =>
|
||||
gulp
|
||||
.src(["./src/locales/**/*"])
|
||||
.pipe(gulp.dest(resolveDistFolder() + "/locales"))
|
||||
);
|
||||
|
||||
gulp.task(
|
||||
"server",
|
||||
gulp.series("server:schema", gulp.parallel("server:scripts", "server:static"))
|
||||
gulp.series(
|
||||
"server:schema",
|
||||
gulp.parallel("server:scripts", "server:static", "locales")
|
||||
)
|
||||
);
|
||||
|
||||
gulp.task("default", gulp.series("clean", "server"));
|
||||
|
||||
Generated
+993
-328
File diff suppressed because it is too large
Load Diff
+28
-13
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@coralproject/talk",
|
||||
"version": "5.0.0-beta.5",
|
||||
"version": "5.0.0",
|
||||
"author": "The Coral Project",
|
||||
"homepage": "https://coralproject.net/",
|
||||
"sideEffects": [
|
||||
@@ -12,7 +12,7 @@
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0",
|
||||
"npm": ">=6.7.0"
|
||||
"npm": ">=6.9.0"
|
||||
},
|
||||
"bugs": "https://github.com/coralproject/talk/issues",
|
||||
"contributors": [
|
||||
@@ -20,18 +20,21 @@
|
||||
],
|
||||
"description": "A better commenting experience from Mozilla, The Washington Post, and The New York Times.",
|
||||
"scripts": {
|
||||
"build": "NODE_ENV=production WEBPACK_DISABLE_SOURCEMAPS=1 npm-run-all generate --parallel lint:client build:client build:server",
|
||||
"build": "NODE_ENV=production npm-run-all generate-persist --parallel lint:client build:client build:server",
|
||||
"build:development": "NODE_ENV=development npm-run-all generate --parallel lint:client build:client build:server",
|
||||
"build:client": "ts-node --transpile-only ./scripts/build.ts",
|
||||
"build:server": "gulp server",
|
||||
"doctoc": "doctoc --title='## Table of Contents' --github README.md",
|
||||
"generate": "npm-run-all --parallel generate:*",
|
||||
"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/",
|
||||
"generate:relay-stream": "ts-node --transpile-only ./scripts/compileRelay --src ./src/core/client/stream --schema tenant",
|
||||
"generate:relay-account": "ts-node --transpile-only ./scripts/compileRelay --src ./src/core/client/account --schema tenant",
|
||||
"generate:relay-auth": "ts-node --transpile-only ./scripts/compileRelay --src ./src/core/client/auth --schema tenant",
|
||||
"generate:relay-install": "ts-node --transpile-only ./scripts/compileRelay --src ./src/core/client/install --schema tenant",
|
||||
"generate:relay-admin": "ts-node --transpile-only ./scripts/compileRelay --src ./src/core/client/admin --schema tenant",
|
||||
"generate:relay": "npm-run-all --parallel generate:relay:*",
|
||||
"generate:relay-persist": "npm-run-all --parallel 'generate:relay:* -- --persist'",
|
||||
"generate:relay:stream": "ts-node --transpile-only ./scripts/compileRelay --src ./src/core/client/stream --schema tenant",
|
||||
"generate:relay:account": "ts-node --transpile-only ./scripts/compileRelay --src ./src/core/client/account --schema tenant",
|
||||
"generate:relay:auth": "ts-node --transpile-only ./scripts/compileRelay --src ./src/core/client/auth --schema tenant",
|
||||
"generate:relay:install": "ts-node --transpile-only ./scripts/compileRelay --src ./src/core/client/install --schema tenant",
|
||||
"generate:relay:admin": "ts-node --transpile-only ./scripts/compileRelay --src ./src/core/client/admin --schema tenant",
|
||||
"generate:schema": "node ./scripts/generateSchemaTypes.js",
|
||||
"docz": "docz",
|
||||
"start": "NODE_ENV=production node dist/index.js",
|
||||
@@ -53,8 +56,11 @@
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@coralproject/bunyan-prettystream": "^0.1.4",
|
||||
"@types/archiver": "^3.0.0",
|
||||
"@types/cron": "^1.7.1",
|
||||
"akismet-api": "^4.2.0",
|
||||
"apollo-server-express": "^2.1.0",
|
||||
"apollo-server-express": "^2.8.1",
|
||||
"archiver": "^3.0.3",
|
||||
"basic-auth": "^2.0.1",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"bull": "^3.8.1",
|
||||
@@ -67,6 +73,8 @@
|
||||
"cookie": "^0.4.0",
|
||||
"cookie-parser": "^1.4.4",
|
||||
"cors": "^2.8.4",
|
||||
"cron": "^1.7.1",
|
||||
"csv-stringify": "^5.3.0",
|
||||
"dataloader": "^1.4.0",
|
||||
"dompurify": "^1.0.8",
|
||||
"dotenv": "^6.0.0",
|
||||
@@ -98,7 +106,8 @@
|
||||
"jwks-rsa": "^1.3.0",
|
||||
"linkify-it": "^2.1.0",
|
||||
"linkifyjs": "^2.1.8",
|
||||
"lodash": "^4.17.10",
|
||||
"lodash": "^4.17.15",
|
||||
"lru-cache": "^5.1.1",
|
||||
"luxon": "^1.12.0",
|
||||
"metascraper-author": "^3.11.8",
|
||||
"metascraper-date": "^3.11.4",
|
||||
@@ -123,13 +132,14 @@
|
||||
"prom-client": "^11.3.0",
|
||||
"proxy-agent": "^3.1.0",
|
||||
"querystringify": "^2.1.0",
|
||||
"react-relay-network-modern": "^3.0.4",
|
||||
"react-relay-network-modern": "^4.0.4",
|
||||
"source-map-support": "^0.5.12",
|
||||
"stack-utils": "^1.0.2",
|
||||
"striptags": "^3.1.1",
|
||||
"throng": "^4.0.0",
|
||||
"tlds": "^1.203.1",
|
||||
"tsscmp": "^1.0.6",
|
||||
"url-regex": "^5.0.0",
|
||||
"uuid": "^3.3.2",
|
||||
"verror": "^1.10.0"
|
||||
},
|
||||
@@ -160,6 +170,7 @@
|
||||
"@types/cookie": "^0.3.3",
|
||||
"@types/cookie-parser": "^1.4.1",
|
||||
"@types/cors": "^2.8.4",
|
||||
"@types/cron": "^1.7.1",
|
||||
"@types/cross-spawn": "^6.0.0",
|
||||
"@types/dotenv": "^4.0.3",
|
||||
"@types/enzyme": "^3.1.15",
|
||||
@@ -180,6 +191,7 @@
|
||||
"@types/linkify-it": "^2.1.0",
|
||||
"@types/linkifyjs": "^2.1.1",
|
||||
"@types/lodash": "^4.14.118",
|
||||
"@types/lru-cache": "^5.1.0",
|
||||
"@types/luxon": "^1.12.0",
|
||||
"@types/marked": "^0.6.0",
|
||||
"@types/mini-css-extract-plugin": "^0.2.0",
|
||||
@@ -189,6 +201,7 @@
|
||||
"@types/node-fetch": "^2.3.3",
|
||||
"@types/nodemailer": "^4.6.2",
|
||||
"@types/nunjucks": "^3.1.1",
|
||||
"@types/object-diff": "0.0.0",
|
||||
"@types/on-finished": "^2.3.1",
|
||||
"@types/passport": "^0.4.6",
|
||||
"@types/passport-facebook": "^2.1.8",
|
||||
@@ -276,9 +289,10 @@
|
||||
"lint-staged": "^8.1.6",
|
||||
"loader-utils": "^1.2.3",
|
||||
"lodash-es": "^4.17.11",
|
||||
"marked": "^0.6.0",
|
||||
"marked": "^0.7.0",
|
||||
"material-design-icons": "^3.0.1",
|
||||
"mini-css-extract-plugin": "^0.6.0",
|
||||
"object-diff": "0.0.4",
|
||||
"postcss-advanced-variables": "^3.0.0",
|
||||
"postcss-css-variables": "^0.11.0",
|
||||
"postcss-flexbugs-fixes": "^4.1.0",
|
||||
@@ -313,6 +327,7 @@
|
||||
"relay-compiler-language-typescript": "^4.1.0",
|
||||
"relay-local-schema": "^0.8.0",
|
||||
"relay-runtime": "^4.0.0",
|
||||
"resize-observer-polyfill": "^1.5.1",
|
||||
"sane": "^4.1.0",
|
||||
"shallow-equals": "^1.0.0",
|
||||
"simplemde": "^1.11.2",
|
||||
|
||||
+26
-3
@@ -2,7 +2,7 @@
|
||||
|
||||
import program from "commander";
|
||||
import spawn from "cross-spawn";
|
||||
import fs from "fs";
|
||||
import fs from "fs-extra";
|
||||
import path from "path";
|
||||
|
||||
const config = JSON.parse(
|
||||
@@ -14,6 +14,7 @@ program
|
||||
.usage("--src ./src/core/client/stream --schema tenant")
|
||||
.option("--src <folder>", "Find gql recursively in this folder")
|
||||
.option("--schema <schema>", "Identifier of schema")
|
||||
.option("--persist", "Use persisted queries")
|
||||
.description("Compile relay gql data")
|
||||
.parse(process.argv);
|
||||
|
||||
@@ -57,8 +58,30 @@ const args = [
|
||||
`${program.src}/__generated__`,
|
||||
"--schema",
|
||||
config.projects[program.schema].schemaPath,
|
||||
// "--persist-output",
|
||||
// `${program.src}/persisted-queries.json`,
|
||||
];
|
||||
|
||||
// Set the persisted query path.
|
||||
const persist = program.persist
|
||||
? `${program.src}/persisted-queries.json`
|
||||
: null;
|
||||
if (persist) {
|
||||
args.push("--persist-output", persist);
|
||||
}
|
||||
|
||||
spawn.sync("relay-compiler", args, { stdio: "inherit" });
|
||||
|
||||
if (persist) {
|
||||
if (fs.existsSync(persist)) {
|
||||
// Create the new filename.
|
||||
const name = path.basename(program.src);
|
||||
const generated = "./src/core/server/graph/tenant/persisted/__generated__";
|
||||
|
||||
// Create the generated directory if it doesn't exist.
|
||||
fs.ensureDirSync(generated);
|
||||
|
||||
// Copy the file over to the destination directory.
|
||||
fs.copySync(persist, `${generated}/${name}.json`, {
|
||||
overwrite: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ async function main() {
|
||||
contextType: "TenantContext",
|
||||
importStatements: [
|
||||
'import TenantContext from "coral-server/graph/tenant/context";',
|
||||
'import { Cursor } from "coral-server/models/helpers/connection";',
|
||||
'import { Cursor } from "coral-server/models/helpers";',
|
||||
],
|
||||
customScalarType: { Cursor: "Cursor", Time: "Date" },
|
||||
},
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import convict from "convict";
|
||||
|
||||
import { LOCALES } from "../common/helpers/i18n/locales";
|
||||
|
||||
const config = convict({
|
||||
env: {
|
||||
doc: "The application environment.",
|
||||
@@ -21,6 +23,12 @@ const config = convict({
|
||||
env: "DEV_PORT",
|
||||
arg: "dev-port",
|
||||
},
|
||||
defaultLocale: {
|
||||
doc: "Specify the default locale to use",
|
||||
format: LOCALES,
|
||||
default: "en-US",
|
||||
env: "LOCALE",
|
||||
},
|
||||
generateReport: {
|
||||
doc: "Generate a report using webpack-bundle-analyzer",
|
||||
format: Boolean,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import OptimizeCssnanoPlugin from "@intervolga/optimize-cssnano-plugin";
|
||||
import bunyan from "bunyan";
|
||||
import CaseSensitivePathsPlugin from "case-sensitive-paths-webpack-plugin";
|
||||
import CompressionPlugin from "compression-webpack-plugin";
|
||||
import ForkTsCheckerWebpackPlugin from "fork-ts-checker-webpack-plugin";
|
||||
@@ -30,6 +31,12 @@ import paths from "./paths";
|
||||
const filterPlugins = (plugins: Array<Plugin | null>): Plugin[] =>
|
||||
plugins.filter(identity) as Plugin[];
|
||||
|
||||
// Create the build logger.
|
||||
const logger = bunyan.createLogger({
|
||||
name: "coral",
|
||||
level: "debug",
|
||||
});
|
||||
|
||||
interface CreateWebpackOptions {
|
||||
appendPlugins?: any[];
|
||||
watch?: boolean;
|
||||
@@ -41,6 +48,8 @@ export default function createWebpackConfig(
|
||||
config: Config,
|
||||
{ appendPlugins = [], watch = false }: CreateWebpackOptions = {}
|
||||
): Configuration[] {
|
||||
logger.debug({ config: config.toString() }, "loaded configuration");
|
||||
|
||||
const maxCores = config.get("maxCores");
|
||||
const env = createClientEnv(config);
|
||||
const disableSourcemaps = config.get("disableSourcemaps");
|
||||
@@ -83,23 +92,23 @@ export default function createWebpackConfig(
|
||||
const localesOptions = {
|
||||
pathToLocales: paths.appLocales,
|
||||
|
||||
// Default locale if non could be negotiated.
|
||||
defaultLocale: "en-US",
|
||||
// Default locale if none was specified.
|
||||
defaultLocale: config.get("defaultLocale"),
|
||||
|
||||
// Fallback locale if a translation was not found.
|
||||
// If not set, will use the text that is already
|
||||
// in the code base.
|
||||
fallbackLocale: "en-US",
|
||||
fallbackLocale: config.get("defaultLocale"),
|
||||
|
||||
// Common fluent files are always included in the locale bundles.
|
||||
commonFiles: ["framework.ftl", "common.ftl", "ui.ftl"],
|
||||
|
||||
// Locales that come with the main bundle. Others are loaded on demand.
|
||||
bundled: ["en-US"],
|
||||
bundled: [config.get("defaultLocale")],
|
||||
|
||||
// All available locales can be loadable on demand.
|
||||
// To restrict available locales set:
|
||||
// availableLocales: ["en-US"],
|
||||
// availableLocales: [config.get("defaultLocale")],
|
||||
};
|
||||
|
||||
const additionalPlugins = [
|
||||
@@ -144,6 +153,16 @@ export default function createWebpackConfig(
|
||||
),
|
||||
];
|
||||
|
||||
const devtool = disableSourcemaps
|
||||
? false
|
||||
: isProduction
|
||||
? // We generate sourcemaps in production. This is slow but gives good results.
|
||||
// You can exclude the *.map files from the build during deployment.
|
||||
"source-map"
|
||||
: // You may want 'eval' instead if you prefer to see the compiled output in DevTools.
|
||||
// See the discussion in https://github.com/facebookincubator/create-react-app/issues/343.
|
||||
"cheap-module-source-map";
|
||||
|
||||
const baseConfig: Configuration = {
|
||||
stats: {
|
||||
// https://github.com/TypeStrong/ts-loader#transpileonly-boolean-defaultfalse
|
||||
@@ -192,15 +211,7 @@ export default function createWebpackConfig(
|
||||
}),
|
||||
],
|
||||
},
|
||||
devtool: disableSourcemaps
|
||||
? false
|
||||
: isProduction
|
||||
? // We generate sourcemaps in production. This is slow but gives good results.
|
||||
// You can exclude the *.map files from the build during deployment.
|
||||
"source-map"
|
||||
: // You may want 'eval' instead if you prefer to see the compiled output in DevTools.
|
||||
// See the discussion in https://github.com/facebookincubator/create-react-app/issues/343.
|
||||
"cheap-module-source-map",
|
||||
devtool,
|
||||
// These are the "entry points" to our application.
|
||||
// This means they will be the "root" imports that are included in JS bundle.
|
||||
// The first two entry points enable "hot" CSS and auto-refreshes for JS.
|
||||
@@ -212,10 +223,10 @@ export default function createWebpackConfig(
|
||||
// Generated JS file names (with nested folders).
|
||||
// There will be one main bundle, and one file per asynchronous chunk.
|
||||
filename: isProduction
|
||||
? "assets/js/[name].[chunkhash:8].js"
|
||||
? "assets/js/[name].[chunkhash].js"
|
||||
: "assets/js/[name].js",
|
||||
chunkFilename: isProduction
|
||||
? "assets/js/[name].[chunkhash:8].chunk.js"
|
||||
? "assets/js/[name].[chunkhash].chunk.js"
|
||||
: "assets/js/[name].chunk.js",
|
||||
// We inferred the "public path" (such as / or /my-project) from homepage.
|
||||
publicPath,
|
||||
@@ -346,7 +357,7 @@ export default function createWebpackConfig(
|
||||
options: {
|
||||
limit: 10000,
|
||||
name: isProduction
|
||||
? "assets/media/[name].[hash:8].[ext]"
|
||||
? "assets/media/[name].[hash].[ext]"
|
||||
: "assets/media/[name].[ext]",
|
||||
},
|
||||
},
|
||||
@@ -359,7 +370,7 @@ export default function createWebpackConfig(
|
||||
options: {
|
||||
modules: true,
|
||||
importLoaders: 2,
|
||||
localIdentName: "[name]-[local]-[hash:base64:5]",
|
||||
localIdentName: "[name]-[local]-[contenthash]",
|
||||
sourceMap: !disableSourcemaps,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
__webpack_public_path__ =
|
||||
JSON.parse(document.getElementById("config").innerText) || "/";
|
||||
JSON.parse(document.getElementById("config").innerText).staticURI || "/";
|
||||
|
||||
@@ -4,12 +4,7 @@
|
||||
}
|
||||
|
||||
.centered {
|
||||
max-width: calc(70 * var(--mini-unit));
|
||||
margin: 10% auto;
|
||||
padding: 0 calc(0.5 * var(--mini-unit));
|
||||
box-sizing: border-box;
|
||||
|
||||
@media (min-width: $breakpoints-xs) {
|
||||
max-width: calc(42 * var(--mini-unit));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,6 @@ async function main() {
|
||||
const ManagedCoralContextProvider = await createManaged({
|
||||
initLocalState,
|
||||
localesData,
|
||||
userLocales: navigator.languages,
|
||||
});
|
||||
|
||||
const Index: FunctionComponent = () => (
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { makeRouteConfig, Route } from "found";
|
||||
import React from "react";
|
||||
|
||||
import DownloadRoute from "./routes/download/Download";
|
||||
import ConfirmRoute from "./routes/email/Confirm";
|
||||
import UnsubscribeRoute from "./routes/notifications/Unsubscribe";
|
||||
import ResetRoute from "./routes/password/Reset";
|
||||
|
||||
export default makeRouteConfig(
|
||||
@@ -12,5 +14,9 @@ export default makeRouteConfig(
|
||||
<Route path="email">
|
||||
<Route path="confirm" {...ConfirmRoute.routeConfig} />
|
||||
</Route>
|
||||
<Route path="download" {...DownloadRoute.routeConfig} />
|
||||
<Route path="notifications">
|
||||
<Route path="unsubscribe" {...UnsubscribeRoute.routeConfig} />
|
||||
</Route>
|
||||
</Route>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
.title {
|
||||
font-family: var(--font-family-serif);
|
||||
font-weight: 600;
|
||||
font-style: normal;
|
||||
font-size: calc(48rem / var(--rem-base));
|
||||
line-height: calc(42em / 48);
|
||||
text-align: center;
|
||||
|
||||
color: var(--palette-common-black);
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: var(--spacing-2) var(--spacing-3) var(--spacing-3) var(--spacing-3);
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: var(--spacing-4);
|
||||
}
|
||||
|
||||
.sectionText {
|
||||
font-family: var(--font-family-sans-serif);
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-size: calc(24rem / var(--rem-base));
|
||||
line-height: calc(34em / 24);
|
||||
letter-spacing: 0.3px;
|
||||
|
||||
color: var(--palette-text-primary);
|
||||
}
|
||||
|
||||
.list {
|
||||
list-style: none;
|
||||
margin-left: calc(-1 * var(--spacing-5));
|
||||
}
|
||||
|
||||
.list li {
|
||||
margin-bottom: calc(16em / 24);
|
||||
}
|
||||
|
||||
.listContent {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.bullet {
|
||||
display: inline-block;
|
||||
min-width: 24px;
|
||||
margin-right: var(--spacing-2);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import cn from "classnames";
|
||||
import { Localized } from "fluent-react/compat";
|
||||
import React, { FunctionComponent } from "react";
|
||||
|
||||
import { Flex, HorizontalGutter, Icon, Typography } from "coral-ui/components";
|
||||
|
||||
import styles from "./DownloadDescription.css";
|
||||
|
||||
const DownloadDescription: FunctionComponent = () => {
|
||||
return (
|
||||
<HorizontalGutter size="double">
|
||||
<Localized id="download-landingPage-title">
|
||||
<div className={styles.title}>Download Your Comment History</div>
|
||||
</Localized>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.section}>
|
||||
<Localized id="download-landingPage-description">
|
||||
<Typography variant="bodyCopy" className={styles.sectionText}>
|
||||
Your comment history will be downloaded into a .zip file. After
|
||||
your comment history is unzipped you will have a comma separated
|
||||
value (or .csv) file that you can easily import into your favorite
|
||||
spreadsheet application.
|
||||
</Typography>
|
||||
</Localized>
|
||||
</div>
|
||||
|
||||
<div className={styles.section}>
|
||||
<Localized id="download-landingPage-contentsDescription">
|
||||
<Typography variant="bodyCopy" className={styles.sectionText}>
|
||||
For each of your comments the following information is included:
|
||||
</Typography>
|
||||
</Localized>
|
||||
<ul className={styles.list}>
|
||||
<li>
|
||||
<Flex alignItems="center">
|
||||
<Icon size="lg" className={styles.bullet}>
|
||||
check
|
||||
</Icon>
|
||||
<Localized id="download-landingPage-contentsDate">
|
||||
<Typography
|
||||
variant="bodyCopy"
|
||||
className={cn(styles.sectionText, styles.listContent)}
|
||||
>
|
||||
When you wrote the comment
|
||||
</Typography>
|
||||
</Localized>
|
||||
</Flex>
|
||||
</li>
|
||||
<li>
|
||||
<Flex alignItems="center">
|
||||
<Icon size="lg" className={styles.bullet}>
|
||||
check
|
||||
</Icon>
|
||||
<Localized id="download-landingPage-contentsUrl">
|
||||
<Typography
|
||||
variant="bodyCopy"
|
||||
className={cn(styles.sectionText, styles.listContent)}
|
||||
>
|
||||
The permalink URL for the comment
|
||||
</Typography>
|
||||
</Localized>
|
||||
</Flex>
|
||||
</li>
|
||||
<li>
|
||||
<Flex alignItems="center">
|
||||
<Icon size="lg" className={styles.bullet}>
|
||||
check
|
||||
</Icon>
|
||||
<Localized id="download-landingPage-contentsText">
|
||||
<Typography
|
||||
variant="bodyCopy"
|
||||
className={cn(styles.sectionText, styles.listContent)}
|
||||
>
|
||||
The comment text
|
||||
</Typography>
|
||||
</Localized>
|
||||
</Flex>
|
||||
</li>
|
||||
<li>
|
||||
<Flex alignItems="center">
|
||||
<Icon size="lg" className={styles.bullet}>
|
||||
check
|
||||
</Icon>
|
||||
<Localized id="download-landingPage-contentsStoryUrl">
|
||||
<Typography
|
||||
variant="bodyCopy"
|
||||
className={cn(styles.sectionText, styles.listContent)}
|
||||
>
|
||||
The URL on the article or story where the comment appears
|
||||
</Typography>
|
||||
</Localized>
|
||||
</Flex>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</HorizontalGutter>
|
||||
);
|
||||
};
|
||||
|
||||
export default DownloadDescription;
|
||||
@@ -0,0 +1,8 @@
|
||||
.form {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.downloadButton {
|
||||
font-size: calc(18rem / var(--rem-base));
|
||||
line-height: calc(23em / 18);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Localized } from "fluent-react/compat";
|
||||
import React, { FunctionComponent, useCallback, useState } from "react";
|
||||
|
||||
import { Button, HorizontalGutter } from "coral-ui/components";
|
||||
|
||||
import styles from "./DownloadForm.css";
|
||||
|
||||
interface Props {
|
||||
token: string;
|
||||
}
|
||||
|
||||
const DownloadForm: FunctionComponent<Props> = ({ token }) => {
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const onClick = useCallback(() => {
|
||||
setSubmitted(true);
|
||||
}, [setSubmitted]);
|
||||
|
||||
return (
|
||||
<HorizontalGutter size="double">
|
||||
<form
|
||||
className={styles.form}
|
||||
method="post"
|
||||
action="/api/account/download"
|
||||
>
|
||||
<input name="token" type="hidden" value={token} />
|
||||
<Localized id="download-landingPage-downloadComments ">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="filled"
|
||||
color="primary"
|
||||
disabled={submitted}
|
||||
onClick={onClick}
|
||||
className={styles.downloadButton}
|
||||
>
|
||||
Download My Comment History
|
||||
</Button>
|
||||
</Localized>
|
||||
</form>
|
||||
</HorizontalGutter>
|
||||
);
|
||||
};
|
||||
|
||||
export default DownloadForm;
|
||||
@@ -0,0 +1,16 @@
|
||||
.container {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.root {
|
||||
display: inline-block;
|
||||
|
||||
max-width: calc(70 * var(--mini-unit));
|
||||
|
||||
@media (min-width: $breakpoints-xs) {
|
||||
max-width: calc(90 * var(--mini-unit));
|
||||
}
|
||||
|
||||
text-align: left;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import React, { FunctionComponent } from "react";
|
||||
import { Environment } from "relay-runtime";
|
||||
|
||||
import Loading from "coral-account/components/Loading";
|
||||
import { useToken } from "coral-framework/hooks";
|
||||
import { createFetch } from "coral-framework/lib/relay";
|
||||
import { withRouteConfig } from "coral-framework/lib/router";
|
||||
import { parseHashQuery } from "coral-framework/utils";
|
||||
import { HorizontalGutter } from "coral-ui/components";
|
||||
|
||||
import DownloadDescription from "./DownloadDescription";
|
||||
import DownloadForm from "./DownloadForm";
|
||||
import Sorry from "./Sorry";
|
||||
|
||||
import styles from "./DownloadRoute.css";
|
||||
|
||||
const fetcher = createFetch(
|
||||
"downloadToken",
|
||||
async (environment: Environment, variables: { token: string }, { rest }) =>
|
||||
await rest.fetch<void>("/account/download", {
|
||||
method: "GET",
|
||||
token: variables.token,
|
||||
})
|
||||
);
|
||||
|
||||
interface Props {
|
||||
token: string | undefined;
|
||||
}
|
||||
|
||||
const DownloadRoute: FunctionComponent<Props> = ({ token }) => {
|
||||
const [state, error] = useToken(fetcher, token);
|
||||
|
||||
if (state === "UNCHECKED") {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.root}>
|
||||
<HorizontalGutter size="double">
|
||||
<Loading />
|
||||
</HorizontalGutter>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (state !== "VALID" || error) {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.root}>
|
||||
<HorizontalGutter size="double">
|
||||
<DownloadDescription />
|
||||
<Sorry />
|
||||
</HorizontalGutter>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.root}>
|
||||
<DownloadDescription />
|
||||
<DownloadForm token={token!} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const enhanced = withRouteConfig<Props>({
|
||||
render: ({ match, Component }) => (
|
||||
<Component token={parseHashQuery(match.location.hash).downloadToken} />
|
||||
),
|
||||
})(DownloadRoute);
|
||||
|
||||
export default enhanced;
|
||||
@@ -0,0 +1,7 @@
|
||||
.icon {
|
||||
margin-right: var(--spacing-2);
|
||||
}
|
||||
|
||||
.callout {
|
||||
justify-content: left;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import React, { FunctionComponent } from "react";
|
||||
|
||||
import { CallOut, Flex, Icon } from "coral-ui/components";
|
||||
import { Localized } from "fluent-react/compat";
|
||||
|
||||
import styles from "./Sorry.css";
|
||||
|
||||
const Sorry: FunctionComponent = () => {
|
||||
return (
|
||||
<CallOut color="error" fullWidth className={styles.callout}>
|
||||
<Flex>
|
||||
<Icon size="md" className={styles.icon}>
|
||||
error
|
||||
</Icon>
|
||||
<Localized id="download-landingPage-sorry">
|
||||
Your download link is invalid.
|
||||
</Localized>
|
||||
</Flex>
|
||||
</CallOut>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sorry;
|
||||
@@ -0,0 +1 @@
|
||||
export { default, default as DownloadRoute } from "./DownloadRoute";
|
||||
@@ -30,39 +30,37 @@ const ConfirmForm: React.FunctionComponent<Props> = ({ onSuccess, token }) => {
|
||||
return;
|
||||
}, [token]);
|
||||
return (
|
||||
<div>
|
||||
<Form onSubmit={onSubmit}>
|
||||
{({ handleSubmit, submitting }) => (
|
||||
<form autoComplete="off" onSubmit={handleSubmit}>
|
||||
<HorizontalGutter size="double">
|
||||
<HorizontalGutter>
|
||||
<Localized id="confirmEmail-emailConfirmation">
|
||||
<Typography variant="heading1">Email Confirmation</Typography>
|
||||
</Localized>
|
||||
<Localized id="confirmEmail-pleaseClickToConfirm">
|
||||
<Typography variant="bodyCopy">
|
||||
Click below to confirm your email address.
|
||||
</Typography>
|
||||
</Localized>
|
||||
</HorizontalGutter>
|
||||
<HorizontalGutter>
|
||||
<Localized id="confirmEmail-confirmEmail">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="filled"
|
||||
color="primary"
|
||||
disabled={submitting}
|
||||
fullWidth
|
||||
>
|
||||
Confirm email
|
||||
</Button>
|
||||
</Localized>
|
||||
</HorizontalGutter>
|
||||
<Form onSubmit={onSubmit}>
|
||||
{({ handleSubmit, submitting }) => (
|
||||
<form autoComplete="off" onSubmit={handleSubmit}>
|
||||
<HorizontalGutter size="double">
|
||||
<HorizontalGutter>
|
||||
<Localized id="confirmEmail-emailConfirmation">
|
||||
<Typography variant="heading1">Email Confirmation</Typography>
|
||||
</Localized>
|
||||
<Localized id="confirmEmail-pleaseClickToConfirm">
|
||||
<Typography variant="bodyCopy">
|
||||
Click below to confirm your email address.
|
||||
</Typography>
|
||||
</Localized>
|
||||
</HorizontalGutter>
|
||||
</form>
|
||||
)}
|
||||
</Form>
|
||||
</div>
|
||||
<HorizontalGutter>
|
||||
<Localized id="confirmEmail-confirmEmail">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="filled"
|
||||
color="primary"
|
||||
disabled={submitting}
|
||||
fullWidth
|
||||
>
|
||||
Confirm email
|
||||
</Button>
|
||||
</Localized>
|
||||
</HorizontalGutter>
|
||||
</HorizontalGutter>
|
||||
</form>
|
||||
)}
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
.container {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.root {
|
||||
display: inline-block;
|
||||
|
||||
max-width: calc(70 * var(--mini-unit));
|
||||
|
||||
@media (min-width: $breakpoints-xs) {
|
||||
max-width: calc(42 * var(--mini-unit));
|
||||
}
|
||||
|
||||
text-align: left;
|
||||
}
|
||||
@@ -11,6 +11,8 @@ import ConfirmForm from "./ConfirmForm";
|
||||
import Sorry from "./Sorry";
|
||||
import Success from "./Success";
|
||||
|
||||
import styles from "./ConfirmRoute.css";
|
||||
|
||||
const fetcher = createFetch(
|
||||
"confirmToken",
|
||||
async (environment: Environment, variables: { token: string }, { rest }) =>
|
||||
@@ -32,17 +34,37 @@ const ConfirmRoute: React.FunctionComponent<Props> = ({ token }) => {
|
||||
const [state, error] = useToken(fetcher, token);
|
||||
|
||||
if (state === "UNCHECKED") {
|
||||
return <Loading />;
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.root}>
|
||||
<Loading />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (state !== "VALID" || error) {
|
||||
return <Sorry reason={error} />;
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.root}>
|
||||
<Sorry reason={error} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return !finished ? (
|
||||
<ConfirmForm token={token!} onSuccess={onSuccess} />
|
||||
<div className={styles.container}>
|
||||
<div className={styles.root}>
|
||||
<ConfirmForm token={token!} onSuccess={onSuccess} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Success />
|
||||
<div className={styles.container}>
|
||||
<div className={styles.root}>
|
||||
<Success />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Localized } from "fluent-react/compat";
|
||||
import React from "react";
|
||||
|
||||
import { CallOut, HorizontalGutter, Typography } from "coral-ui/components";
|
||||
|
||||
interface Props {
|
||||
reason: React.ReactNode;
|
||||
}
|
||||
|
||||
const Sorry: React.FunctionComponent<Props> = ({ reason }) => {
|
||||
return (
|
||||
<HorizontalGutter size="double">
|
||||
<Localized id="unsubscribe-oopsSorry">
|
||||
<Typography variant="heading1">Oops Sorry!</Typography>
|
||||
</Localized>
|
||||
<CallOut color="error" fullWidth>
|
||||
{reason ? (
|
||||
reason
|
||||
) : (
|
||||
<Localized id="account-tokenNotFound">
|
||||
<span data-testid="invalid-link">
|
||||
The specified link is invalid, check to see if it was copied
|
||||
correctly.
|
||||
</span>
|
||||
</Localized>
|
||||
)}
|
||||
</CallOut>
|
||||
</HorizontalGutter>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sorry;
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Localized } from "fluent-react/compat";
|
||||
import React from "react";
|
||||
|
||||
import { HorizontalGutter, Typography } from "coral-ui/components";
|
||||
|
||||
const Success: React.FunctionComponent = () => {
|
||||
return (
|
||||
<HorizontalGutter data-testid="success" size="double">
|
||||
<Localized id="unsubscribe-successfullyUnsubscribed">
|
||||
<Typography variant="heading1">
|
||||
You are now unsubscribed from all notifications
|
||||
</Typography>
|
||||
</Localized>
|
||||
</HorizontalGutter>
|
||||
);
|
||||
};
|
||||
|
||||
export default Success;
|
||||
@@ -0,0 +1,76 @@
|
||||
import { FORM_ERROR } from "final-form";
|
||||
import { Localized } from "fluent-react/compat";
|
||||
import React, { useCallback } from "react";
|
||||
import { Form } from "react-final-form";
|
||||
|
||||
import { InvalidRequestError } from "coral-framework/lib/errors";
|
||||
import { useMutation } from "coral-framework/lib/relay";
|
||||
import {
|
||||
Button,
|
||||
CallOut,
|
||||
HorizontalGutter,
|
||||
Typography,
|
||||
} from "coral-ui/components";
|
||||
|
||||
import UnsubscribeNotificationsMutation from "./UnsubscribeNotificationsMutation";
|
||||
|
||||
interface Props {
|
||||
token: string;
|
||||
disabled?: boolean;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
const UnsubscribeForm: React.FunctionComponent<Props> = ({
|
||||
onSuccess,
|
||||
token,
|
||||
}) => {
|
||||
const unsubscribe = useMutation(UnsubscribeNotificationsMutation);
|
||||
const onSubmit = useCallback(async () => {
|
||||
try {
|
||||
await unsubscribe({ token });
|
||||
onSuccess();
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidRequestError) {
|
||||
return error.invalidArgs;
|
||||
}
|
||||
return { [FORM_ERROR]: error.message };
|
||||
}
|
||||
return;
|
||||
}, [token]);
|
||||
return (
|
||||
<div data-testid="unsubscribe-form">
|
||||
<Form onSubmit={onSubmit}>
|
||||
{({ handleSubmit, submitting, submitError }) => (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<HorizontalGutter>
|
||||
<Localized id="unsubscribe-clickToConfirm">
|
||||
<Typography variant="heading1">
|
||||
Click below to confirm that you want to unsubscribe from all
|
||||
notifications.
|
||||
</Typography>
|
||||
</Localized>
|
||||
{submitError && (
|
||||
<CallOut color="error" fullWidth>
|
||||
{submitError}
|
||||
</CallOut>
|
||||
)}
|
||||
<Localized id="unsubscribe-confirm">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="filled"
|
||||
color="primary"
|
||||
disabled={submitting}
|
||||
fullWidth
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
</Localized>
|
||||
</HorizontalGutter>
|
||||
</form>
|
||||
)}
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UnsubscribeForm;
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
import { Environment } from "relay-runtime";
|
||||
|
||||
import { createMutation } from "coral-framework/lib/relay";
|
||||
|
||||
const UnsubscribeNotificationsMutation = createMutation(
|
||||
"unsubscribeNotifications",
|
||||
async (environment: Environment, variables: { token: string }, { rest }) =>
|
||||
await rest.fetch<void>("/account/notifications/unsubscribe", {
|
||||
method: "DELETE",
|
||||
token: variables.token,
|
||||
})
|
||||
);
|
||||
|
||||
export default UnsubscribeNotificationsMutation;
|
||||
@@ -0,0 +1,16 @@
|
||||
.container {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.root {
|
||||
display: inline-block;
|
||||
|
||||
max-width: calc(70 * var(--mini-unit));
|
||||
|
||||
@media (min-width: $breakpoints-xs) {
|
||||
max-width: calc(42 * var(--mini-unit));
|
||||
}
|
||||
|
||||
text-align: left;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import React, { useCallback, useState } from "react";
|
||||
import { Environment } from "relay-runtime";
|
||||
|
||||
import Loading from "coral-account/components/Loading";
|
||||
import { useToken } from "coral-framework/hooks";
|
||||
import { createFetch } from "coral-framework/lib/relay";
|
||||
import { withRouteConfig } from "coral-framework/lib/router";
|
||||
import { parseHashQuery } from "coral-framework/utils";
|
||||
|
||||
import Sorry from "./Sorry";
|
||||
import Success from "./Success";
|
||||
import UnsubscribeForm from "./UnsubscribeForm";
|
||||
|
||||
import styles from "./UnsubscribeRoute.css";
|
||||
|
||||
const fetcher = createFetch(
|
||||
"unsubscribeToken",
|
||||
async (environment: Environment, variables: { token: string }, { rest }) =>
|
||||
await rest.fetch<void>("/account/notifications/unsubscribe", {
|
||||
method: "GET",
|
||||
token: variables.token,
|
||||
})
|
||||
);
|
||||
|
||||
interface Props {
|
||||
token: string | undefined;
|
||||
}
|
||||
|
||||
const UnsubscribeRoute: React.FunctionComponent<Props> = ({ token }) => {
|
||||
const [finished, setFinished] = useState(false);
|
||||
const onSuccess = useCallback(() => {
|
||||
setFinished(true);
|
||||
}, []);
|
||||
const [state, error] = useToken(fetcher, token);
|
||||
|
||||
if (state === "UNCHECKED") {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.root}>
|
||||
<Loading />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (state !== "VALID" || error) {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.root}>
|
||||
<Sorry reason={error} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return !finished ? (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.root}>
|
||||
<UnsubscribeForm token={token!} onSuccess={onSuccess} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.root}>
|
||||
<Success />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const enhanced = withRouteConfig<Props>({
|
||||
render: ({ match, Component }) => (
|
||||
<Component token={parseHashQuery(match.location.hash).unsubscribeToken} />
|
||||
),
|
||||
})(UnsubscribeRoute);
|
||||
|
||||
export default enhanced;
|
||||
@@ -0,0 +1 @@
|
||||
export { default, default as UnsubscribeRoute } from "./UnsubscribeRoute";
|
||||
@@ -4,6 +4,7 @@ import React, { useCallback } from "react";
|
||||
import { Field, Form } from "react-final-form";
|
||||
|
||||
import { InvalidRequestError } from "coral-framework/lib/errors";
|
||||
import { colorFromMeta, ValidationMessage } from "coral-framework/lib/form";
|
||||
import { useMutation } from "coral-framework/lib/relay";
|
||||
import {
|
||||
composeValidators,
|
||||
@@ -19,7 +20,6 @@ import {
|
||||
InputLabel,
|
||||
PasswordField,
|
||||
Typography,
|
||||
ValidationMessage,
|
||||
} from "coral-ui/components";
|
||||
|
||||
import ResetPasswordMutation from "./ResetPasswordMutation";
|
||||
@@ -99,32 +99,23 @@ const ResetPasswordForm: React.FunctionComponent<Props> = ({
|
||||
>
|
||||
<PasswordField
|
||||
id={input.name}
|
||||
name={input.name}
|
||||
onChange={input.onChange}
|
||||
value={input.value}
|
||||
placeholder="Password"
|
||||
color={
|
||||
meta.touched && (meta.error || meta.submitError)
|
||||
? "error"
|
||||
: "regular"
|
||||
}
|
||||
disabled={disabled}
|
||||
color={colorFromMeta(meta)}
|
||||
autoComplete="new-password"
|
||||
disabled={disabled || meta.submitting}
|
||||
fullWidth
|
||||
{...input}
|
||||
/>
|
||||
</Localized>
|
||||
{submitError && (
|
||||
<CallOut color="error" fullWidth>
|
||||
{submitError}
|
||||
</CallOut>
|
||||
)}
|
||||
{meta.touched && (meta.error || meta.submitError) && (
|
||||
<ValidationMessage fullWidth>
|
||||
{meta.error || meta.submitError}
|
||||
</ValidationMessage>
|
||||
)}
|
||||
<ValidationMessage meta={meta} fullWidth />
|
||||
</FormField>
|
||||
)}
|
||||
</Field>
|
||||
{submitError && (
|
||||
<CallOut color="error" fullWidth>
|
||||
{submitError}
|
||||
</CallOut>
|
||||
)}
|
||||
<Localized id="resetPassword-resetPassword">
|
||||
<Button
|
||||
type="submit"
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
.container {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.root {
|
||||
display: inline-block;
|
||||
|
||||
max-width: calc(70 * var(--mini-unit));
|
||||
|
||||
@media (min-width: $breakpoints-xs) {
|
||||
max-width: calc(42 * var(--mini-unit));
|
||||
}
|
||||
|
||||
text-align: left;
|
||||
}
|
||||
@@ -11,6 +11,8 @@ import ResetPasswordForm from "./ResetPasswordForm";
|
||||
import Sorry from "./Sorry";
|
||||
import Success from "./Success";
|
||||
|
||||
import styles from "./ResetRoute.css";
|
||||
|
||||
const fetcher = createFetch(
|
||||
"resetToken",
|
||||
async (environment: Environment, variables: { token: string }, { rest }) =>
|
||||
@@ -32,17 +34,37 @@ const ResetRoute: React.FunctionComponent<Props> = ({ token }) => {
|
||||
const [state, error] = useToken(fetcher, token);
|
||||
|
||||
if (state === "UNCHECKED") {
|
||||
return <Loading />;
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.root}>
|
||||
<Loading />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (state !== "VALID" || error) {
|
||||
return <Sorry reason={error} />;
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.root}>
|
||||
<Sorry reason={error} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return !finished ? (
|
||||
<ResetPasswordForm token={token!} onSuccess={onSuccess} />
|
||||
<div className={styles.container}>
|
||||
<div className={styles.root}>
|
||||
<ResetPasswordForm token={token!} onSuccess={onSuccess} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Success />
|
||||
<div className={styles.container}>
|
||||
<div className={styles.root}>
|
||||
<Success />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -10,46 +10,54 @@ exports[`renders form 1`] = `
|
||||
<div
|
||||
className="MainLayout-centered"
|
||||
>
|
||||
<div>
|
||||
<form
|
||||
autoComplete="off"
|
||||
onSubmit={[Function]}
|
||||
<div
|
||||
className="ConfirmRoute-container"
|
||||
>
|
||||
<div
|
||||
className="ConfirmRoute-root"
|
||||
>
|
||||
<div
|
||||
className="Box-root HorizontalGutter-root HorizontalGutter-double"
|
||||
<form
|
||||
autoComplete="off"
|
||||
onSubmit={[Function]}
|
||||
>
|
||||
<div
|
||||
className="Box-root HorizontalGutter-root HorizontalGutter-full"
|
||||
className="Box-root HorizontalGutter-root HorizontalGutter-double"
|
||||
>
|
||||
<h1
|
||||
className="Box-root Typography-root Typography-heading1 Typography-colorTextPrimary"
|
||||
<div
|
||||
className="Box-root HorizontalGutter-root HorizontalGutter-full"
|
||||
>
|
||||
Email Confirmation
|
||||
</h1>
|
||||
<p
|
||||
className="Box-root Typography-root Typography-bodyCopy Typography-colorTextPrimary"
|
||||
<h1
|
||||
className="Box-root Typography-root Typography-heading1 Typography-colorTextPrimary"
|
||||
>
|
||||
Email Confirmation
|
||||
</h1>
|
||||
<p
|
||||
className="Box-root Typography-root Typography-bodyCopy Typography-colorTextPrimary"
|
||||
>
|
||||
Click below to confirm your email address.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className="Box-root HorizontalGutter-root HorizontalGutter-full"
|
||||
>
|
||||
Click below to confirm your email address.
|
||||
</p>
|
||||
<button
|
||||
className="BaseButton-root Button-root Button-sizeRegular Button-colorPrimary Button-variantFilled Button-fullWidth"
|
||||
data-color="primary"
|
||||
data-variant="filled"
|
||||
disabled={false}
|
||||
onBlur={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="submit"
|
||||
>
|
||||
Confirm email
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="Box-root HorizontalGutter-root HorizontalGutter-full"
|
||||
>
|
||||
<button
|
||||
className="BaseButton-root Button-root Button-sizeRegular Button-colorPrimary Button-variantFilled Button-fullWidth"
|
||||
disabled={false}
|
||||
onBlur={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="submit"
|
||||
>
|
||||
Confirm email
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -66,22 +74,30 @@ exports[`renders missing confirm token 1`] = `
|
||||
className="MainLayout-centered"
|
||||
>
|
||||
<div
|
||||
className="Box-root HorizontalGutter-root HorizontalGutter-double"
|
||||
className="ConfirmRoute-container"
|
||||
>
|
||||
<h1
|
||||
className="Box-root Typography-root Typography-heading1 Typography-colorTextPrimary"
|
||||
>
|
||||
Oops Sorry!
|
||||
</h1>
|
||||
<div
|
||||
className="CallOut-root CallOut-colorError CallOut-fullWidth"
|
||||
className="ConfirmRoute-root"
|
||||
>
|
||||
<div>
|
||||
<span
|
||||
data-testid="invalid-link"
|
||||
<div
|
||||
className="Box-root HorizontalGutter-root HorizontalGutter-double"
|
||||
>
|
||||
<h1
|
||||
className="Box-root Typography-root Typography-heading1 Typography-colorTextPrimary"
|
||||
>
|
||||
The specified link is invalid, check to see if it was copied correctly.
|
||||
</span>
|
||||
Oops Sorry!
|
||||
</h1>
|
||||
<div
|
||||
className="CallOut-root CallOut-colorError CallOut-fullWidth"
|
||||
>
|
||||
<div>
|
||||
<span
|
||||
data-testid="invalid-link"
|
||||
>
|
||||
The specified link is invalid, check to see if it was copied correctly.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,97 +10,110 @@ exports[`renders form 1`] = `
|
||||
<div
|
||||
className="MainLayout-centered"
|
||||
>
|
||||
<div>
|
||||
<form
|
||||
autoComplete="off"
|
||||
onSubmit={[Function]}
|
||||
<div
|
||||
className="ResetRoute-container"
|
||||
>
|
||||
<div
|
||||
className="ResetRoute-root"
|
||||
>
|
||||
<div
|
||||
className="Box-root HorizontalGutter-root HorizontalGutter-double"
|
||||
>
|
||||
<div
|
||||
className="Box-root HorizontalGutter-root HorizontalGutter-full"
|
||||
>
|
||||
<h1
|
||||
className="Box-root Typography-root Typography-heading1 Typography-colorTextPrimary"
|
||||
>
|
||||
Reset your password
|
||||
</h1>
|
||||
<p
|
||||
className="Box-root Typography-root Typography-bodyCopy Typography-colorTextPrimary"
|
||||
>
|
||||
Please enter a new password to use to sign in to your account.
|
||||
Make sure it is unique and be sure to keep it secure.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className="Box-root HorizontalGutter-root HorizontalGutter-full"
|
||||
<div>
|
||||
<form
|
||||
autoComplete="off"
|
||||
onSubmit={[Function]}
|
||||
>
|
||||
<div
|
||||
className="Box-root HorizontalGutter-root FormField-root HorizontalGutter-half"
|
||||
className="Box-root HorizontalGutter-root HorizontalGutter-double"
|
||||
>
|
||||
<label
|
||||
className="Box-root Typography-root Typography-inputLabel Typography-colorTextPrimary InputLabel-root"
|
||||
htmlFor="password"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<p
|
||||
className="Box-root Typography-root Typography-detail Typography-colorTextSecondary"
|
||||
>
|
||||
Must be at least 8 characters
|
||||
</p>
|
||||
<div
|
||||
className="PasswordField-fullWidth PasswordField-root"
|
||||
className="Box-root HorizontalGutter-root HorizontalGutter-full"
|
||||
>
|
||||
<h1
|
||||
className="Box-root Typography-root Typography-heading1 Typography-colorTextPrimary"
|
||||
>
|
||||
Reset your password
|
||||
</h1>
|
||||
<p
|
||||
className="Box-root Typography-root Typography-bodyCopy Typography-colorTextPrimary"
|
||||
>
|
||||
Please enter a new password to use to sign in to your account.
|
||||
Make sure it is unique and be sure to keep it secure.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className="Box-root HorizontalGutter-root HorizontalGutter-full"
|
||||
>
|
||||
<div
|
||||
className="PasswordField-wrapper"
|
||||
className="Box-root HorizontalGutter-root FormField-root HorizontalGutter-half"
|
||||
>
|
||||
<input
|
||||
autoCapitalize="off"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
className="PasswordField-colorRegular PasswordField-fullWidth PasswordField-input"
|
||||
id="password"
|
||||
name="password"
|
||||
onChange={[Function]}
|
||||
placeholder="Password"
|
||||
spellCheck={false}
|
||||
type="password"
|
||||
value=""
|
||||
/>
|
||||
<div
|
||||
className="PasswordField-icon"
|
||||
onClick={[Function]}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
title="Hide password"
|
||||
<label
|
||||
className="Box-root Typography-root Typography-inputLabel Typography-colorTextPrimary InputLabel-root"
|
||||
htmlFor="password"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="Icon-root Icon-sm"
|
||||
Password
|
||||
</label>
|
||||
<p
|
||||
className="Box-root Typography-root Typography-fieldDescription Typography-colorTextSecondary"
|
||||
>
|
||||
Must be at least 8 characters
|
||||
</p>
|
||||
<div
|
||||
className="PasswordField-fullWidth PasswordField-root"
|
||||
>
|
||||
<div
|
||||
className="PasswordField-wrapper"
|
||||
>
|
||||
visibility
|
||||
</span>
|
||||
<input
|
||||
autoCapitalize="off"
|
||||
autoComplete="new-password"
|
||||
autoCorrect="off"
|
||||
className="PasswordField-colorRegular PasswordField-fullWidth PasswordField-input"
|
||||
disabled={false}
|
||||
id="password"
|
||||
name="password"
|
||||
onBlur={[Function]}
|
||||
onChange={[Function]}
|
||||
onFocus={[Function]}
|
||||
placeholder="Password"
|
||||
spellCheck={false}
|
||||
type="password"
|
||||
value=""
|
||||
/>
|
||||
<div
|
||||
className="PasswordField-icon"
|
||||
onClick={[Function]}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
title="Hide password"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
className="Icon-root Icon-sm"
|
||||
>
|
||||
visibility
|
||||
</i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="BaseButton-root Button-root Button-sizeRegular Button-colorPrimary Button-variantFilled Button-fullWidth"
|
||||
data-color="primary"
|
||||
data-variant="filled"
|
||||
disabled={false}
|
||||
onBlur={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="submit"
|
||||
>
|
||||
Reset Password
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="BaseButton-root Button-root Button-sizeRegular Button-colorPrimary Button-variantFilled Button-fullWidth"
|
||||
disabled={false}
|
||||
onBlur={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="submit"
|
||||
>
|
||||
Reset Password
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -117,22 +130,30 @@ exports[`renders missing reset token 1`] = `
|
||||
className="MainLayout-centered"
|
||||
>
|
||||
<div
|
||||
className="Box-root HorizontalGutter-root HorizontalGutter-double"
|
||||
className="ResetRoute-container"
|
||||
>
|
||||
<h1
|
||||
className="Box-root Typography-root Typography-heading1 Typography-colorTextPrimary"
|
||||
>
|
||||
Oops Sorry!
|
||||
</h1>
|
||||
<div
|
||||
className="CallOut-root CallOut-colorError CallOut-fullWidth"
|
||||
className="ResetRoute-root"
|
||||
>
|
||||
<div>
|
||||
<span
|
||||
data-testid="invalid-link"
|
||||
<div
|
||||
className="Box-root HorizontalGutter-root HorizontalGutter-double"
|
||||
>
|
||||
<h1
|
||||
className="Box-root Typography-root Typography-heading1 Typography-colorTextPrimary"
|
||||
>
|
||||
The specified link is invalid, check to see if it was copied correctly.
|
||||
</span>
|
||||
Oops Sorry!
|
||||
</h1>
|
||||
<div
|
||||
className="CallOut-root CallOut-colorError CallOut-fullWidth"
|
||||
>
|
||||
<div>
|
||||
<span
|
||||
data-testid="invalid-link"
|
||||
>
|
||||
The specified link is invalid, check to see if it was copied correctly.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`renders form 1`] = `
|
||||
<div
|
||||
data-testid="main-layout"
|
||||
>
|
||||
<div
|
||||
className="MainLayout-bar"
|
||||
/>
|
||||
<div
|
||||
className="MainLayout-centered"
|
||||
>
|
||||
<div
|
||||
className="UnsubscribeRoute-container"
|
||||
>
|
||||
<div
|
||||
className="UnsubscribeRoute-root"
|
||||
>
|
||||
<div
|
||||
data-testid="unsubscribe-form"
|
||||
>
|
||||
<form
|
||||
onSubmit={[Function]}
|
||||
>
|
||||
<div
|
||||
className="Box-root HorizontalGutter-root HorizontalGutter-full"
|
||||
>
|
||||
<h1
|
||||
className="Box-root Typography-root Typography-heading1 Typography-colorTextPrimary"
|
||||
>
|
||||
Click below to confirm that you want to unsubscribe from all notifications.
|
||||
</h1>
|
||||
<button
|
||||
className="BaseButton-root Button-root Button-sizeRegular Button-colorPrimary Button-variantFilled Button-fullWidth"
|
||||
data-color="primary"
|
||||
data-variant="filled"
|
||||
disabled={false}
|
||||
onBlur={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseOut={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
onTouchEnd={[Function]}
|
||||
type="submit"
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`renders missing confirm token 1`] = `
|
||||
<div
|
||||
data-testid="main-layout"
|
||||
>
|
||||
<div
|
||||
className="MainLayout-bar"
|
||||
/>
|
||||
<div
|
||||
className="MainLayout-centered"
|
||||
>
|
||||
<div
|
||||
className="UnsubscribeRoute-container"
|
||||
>
|
||||
<div
|
||||
className="UnsubscribeRoute-root"
|
||||
>
|
||||
<div
|
||||
className="Box-root HorizontalGutter-root HorizontalGutter-double"
|
||||
>
|
||||
<h1
|
||||
className="Box-root Typography-root Typography-heading1 Typography-colorTextPrimary"
|
||||
>
|
||||
Oops Sorry!
|
||||
</h1>
|
||||
<div
|
||||
className="CallOut-root CallOut-colorError CallOut-fullWidth"
|
||||
>
|
||||
<div>
|
||||
<span
|
||||
data-testid="invalid-link"
|
||||
>
|
||||
The specified link is invalid, check to see if it was copied correctly.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -0,0 +1,134 @@
|
||||
import sinon from "sinon";
|
||||
|
||||
import { ERROR_CODES } from "coral-common/errors";
|
||||
import { InvalidRequestError } from "coral-framework/lib/errors";
|
||||
import { GQLResolver } from "coral-framework/schema";
|
||||
import {
|
||||
act,
|
||||
createAccessToken,
|
||||
CreateTestRendererParams,
|
||||
replaceHistoryLocation,
|
||||
waitForElement,
|
||||
within,
|
||||
} from "coral-framework/testHelpers";
|
||||
|
||||
import create from "./create";
|
||||
|
||||
const token = createAccessToken();
|
||||
|
||||
async function createTestRenderer(
|
||||
params: CreateTestRendererParams<GQLResolver> = {}
|
||||
) {
|
||||
const { testRenderer, context } = create();
|
||||
return {
|
||||
context,
|
||||
testRenderer,
|
||||
root: testRenderer.root,
|
||||
};
|
||||
}
|
||||
|
||||
it("renders missing confirm token", async () => {
|
||||
replaceHistoryLocation("http://localhost/account/notifications/unsubscribe");
|
||||
const { root } = await createTestRenderer();
|
||||
await waitForElement(() => within(root).getByTestID("invalid-link"));
|
||||
expect(within(root).toJSON()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders form", async () => {
|
||||
replaceHistoryLocation(
|
||||
`http://localhost/account/notifications/unsubscribe#unsubscribeToken=${token}`
|
||||
);
|
||||
const { root, context } = await createTestRenderer();
|
||||
|
||||
const restMock = sinon.mock(context.rest);
|
||||
restMock
|
||||
.expects("fetch")
|
||||
.withArgs("/account/notifications/unsubscribe", {
|
||||
method: "GET",
|
||||
token,
|
||||
})
|
||||
.once();
|
||||
|
||||
await act(async () => {
|
||||
await waitForElement(() => within(root).getByTestID("unsubscribe-form"));
|
||||
});
|
||||
expect(within(root).toJSON()).toMatchSnapshot();
|
||||
restMock.verify();
|
||||
});
|
||||
|
||||
it("renders error from server", async () => {
|
||||
replaceHistoryLocation(
|
||||
`http://localhost/account/notifications/unsubscribe#unsubscribeToken=${token}`
|
||||
);
|
||||
|
||||
const codes = [
|
||||
ERROR_CODES.RATE_LIMIT_EXCEEDED,
|
||||
ERROR_CODES.INTEGRATION_DISABLED,
|
||||
ERROR_CODES.USER_NOT_FOUND,
|
||||
ERROR_CODES.TOKEN_INVALID,
|
||||
];
|
||||
|
||||
for (const code of codes) {
|
||||
const { root, context } = await createTestRenderer();
|
||||
|
||||
const restMock = sinon.mock(context.rest);
|
||||
restMock
|
||||
.expects("fetch")
|
||||
.withArgs("/account/notifications/unsubscribe", {
|
||||
method: "GET",
|
||||
token,
|
||||
})
|
||||
.once()
|
||||
.throwsException(
|
||||
new InvalidRequestError({
|
||||
code,
|
||||
})
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await waitForElement(() =>
|
||||
within(root).getByText(code, {
|
||||
exact: false,
|
||||
})
|
||||
);
|
||||
});
|
||||
restMock.verify();
|
||||
}
|
||||
});
|
||||
|
||||
it("submits form", async () => {
|
||||
replaceHistoryLocation(
|
||||
`http://localhost/account/notifications/unsubscribe#unsubscribeToken=${token}`
|
||||
);
|
||||
const { root, context } = await createTestRenderer();
|
||||
|
||||
const restMock = sinon.mock(context.rest);
|
||||
restMock
|
||||
.expects("fetch")
|
||||
.withArgs("/account/notifications/unsubscribe", {
|
||||
method: "GET",
|
||||
token,
|
||||
})
|
||||
.once();
|
||||
|
||||
restMock
|
||||
.expects("fetch")
|
||||
.withArgs("/account/notifications/unsubscribe", {
|
||||
method: "DELETE",
|
||||
token,
|
||||
})
|
||||
.once();
|
||||
|
||||
await act(async () => {
|
||||
await waitForElement(() => within(root).getByTestID("unsubscribe-form"));
|
||||
});
|
||||
const form = within(root).getByType("form");
|
||||
|
||||
// Submit valid form.
|
||||
await act(async () => {
|
||||
form.props.onSubmit();
|
||||
await waitForElement(() => within(root).getByTestID("success"));
|
||||
});
|
||||
|
||||
restMock.verify();
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { FunctionComponent } from "react";
|
||||
|
||||
import { PropTypesOf } from "coral-framework/types";
|
||||
import { Logo } from "coral-ui/components";
|
||||
import { LogoHorizontal } from "coral-ui/components";
|
||||
import { AppBar, Begin, Divider, End } from "coral-ui/components/AppBar";
|
||||
|
||||
import { DecisionHistoryButton } from "./DecisionHistory";
|
||||
@@ -22,8 +22,7 @@ const Main: FunctionComponent<Props> = ({ children, viewer }) => (
|
||||
<AppBar gutterBegin gutterEnd>
|
||||
<Begin itemGutter="double">
|
||||
<div className={styles.logoContainer}>
|
||||
<Logo />
|
||||
<Version />
|
||||
<LogoHorizontal />
|
||||
</div>
|
||||
<NavigationContainer viewer={viewer} />
|
||||
</Begin>
|
||||
@@ -34,6 +33,7 @@ const Main: FunctionComponent<Props> = ({ children, viewer }) => (
|
||||
</End>
|
||||
</AppBar>
|
||||
{children}
|
||||
<Version />
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
.version {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 23px;
|
||||
font-size: calc(10rem / var(--rem-base));
|
||||
color: var(--palette-text-secondary);
|
||||
text-align: center;
|
||||
padding-bottom: var(--spacing-3);
|
||||
}
|
||||
|
||||
@@ -14,8 +14,7 @@ exports[`renders correctly 1`] = `
|
||||
<div
|
||||
className="Main-logoContainer"
|
||||
>
|
||||
<withPropsOnChange(Logo) />
|
||||
<Version />
|
||||
<withPropsOnChange(LogoHorizontal) />
|
||||
</div>
|
||||
<withContext(createMutationContainer(Relay(NavigationContainer)))
|
||||
viewer={Object {}}
|
||||
@@ -30,5 +29,6 @@ exports[`renders correctly 1`] = `
|
||||
</withPropsOnChange(End)>
|
||||
</withPropsOnChange(AppBar)>
|
||||
child
|
||||
<Version />
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { FunctionComponent } from "react";
|
||||
|
||||
import {
|
||||
BrandIcon,
|
||||
BrandMark,
|
||||
BrandName,
|
||||
Flex,
|
||||
HorizontalGutter,
|
||||
@@ -22,7 +22,7 @@ const AuthBox: FunctionComponent<Props> = ({ title, children }) => {
|
||||
<HorizontalGutter className={styles.container} size="double">
|
||||
<Flex justifyContent="center">
|
||||
<div className={styles.brandIcon}>
|
||||
<BrandIcon size="lg" />
|
||||
<BrandMark size="lg" />
|
||||
</div>
|
||||
</Flex>
|
||||
<div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
.root {
|
||||
border: 1px solid var(--palette-success-main);
|
||||
box-sizing: border-box;
|
||||
border-radius: 2px;
|
||||
border-radius: var(--round-corners);
|
||||
width: 65px;
|
||||
height: 50px;
|
||||
display: flex;
|
||||
|
||||
@@ -14,7 +14,7 @@ const purify = createPurify(window, false);
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
children: string;
|
||||
children: string | React.ReactElement;
|
||||
suspectWords: ReadonlyArray<string>;
|
||||
bannedWords: ReadonlyArray<string>;
|
||||
}
|
||||
@@ -117,25 +117,29 @@ const CommentContent: FunctionComponent<Props> = ({
|
||||
className,
|
||||
children,
|
||||
}) => {
|
||||
// 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 (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) {
|
||||
// Then we traverse it recursively and manipulate it to highlight suspect words
|
||||
// and banned words.
|
||||
markHTMLNode(node, suspectWords, bannedWords);
|
||||
if (suspectWords.length || bannedWords.length) {
|
||||
// Then we traverse it recursively and manipulate it to highlight suspect words
|
||||
// and banned words.
|
||||
markHTMLNode(node, suspectWords, bannedWords);
|
||||
}
|
||||
|
||||
// Finally we render the content of the Shadow DOM Tree
|
||||
return (
|
||||
<Typography
|
||||
className={cn(className, styles.root)}
|
||||
dangerouslySetInnerHTML={{ __html: node.innerHTML }}
|
||||
container="div"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Finally we render the content of the Shadow DOM Tree
|
||||
return (
|
||||
<Typography
|
||||
className={cn(className, styles.root)}
|
||||
dangerouslySetInnerHTML={{ __html: node.innerHTML }}
|
||||
container="div"
|
||||
/>
|
||||
);
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
export default CommentContent;
|
||||
|
||||
@@ -9,10 +9,12 @@ import styles from "./FeatureButton.css";
|
||||
|
||||
interface Props extends PropTypesOf<typeof BaseButton> {
|
||||
featured: boolean;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
const FeatureButton: FunctionComponent<Props> = ({
|
||||
featured,
|
||||
enabled = true,
|
||||
className,
|
||||
...rest
|
||||
}) => (
|
||||
@@ -21,6 +23,7 @@ const FeatureButton: FunctionComponent<Props> = ({
|
||||
className={cn(className, styles.root, {
|
||||
[styles.invert]: featured,
|
||||
})}
|
||||
disabled={!enabled}
|
||||
>
|
||||
{featured ? (
|
||||
<Localized id="moderate-comment-featuredText">
|
||||
|
||||
@@ -17,15 +17,17 @@ import ToxicityLabel from "./ToxicityLabel";
|
||||
interface Props {
|
||||
comment: FlagDetailsContainer_comment;
|
||||
settings: FlagDetailsContainer_settings;
|
||||
onUsernameClick: (id?: string) => void;
|
||||
}
|
||||
|
||||
const FlagDetailsContainer: FunctionComponent<Props> = ({
|
||||
comment: {
|
||||
revision: { metadata },
|
||||
flags: { nodes },
|
||||
},
|
||||
comment,
|
||||
onUsernameClick,
|
||||
settings,
|
||||
}) => {
|
||||
const metadata = comment.revision ? comment.revision.metadata : null;
|
||||
const nodes = comment.flags.nodes;
|
||||
|
||||
const offensive = nodes.filter(
|
||||
({ reason }) => reason === GQLCOMMENT_FLAG_REASON.COMMENT_REPORTED_OFFENSIVE
|
||||
);
|
||||
@@ -35,7 +37,7 @@ const FlagDetailsContainer: FunctionComponent<Props> = ({
|
||||
|
||||
return (
|
||||
<HorizontalGutter size="oneAndAHalf">
|
||||
{metadata.perspective && (
|
||||
{metadata && metadata.perspective && (
|
||||
<FlagDetailsCategory
|
||||
category={
|
||||
<Localized id="moderate-flagDetails-toxicityScore">
|
||||
@@ -63,6 +65,9 @@ const FlagDetailsContainer: FunctionComponent<Props> = ({
|
||||
{offensive.map((flag, i) => (
|
||||
<FlagDetailsEntry
|
||||
key={i}
|
||||
onClick={() =>
|
||||
flag.flagger ? onUsernameClick(flag.flagger.id) : null
|
||||
}
|
||||
user={flag.flagger ? flag.flagger.username : <NotAvailable />}
|
||||
details={flag.additionalDetails}
|
||||
/>
|
||||
@@ -79,6 +84,9 @@ const FlagDetailsContainer: FunctionComponent<Props> = ({
|
||||
>
|
||||
{spam.map((flag, i) => (
|
||||
<FlagDetailsEntry
|
||||
onClick={() =>
|
||||
flag.flagger ? onUsernameClick(flag.flagger.id) : null
|
||||
}
|
||||
key={i}
|
||||
user={flag.flagger ? flag.flagger.username : <NotAvailable />}
|
||||
details={flag.additionalDetails}
|
||||
@@ -97,6 +105,7 @@ const enhanced = withFragmentContainer<Props>({
|
||||
nodes {
|
||||
flagger {
|
||||
username
|
||||
id
|
||||
}
|
||||
reason
|
||||
additionalDetails
|
||||
|
||||
@@ -16,3 +16,22 @@
|
||||
color: var(--palette-text-primary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.flagger {
|
||||
margin-right: var(--mini-unit);
|
||||
padding: var(--spacing-1);
|
||||
margin-left: calc(-1 * var(--spacing-1));
|
||||
line-height: calc(16rem / var(--rem-base));
|
||||
|
||||
&:hover {
|
||||
background-color: var(--palette-grey-lightest);
|
||||
border-radius: var(--round-corners);
|
||||
border-style: none;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: var(--palette-grey-lighter);
|
||||
border-radius: var(--round-corners);
|
||||
border-style: none;
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,35 @@
|
||||
import React, { FunctionComponent } from "react";
|
||||
|
||||
import { BaseButton } from "coral-ui/components";
|
||||
import styles from "./FlagDetailsEntry.css";
|
||||
|
||||
interface Props {
|
||||
user: React.ReactNode;
|
||||
details?: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
const FlagDetailsEntry: FunctionComponent<Props> = ({ user, details }) => {
|
||||
const FlagDetailsEntry: FunctionComponent<Props> = ({
|
||||
user,
|
||||
details,
|
||||
onClick,
|
||||
}) => {
|
||||
return (
|
||||
<div>
|
||||
<span className={styles.user}>
|
||||
{user}
|
||||
{details && ":"}
|
||||
</span>
|
||||
{onClick && (
|
||||
<BaseButton className={styles.flagger} onClick={onClick}>
|
||||
<span className={styles.user}>
|
||||
{user}
|
||||
{details && ":"}
|
||||
</span>
|
||||
</BaseButton>
|
||||
)}
|
||||
{!onClick && (
|
||||
<span className={styles.user}>
|
||||
{user}
|
||||
{details && ":"}
|
||||
</span>
|
||||
)}
|
||||
{details && <span className={styles.details}>{details}</span>}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -7,3 +7,7 @@
|
||||
.username {
|
||||
color: var(--palette-grey-main);
|
||||
}
|
||||
|
||||
.usernameButton {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ it("renders all markers", () => {
|
||||
reasons: {
|
||||
COMMENT_DETECTED_TOXIC: 1,
|
||||
COMMENT_DETECTED_SPAM: 1,
|
||||
COMMENT_DETECTED_TRUST: 1,
|
||||
COMMENT_DETECTED_RECENT_HISTORY: 1,
|
||||
COMMENT_DETECTED_LINKS: 1,
|
||||
COMMENT_DETECTED_BANNED_WORD: 1,
|
||||
COMMENT_DETECTED_SUSPECT_WORD: 1,
|
||||
@@ -42,6 +42,7 @@ it("renders all markers", () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
onUsernameClick: () => null,
|
||||
};
|
||||
const renderer = createRenderer();
|
||||
renderer.render(<MarkersContainerN {...props} />);
|
||||
@@ -58,7 +59,7 @@ it("renders some markers", () => {
|
||||
reasons: {
|
||||
COMMENT_DETECTED_TOXIC: 1,
|
||||
COMMENT_DETECTED_SPAM: 0,
|
||||
COMMENT_DETECTED_TRUST: 1,
|
||||
COMMENT_DETECTED_RECENT_HISTORY: 1,
|
||||
COMMENT_DETECTED_LINKS: 0,
|
||||
COMMENT_DETECTED_BANNED_WORD: 1,
|
||||
COMMENT_DETECTED_SUSPECT_WORD: 0,
|
||||
@@ -81,6 +82,7 @@ it("renders some markers", () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
onUsernameClick: () => null,
|
||||
};
|
||||
const renderer = createRenderer();
|
||||
renderer.render(<MarkersContainerN {...props} />);
|
||||
|
||||
@@ -11,15 +11,16 @@ import Markers from "./Markers";
|
||||
|
||||
interface MarkersContainerProps {
|
||||
comment: MarkersContainer_comment;
|
||||
onUsernameClick: (id?: string) => void;
|
||||
settings: MarkersContainer_settings;
|
||||
}
|
||||
|
||||
function hasDetails(c: MarkersContainer_comment) {
|
||||
return (
|
||||
c.revision.actionCounts.flag.reasons.COMMENT_REPORTED_OFFENSIVE +
|
||||
c.revision.actionCounts.flag.reasons.COMMENT_REPORTED_SPAM >
|
||||
0 || c.revision.metadata.perspective
|
||||
);
|
||||
return c.revision
|
||||
? c.revision.actionCounts.flag.reasons.COMMENT_REPORTED_OFFENSIVE +
|
||||
c.revision.actionCounts.flag.reasons.COMMENT_REPORTED_SPAM >
|
||||
0 || c.revision.metadata.perspective
|
||||
: false;
|
||||
}
|
||||
|
||||
let keyCounter = 0;
|
||||
@@ -34,72 +35,80 @@ const markers: Array<
|
||||
)) ||
|
||||
null,
|
||||
c =>
|
||||
(c.revision.actionCounts.flag.reasons.COMMENT_DETECTED_LINKS && (
|
||||
<Localized id="moderate-marker-link" key={keyCounter++}>
|
||||
<Marker color="primary">Link</Marker>
|
||||
</Localized>
|
||||
)) ||
|
||||
(c.revision &&
|
||||
c.revision.actionCounts.flag.reasons.COMMENT_DETECTED_LINKS && (
|
||||
<Localized id="moderate-marker-link" key={keyCounter++}>
|
||||
<Marker color="primary">Link</Marker>
|
||||
</Localized>
|
||||
)) ||
|
||||
null,
|
||||
c =>
|
||||
(c.revision.actionCounts.flag.reasons.COMMENT_DETECTED_BANNED_WORD && (
|
||||
<Localized id="moderate-marker-bannedWord" key={keyCounter++}>
|
||||
<Marker color="error">Banned Word</Marker>
|
||||
</Localized>
|
||||
)) ||
|
||||
(c.revision &&
|
||||
c.revision.actionCounts.flag.reasons.COMMENT_DETECTED_BANNED_WORD && (
|
||||
<Localized id="moderate-marker-bannedWord" key={keyCounter++}>
|
||||
<Marker color="error">Banned Word</Marker>
|
||||
</Localized>
|
||||
)) ||
|
||||
null,
|
||||
c =>
|
||||
(c.revision.actionCounts.flag.reasons.COMMENT_DETECTED_SUSPECT_WORD && (
|
||||
<Localized id="moderate-marker-suspectWord" key={keyCounter++}>
|
||||
<Marker color="error" variant="filled">
|
||||
Suspect Word
|
||||
(c.revision &&
|
||||
c.revision.actionCounts.flag.reasons.COMMENT_DETECTED_SUSPECT_WORD && (
|
||||
<Localized id="moderate-marker-suspectWord" key={keyCounter++}>
|
||||
<Marker color="error" variant="filled">
|
||||
Suspect Word
|
||||
</Marker>
|
||||
</Localized>
|
||||
)) ||
|
||||
null,
|
||||
c =>
|
||||
(c.revision &&
|
||||
c.revision.actionCounts.flag.reasons.COMMENT_DETECTED_SPAM && (
|
||||
<Localized id="moderate-marker-spam" key={keyCounter++}>
|
||||
<Marker color="error">Spam</Marker>
|
||||
</Localized>
|
||||
)) ||
|
||||
null,
|
||||
c =>
|
||||
(c.revision &&
|
||||
c.revision.actionCounts.flag.reasons.COMMENT_DETECTED_TOXIC && (
|
||||
<Localized id="moderate-marker-toxic" key={keyCounter++}>
|
||||
<Marker color="error">Toxic</Marker>
|
||||
</Localized>
|
||||
)) ||
|
||||
null,
|
||||
c =>
|
||||
(c.revision &&
|
||||
c.revision.actionCounts.flag.reasons.COMMENT_DETECTED_RECENT_HISTORY && (
|
||||
<Localized id="moderate-marker-recentHistory" key={keyCounter++}>
|
||||
<Marker color="error">Recent History</Marker>
|
||||
</Localized>
|
||||
)) ||
|
||||
null,
|
||||
c =>
|
||||
(c.revision &&
|
||||
c.revision.actionCounts.flag.reasons.COMMENT_REPORTED_OFFENSIVE && (
|
||||
<Marker key={keyCounter++} color="error">
|
||||
<Localized id="moderate-marker-offensive">
|
||||
<span>Offensive</span>
|
||||
</Localized>{" "}
|
||||
<MarkerCount>
|
||||
{c.revision.actionCounts.flag.reasons.COMMENT_REPORTED_OFFENSIVE}
|
||||
</MarkerCount>
|
||||
</Marker>
|
||||
</Localized>
|
||||
)) ||
|
||||
)) ||
|
||||
null,
|
||||
c =>
|
||||
(c.revision.actionCounts.flag.reasons.COMMENT_DETECTED_SPAM && (
|
||||
<Localized id="moderate-marker-spam" key={keyCounter++}>
|
||||
<Marker color="error">Spam</Marker>
|
||||
</Localized>
|
||||
)) ||
|
||||
null,
|
||||
c =>
|
||||
(c.revision.actionCounts.flag.reasons.COMMENT_DETECTED_TOXIC && (
|
||||
<Localized id="moderate-marker-toxic" key={keyCounter++}>
|
||||
<Marker color="error">Toxic</Marker>
|
||||
</Localized>
|
||||
)) ||
|
||||
null,
|
||||
c =>
|
||||
(c.revision.actionCounts.flag.reasons.COMMENT_DETECTED_TRUST && (
|
||||
<Localized id="moderate-marker-karma" key={keyCounter++}>
|
||||
<Marker color="error">Karma</Marker>
|
||||
</Localized>
|
||||
)) ||
|
||||
null,
|
||||
c =>
|
||||
(c.revision.actionCounts.flag.reasons.COMMENT_REPORTED_OFFENSIVE && (
|
||||
<Marker key={keyCounter++} color="error">
|
||||
<Localized id="moderate-marker-offensive">
|
||||
<span>Offensive</span>
|
||||
</Localized>{" "}
|
||||
<MarkerCount>
|
||||
{c.revision.actionCounts.flag.reasons.COMMENT_REPORTED_OFFENSIVE}
|
||||
</MarkerCount>
|
||||
</Marker>
|
||||
)) ||
|
||||
null,
|
||||
c =>
|
||||
(c.revision.actionCounts.flag.reasons.COMMENT_REPORTED_SPAM && (
|
||||
<Marker key={keyCounter++} color="error">
|
||||
<Localized id="moderate-marker-spam">
|
||||
<span>Spam</span>
|
||||
</Localized>{" "}
|
||||
<MarkerCount>
|
||||
{c.revision.actionCounts.flag.reasons.COMMENT_REPORTED_SPAM}
|
||||
</MarkerCount>
|
||||
</Marker>
|
||||
)) ||
|
||||
(c.revision &&
|
||||
c.revision.actionCounts.flag.reasons.COMMENT_REPORTED_SPAM && (
|
||||
<Marker key={keyCounter++} color="error">
|
||||
<Localized id="moderate-marker-spam">
|
||||
<span>Spam</span>
|
||||
</Localized>{" "}
|
||||
<MarkerCount>
|
||||
{c.revision.actionCounts.flag.reasons.COMMENT_REPORTED_SPAM}
|
||||
</MarkerCount>
|
||||
</Marker>
|
||||
)) ||
|
||||
null,
|
||||
];
|
||||
|
||||
@@ -116,6 +125,7 @@ export class MarkersContainer extends React.Component<MarkersContainerProps> {
|
||||
details={
|
||||
doesHaveDetails ? (
|
||||
<FlagDetailsContainer
|
||||
onUsernameClick={this.props.onUsernameClick}
|
||||
comment={this.props.comment}
|
||||
settings={this.props.settings}
|
||||
/>
|
||||
@@ -139,7 +149,7 @@ const enhanced = withFragmentContainer<MarkersContainerProps>({
|
||||
reasons {
|
||||
COMMENT_DETECTED_TOXIC
|
||||
COMMENT_DETECTED_SPAM
|
||||
COMMENT_DETECTED_TRUST
|
||||
COMMENT_DETECTED_RECENT_HISTORY
|
||||
COMMENT_DETECTED_LINKS
|
||||
COMMENT_DETECTED_BANNED_WORD
|
||||
COMMENT_DETECTED_SUSPECT_WORD
|
||||
|
||||
@@ -15,13 +15,13 @@
|
||||
|
||||
&:hover {
|
||||
background-color: var(--palette-grey-lightest);
|
||||
border-radius: 2px;
|
||||
border-radius: var(--round-corners);
|
||||
border-style: none;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: var(--palette-grey-lighter);
|
||||
border-radius: 2px;
|
||||
border-radius: var(--round-corners);
|
||||
border-style: none;
|
||||
}
|
||||
}
|
||||
@@ -58,8 +58,8 @@
|
||||
}
|
||||
|
||||
.asideMiniWithReplyTo {
|
||||
/*
|
||||
We have reply to field, so we align below the fattest
|
||||
/*
|
||||
We have reply to field, so we align below the fattest
|
||||
element plus the reply to field.
|
||||
*/
|
||||
padding-top: calc(2 * (14rem / var(--rem-base)) + 5px + var(--spacing-4));
|
||||
@@ -151,4 +151,8 @@
|
||||
.miniButton {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
.deleted {
|
||||
background: var(--palette-grey-lightest);
|
||||
}
|
||||
|
||||
@@ -42,7 +42,10 @@ it("renders correctly", () => {
|
||||
it("renders reply correctly", () => {
|
||||
const props: PropTypesOf<typeof ModerateCardN> = {
|
||||
...baseProps,
|
||||
inReplyTo: "Julian",
|
||||
inReplyTo: {
|
||||
username: "Julian",
|
||||
id: "1234",
|
||||
},
|
||||
};
|
||||
const renderer = createRenderer();
|
||||
renderer.render(<ModerateCardN {...props} />);
|
||||
@@ -91,3 +94,13 @@ it("renders story info", () => {
|
||||
renderer.render(<ModerateCardN {...props} />);
|
||||
expect(renderer.getRenderOutput()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders tombstoned when comment is deleted", () => {
|
||||
const props: PropTypesOf<typeof ModerateCardN> = {
|
||||
...baseProps,
|
||||
deleted: true,
|
||||
};
|
||||
const renderer = createRenderer();
|
||||
renderer.render(<ModerateCardN {...props} />);
|
||||
expect(renderer.getRenderOutput()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import cn from "classnames";
|
||||
import { Localized } from "fluent-react/compat";
|
||||
import React, { FunctionComponent } from "react";
|
||||
import React, { FunctionComponent, useCallback } from "react";
|
||||
|
||||
import { PropTypesOf } from "coral-framework/types";
|
||||
import {
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Flex,
|
||||
HorizontalGutter,
|
||||
TextLink,
|
||||
Typography,
|
||||
} from "coral-ui/components";
|
||||
|
||||
import ApproveButton from "./ApproveButton";
|
||||
@@ -27,7 +28,10 @@ interface Props {
|
||||
username: string;
|
||||
createdAt: string;
|
||||
body: string;
|
||||
inReplyTo: string | null;
|
||||
inReplyTo?: {
|
||||
id: string;
|
||||
username: string | null;
|
||||
} | null;
|
||||
comment: PropTypesOf<typeof MarkersContainer>["comment"];
|
||||
settings: PropTypesOf<typeof MarkersContainer>["settings"];
|
||||
status: "approved" | "rejected" | "undecided";
|
||||
@@ -43,7 +47,7 @@ interface Props {
|
||||
onApprove: () => void;
|
||||
onReject: () => void;
|
||||
onFeature: () => void;
|
||||
onUsernameClick: () => void;
|
||||
onUsernameClick: (id?: string) => void;
|
||||
mini?: boolean;
|
||||
hideUsername?: boolean;
|
||||
/**
|
||||
@@ -52,6 +56,7 @@ interface Props {
|
||||
* reflect that
|
||||
*/
|
||||
dangling?: boolean;
|
||||
deleted?: boolean;
|
||||
}
|
||||
|
||||
const ModerateCard: FunctionComponent<Props> = ({
|
||||
@@ -79,115 +84,154 @@ const ModerateCard: FunctionComponent<Props> = ({
|
||||
moderatedBy,
|
||||
mini = false,
|
||||
hideUsername = false,
|
||||
}) => (
|
||||
<Card
|
||||
className={cn(
|
||||
styles.root,
|
||||
{ [styles.borderless]: mini },
|
||||
{ [styles.dangling]: dangling }
|
||||
)}
|
||||
data-testid={`moderate-comment-${id}`}
|
||||
>
|
||||
<Flex>
|
||||
<div className={styles.mainContainer}>
|
||||
<div
|
||||
className={cn(styles.topBar, {
|
||||
[styles.topBarMini]: mini && !inReplyTo,
|
||||
})}
|
||||
>
|
||||
<Flex alignItems="center">
|
||||
{!hideUsername && (
|
||||
<BaseButton onClick={onUsernameClick} className={styles.username}>
|
||||
<Username>{username}</Username>
|
||||
</BaseButton>
|
||||
)}
|
||||
<Timestamp>{createdAt}</Timestamp>
|
||||
<FeatureButton featured={featured} onClick={onFeature} />
|
||||
</Flex>
|
||||
{inReplyTo && (
|
||||
<div>
|
||||
<InReplyTo>{inReplyTo}</InReplyTo>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<CommentContent
|
||||
suspectWords={suspectWords}
|
||||
bannedWords={bannedWords}
|
||||
className={styles.content}
|
||||
>
|
||||
{body}
|
||||
</CommentContent>
|
||||
<div className={styles.footer}>
|
||||
<HorizontalGutter>
|
||||
<div>
|
||||
<Localized id="moderate-comment-viewContext">
|
||||
<TextLink
|
||||
className={styles.link}
|
||||
href={viewContextHref}
|
||||
target="_blank"
|
||||
deleted = false,
|
||||
}) => {
|
||||
const commentBody = deleted ? (
|
||||
<Localized id="moderate-comment-deleted-body">
|
||||
<Typography>
|
||||
This comment is no longer available. The commenter has deleted their
|
||||
account.
|
||||
</Typography>
|
||||
</Localized>
|
||||
) : (
|
||||
body
|
||||
);
|
||||
const commentAuthorClick = useCallback(() => {
|
||||
onUsernameClick();
|
||||
}, [onUsernameClick]);
|
||||
const commentParentAuthorClick = useCallback(() => {
|
||||
if (inReplyTo) {
|
||||
onUsernameClick(inReplyTo.id);
|
||||
}
|
||||
}, [onUsernameClick, inReplyTo]);
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
styles.root,
|
||||
{ [styles.borderless]: mini },
|
||||
{ [styles.dangling]: dangling },
|
||||
{ [styles.deleted]: deleted }
|
||||
)}
|
||||
data-testid={`moderate-comment-${id}`}
|
||||
>
|
||||
<Flex>
|
||||
<div className={styles.mainContainer}>
|
||||
<div
|
||||
className={cn(styles.topBar, {
|
||||
[styles.topBarMini]: mini && !inReplyTo,
|
||||
})}
|
||||
>
|
||||
<Flex alignItems="center">
|
||||
{!hideUsername && (
|
||||
<BaseButton
|
||||
onClick={commentAuthorClick}
|
||||
className={styles.username}
|
||||
>
|
||||
View Context
|
||||
</TextLink>
|
||||
</Localized>
|
||||
</div>
|
||||
{showStory && (
|
||||
<Username>{username}</Username>
|
||||
</BaseButton>
|
||||
)}
|
||||
<Timestamp>{createdAt}</Timestamp>
|
||||
<FeatureButton
|
||||
featured={featured}
|
||||
onClick={onFeature}
|
||||
enabled={!deleted}
|
||||
/>
|
||||
</Flex>
|
||||
{inReplyTo && inReplyTo.username && (
|
||||
<div>
|
||||
<Localized id="moderate-comment-story">
|
||||
<span className={styles.story}>Story</span>
|
||||
</Localized>
|
||||
{": "}
|
||||
<span className={styles.storyTitle}>{storyTitle}</span>{" "}
|
||||
<Localized id="moderate-comment-moderateStory">
|
||||
<BaseButton
|
||||
onClick={commentParentAuthorClick}
|
||||
className={styles.username}
|
||||
>
|
||||
<InReplyTo>{inReplyTo.username}</InReplyTo>
|
||||
</BaseButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<CommentContent
|
||||
suspectWords={suspectWords}
|
||||
bannedWords={bannedWords}
|
||||
className={styles.content}
|
||||
>
|
||||
{commentBody}
|
||||
</CommentContent>
|
||||
<div className={styles.footer}>
|
||||
<HorizontalGutter>
|
||||
<div>
|
||||
<Localized id="moderate-comment-viewContext">
|
||||
<TextLink
|
||||
className={styles.link}
|
||||
href={storyHref}
|
||||
onClick={onModerateStory}
|
||||
href={viewContextHref}
|
||||
target="_blank"
|
||||
>
|
||||
Moderate Story
|
||||
View Context
|
||||
</TextLink>
|
||||
</Localized>
|
||||
</div>
|
||||
)}
|
||||
<MarkersContainer comment={comment} settings={settings} />
|
||||
</HorizontalGutter>
|
||||
{showStory && (
|
||||
<div>
|
||||
<Localized id="moderate-comment-story">
|
||||
<span className={styles.story}>Story</span>
|
||||
</Localized>
|
||||
{": "}
|
||||
<span className={styles.storyTitle}>{storyTitle}</span>{" "}
|
||||
<Localized id="moderate-comment-moderateStory">
|
||||
<TextLink
|
||||
className={styles.link}
|
||||
href={storyHref}
|
||||
onClick={onModerateStory}
|
||||
>
|
||||
Moderate Story
|
||||
</TextLink>
|
||||
</Localized>
|
||||
</div>
|
||||
)}
|
||||
<MarkersContainer
|
||||
onUsernameClick={onUsernameClick}
|
||||
comment={comment}
|
||||
settings={settings}
|
||||
/>
|
||||
</HorizontalGutter>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(styles.separator, { [styles.ruledSeparator]: !mini })}
|
||||
/>
|
||||
<Flex
|
||||
className={cn(styles.aside, {
|
||||
[styles.asideWithoutReplyTo]: !inReplyTo,
|
||||
[styles.asideMini]: mini && !inReplyTo,
|
||||
[styles.asideMiniWithReplyTo]: mini && inReplyTo,
|
||||
})}
|
||||
alignItems="center"
|
||||
direction="column"
|
||||
itemGutter
|
||||
>
|
||||
{!mini && (
|
||||
<Localized id="moderate-comment-decision">
|
||||
<div className={styles.decision}>DECISION</div>
|
||||
</Localized>
|
||||
)}
|
||||
<Flex itemGutter>
|
||||
<RejectButton
|
||||
onClick={onReject}
|
||||
invert={status === "rejected"}
|
||||
disabled={status === "rejected" || dangling}
|
||||
className={cn({ [styles.miniButton]: mini })}
|
||||
/>
|
||||
<ApproveButton
|
||||
onClick={onApprove}
|
||||
invert={status === "approved"}
|
||||
disabled={status === "approved" || dangling}
|
||||
className={cn({ [styles.miniButton]: mini })}
|
||||
/>
|
||||
<div
|
||||
className={cn(styles.separator, { [styles.ruledSeparator]: !mini })}
|
||||
/>
|
||||
<Flex
|
||||
className={cn(styles.aside, {
|
||||
[styles.asideWithoutReplyTo]: !inReplyTo,
|
||||
[styles.asideMini]: mini && !inReplyTo,
|
||||
[styles.asideMiniWithReplyTo]:
|
||||
mini && inReplyTo && inReplyTo.username,
|
||||
})}
|
||||
alignItems="center"
|
||||
direction="column"
|
||||
itemGutter
|
||||
>
|
||||
{!mini && (
|
||||
<Localized id="moderate-comment-decision">
|
||||
<div className={styles.decision}>DECISION</div>
|
||||
</Localized>
|
||||
)}
|
||||
<Flex itemGutter>
|
||||
<RejectButton
|
||||
onClick={onReject}
|
||||
invert={status === "rejected"}
|
||||
disabled={status === "rejected" || dangling || deleted}
|
||||
className={cn({ [styles.miniButton]: mini })}
|
||||
/>
|
||||
<ApproveButton
|
||||
onClick={onApprove}
|
||||
invert={status === "approved"}
|
||||
disabled={status === "approved" || dangling || deleted}
|
||||
className={cn({ [styles.miniButton]: mini })}
|
||||
/>
|
||||
</Flex>
|
||||
{moderatedBy}
|
||||
</Flex>
|
||||
{moderatedBy}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Card>
|
||||
);
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModerateCard;
|
||||
|
||||
@@ -74,6 +74,10 @@ const ModerateCardContainer: FunctionComponent<Props> = ({
|
||||
onUsernameClicked: usernameClicked,
|
||||
}) => {
|
||||
const handleApprove = useCallback(() => {
|
||||
if (!comment.revision) {
|
||||
return;
|
||||
}
|
||||
|
||||
approveComment({
|
||||
commentID: comment.id,
|
||||
commentRevisionID: comment.revision.id,
|
||||
@@ -82,6 +86,10 @@ const ModerateCardContainer: FunctionComponent<Props> = ({
|
||||
}, [approveComment, comment, match]);
|
||||
|
||||
const handleReject = useCallback(() => {
|
||||
if (!comment.revision) {
|
||||
return;
|
||||
}
|
||||
|
||||
rejectComment({
|
||||
commentID: comment.id,
|
||||
commentRevisionID: comment.revision.id,
|
||||
@@ -90,6 +98,10 @@ const ModerateCardContainer: FunctionComponent<Props> = ({
|
||||
}, [rejectComment, comment, match]);
|
||||
|
||||
const handleFeature = useCallback(() => {
|
||||
if (!comment.revision) {
|
||||
return;
|
||||
}
|
||||
|
||||
featureComment({
|
||||
commentID: comment.id,
|
||||
commentRevisionID: comment.revision.id,
|
||||
@@ -114,12 +126,15 @@ const ModerateCardContainer: FunctionComponent<Props> = ({
|
||||
}
|
||||
}, [comment]);
|
||||
|
||||
const onUsernameClicked = useCallback(() => {
|
||||
if (!usernameClicked) {
|
||||
return;
|
||||
}
|
||||
usernameClicked(comment.author!.id);
|
||||
}, [usernameClicked, comment]);
|
||||
const onUsernameClicked = useCallback(
|
||||
(id?: string) => {
|
||||
if (!usernameClicked) {
|
||||
return;
|
||||
}
|
||||
usernameClicked(id || comment.author!.id);
|
||||
},
|
||||
[usernameClicked, comment]
|
||||
);
|
||||
|
||||
const handleModerateStory = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
@@ -136,10 +151,14 @@ const ModerateCardContainer: FunctionComponent<Props> = ({
|
||||
<FadeInTransition active={Boolean(comment.enteredLive)}>
|
||||
<ModerateCard
|
||||
id={comment.id}
|
||||
username={comment.author!.username!}
|
||||
username={
|
||||
comment.author && comment.author.username
|
||||
? comment.author.username
|
||||
: ""
|
||||
}
|
||||
createdAt={comment.createdAt}
|
||||
body={comment.body!}
|
||||
inReplyTo={comment.parent && comment.parent.author!.username!}
|
||||
inReplyTo={comment.parent && comment.parent.author}
|
||||
comment={comment}
|
||||
settings={settings}
|
||||
dangling={danglingLogic(comment.status)}
|
||||
@@ -153,7 +172,11 @@ const ModerateCardContainer: FunctionComponent<Props> = ({
|
||||
onFeature={onFeature}
|
||||
onUsernameClick={onUsernameClicked}
|
||||
moderatedBy={
|
||||
<ModeratedByContainer viewer={viewer} comment={comment} />
|
||||
<ModeratedByContainer
|
||||
onUsernameClicked={onUsernameClicked}
|
||||
viewer={viewer}
|
||||
comment={comment}
|
||||
/>
|
||||
}
|
||||
showStory={showStoryInfo}
|
||||
storyTitle={
|
||||
@@ -165,6 +188,7 @@ const ModerateCardContainer: FunctionComponent<Props> = ({
|
||||
onModerateStory={handleModerateStory}
|
||||
mini={mini}
|
||||
hideUsername={hideUsername}
|
||||
deleted={comment.deleted ? comment.deleted : false}
|
||||
/>
|
||||
</FadeInTransition>
|
||||
</>
|
||||
@@ -191,6 +215,7 @@ const enhanced = withFragmentContainer<Props>({
|
||||
}
|
||||
parent {
|
||||
author {
|
||||
id
|
||||
username
|
||||
}
|
||||
}
|
||||
@@ -202,6 +227,7 @@ const enhanced = withFragmentContainer<Props>({
|
||||
}
|
||||
permalink
|
||||
enteredLive
|
||||
deleted
|
||||
...MarkersContainer_comment
|
||||
...ModeratedByContainer_comment
|
||||
}
|
||||
|
||||
@@ -1,24 +1,28 @@
|
||||
import { Localized } from "fluent-react/compat";
|
||||
import React from "react";
|
||||
import React, { useCallback } from "react";
|
||||
import { graphql } from "react-relay";
|
||||
|
||||
import { ModeratedByContainer_comment } from "coral-admin/__generated__/ModeratedByContainer_comment.graphql";
|
||||
import { ModeratedByContainer_viewer } from "coral-admin/__generated__/ModeratedByContainer_viewer.graphql";
|
||||
import { withFragmentContainer } from "coral-framework/lib/relay";
|
||||
import { BaseButton } from "coral-ui/components";
|
||||
|
||||
import styles from "./ModeratedByContainer.css";
|
||||
|
||||
interface Props {
|
||||
viewer: ModeratedByContainer_viewer;
|
||||
comment: ModeratedByContainer_comment;
|
||||
onUsernameClicked: (id?: string | null) => void;
|
||||
}
|
||||
|
||||
const ModeratedByContainer: React.FunctionComponent<Props> = ({
|
||||
comment,
|
||||
viewer,
|
||||
onUsernameClicked,
|
||||
}) => {
|
||||
let moderatedBy: React.ReactElement | null;
|
||||
if (!comment.statusLiveUpdated || comment.statusHistory.edges.length === 0) {
|
||||
let id: string | null = null;
|
||||
if (comment.statusHistory.edges.length === 0) {
|
||||
moderatedBy = null;
|
||||
} else if (comment.statusHistory.edges[0].node.moderator === null) {
|
||||
moderatedBy = (
|
||||
@@ -28,19 +32,24 @@ const ModeratedByContainer: React.FunctionComponent<Props> = ({
|
||||
moderatedBy = null;
|
||||
} else {
|
||||
moderatedBy = <>{comment.statusHistory.edges[0].node.moderator.username}</>;
|
||||
id = comment.statusHistory.edges[0].node.moderator.id;
|
||||
}
|
||||
|
||||
if (!moderatedBy) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const onClick = useCallback(() => {
|
||||
onUsernameClicked(id);
|
||||
}, [onUsernameClicked, comment]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<BaseButton onClick={onClick}>
|
||||
<Localized id="moderate-comment-moderatedBy">
|
||||
<div className={styles.moderatedBy}>Moderated By</div>
|
||||
</Localized>
|
||||
<div className={styles.moderatedByUsername}>{moderatedBy}</div>
|
||||
</div>
|
||||
</BaseButton>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
.root {
|
||||
border: 1px solid var(--palette-error-main);
|
||||
box-sizing: border-box;
|
||||
border-radius: 2px;
|
||||
border-radius: var(--round-corners);
|
||||
width: 65px;
|
||||
height: 50px;
|
||||
display: flex;
|
||||
|
||||
+8
-6
@@ -12,10 +12,10 @@ exports[`renders all markers 1`] = `
|
||||
"reasons": Object {
|
||||
"COMMENT_DETECTED_BANNED_WORD": 1,
|
||||
"COMMENT_DETECTED_LINKS": 1,
|
||||
"COMMENT_DETECTED_RECENT_HISTORY": 1,
|
||||
"COMMENT_DETECTED_SPAM": 1,
|
||||
"COMMENT_DETECTED_SUSPECT_WORD": 1,
|
||||
"COMMENT_DETECTED_TOXIC": 1,
|
||||
"COMMENT_DETECTED_TRUST": 1,
|
||||
"COMMENT_REPORTED_OFFENSIVE": 2,
|
||||
"COMMENT_REPORTED_SPAM": 3,
|
||||
},
|
||||
@@ -30,6 +30,7 @@ exports[`renders all markers 1`] = `
|
||||
"status": "PREMOD",
|
||||
}
|
||||
}
|
||||
onUsernameClick={[Function]}
|
||||
settings={
|
||||
Object {
|
||||
"integrations": Object {
|
||||
@@ -98,12 +99,12 @@ exports[`renders all markers 1`] = `
|
||||
</withPropsOnChange(Marker)>
|
||||
</Localized>
|
||||
<Localized
|
||||
id="moderate-marker-karma"
|
||||
id="moderate-marker-recentHistory"
|
||||
>
|
||||
<withPropsOnChange(Marker)
|
||||
color="error"
|
||||
>
|
||||
Karma
|
||||
Recent History
|
||||
</withPropsOnChange(Marker)>
|
||||
</Localized>
|
||||
<withPropsOnChange(Marker)
|
||||
@@ -151,10 +152,10 @@ exports[`renders some markers 1`] = `
|
||||
"reasons": Object {
|
||||
"COMMENT_DETECTED_BANNED_WORD": 1,
|
||||
"COMMENT_DETECTED_LINKS": 0,
|
||||
"COMMENT_DETECTED_RECENT_HISTORY": 1,
|
||||
"COMMENT_DETECTED_SPAM": 0,
|
||||
"COMMENT_DETECTED_SUSPECT_WORD": 0,
|
||||
"COMMENT_DETECTED_TOXIC": 1,
|
||||
"COMMENT_DETECTED_TRUST": 1,
|
||||
"COMMENT_REPORTED_OFFENSIVE": 2,
|
||||
"COMMENT_REPORTED_SPAM": 0,
|
||||
},
|
||||
@@ -169,6 +170,7 @@ exports[`renders some markers 1`] = `
|
||||
"status": "PREMOD",
|
||||
}
|
||||
}
|
||||
onUsernameClick={[Function]}
|
||||
settings={
|
||||
Object {
|
||||
"integrations": Object {
|
||||
@@ -209,12 +211,12 @@ exports[`renders some markers 1`] = `
|
||||
</withPropsOnChange(Marker)>
|
||||
</Localized>
|
||||
<Localized
|
||||
id="moderate-marker-karma"
|
||||
id="moderate-marker-recentHistory"
|
||||
>
|
||||
<withPropsOnChange(Marker)
|
||||
color="error"
|
||||
>
|
||||
Karma
|
||||
Recent History
|
||||
</withPropsOnChange(Marker)>
|
||||
</Localized>
|
||||
<withPropsOnChange(Marker)
|
||||
|
||||
+146
-3
@@ -27,6 +27,7 @@ exports[`renders approved correctly 1`] = `
|
||||
2018-11-29T16:01:51.897Z
|
||||
</Timestamp>
|
||||
<FeatureButton
|
||||
enabled={true}
|
||||
featured={false}
|
||||
onClick={[Function]}
|
||||
/>
|
||||
@@ -66,6 +67,7 @@ exports[`renders approved correctly 1`] = `
|
||||
</div>
|
||||
<Relay(MarkersContainer)
|
||||
comment={Object {}}
|
||||
onUsernameClick={[Function]}
|
||||
settings={Object {}}
|
||||
/>
|
||||
</ForwardRef(forwardRef)>
|
||||
@@ -94,6 +96,7 @@ exports[`renders approved correctly 1`] = `
|
||||
>
|
||||
<RejectButton
|
||||
className=""
|
||||
disabled={false}
|
||||
invert={false}
|
||||
onClick={[Function]}
|
||||
/>
|
||||
@@ -136,6 +139,7 @@ exports[`renders correctly 1`] = `
|
||||
2018-11-29T16:01:51.897Z
|
||||
</Timestamp>
|
||||
<FeatureButton
|
||||
enabled={true}
|
||||
featured={false}
|
||||
onClick={[Function]}
|
||||
/>
|
||||
@@ -175,6 +179,7 @@ exports[`renders correctly 1`] = `
|
||||
</div>
|
||||
<Relay(MarkersContainer)
|
||||
comment={Object {}}
|
||||
onUsernameClick={[Function]}
|
||||
settings={Object {}}
|
||||
/>
|
||||
</ForwardRef(forwardRef)>
|
||||
@@ -203,11 +208,13 @@ exports[`renders correctly 1`] = `
|
||||
>
|
||||
<RejectButton
|
||||
className=""
|
||||
disabled={false}
|
||||
invert={false}
|
||||
onClick={[Function]}
|
||||
/>
|
||||
<ApproveButton
|
||||
className=""
|
||||
disabled={false}
|
||||
invert={false}
|
||||
onClick={[Function]}
|
||||
/>
|
||||
@@ -244,6 +251,7 @@ exports[`renders dangling correctly 1`] = `
|
||||
2018-11-29T16:01:51.897Z
|
||||
</Timestamp>
|
||||
<FeatureButton
|
||||
enabled={true}
|
||||
featured={false}
|
||||
onClick={[Function]}
|
||||
/>
|
||||
@@ -283,6 +291,7 @@ exports[`renders dangling correctly 1`] = `
|
||||
</div>
|
||||
<Relay(MarkersContainer)
|
||||
comment={Object {}}
|
||||
onUsernameClick={[Function]}
|
||||
settings={Object {}}
|
||||
/>
|
||||
</ForwardRef(forwardRef)>
|
||||
@@ -354,6 +363,7 @@ exports[`renders rejected correctly 1`] = `
|
||||
2018-11-29T16:01:51.897Z
|
||||
</Timestamp>
|
||||
<FeatureButton
|
||||
enabled={true}
|
||||
featured={false}
|
||||
onClick={[Function]}
|
||||
/>
|
||||
@@ -393,6 +403,7 @@ exports[`renders rejected correctly 1`] = `
|
||||
</div>
|
||||
<Relay(MarkersContainer)
|
||||
comment={Object {}}
|
||||
onUsernameClick={[Function]}
|
||||
settings={Object {}}
|
||||
/>
|
||||
</ForwardRef(forwardRef)>
|
||||
@@ -427,6 +438,7 @@ exports[`renders rejected correctly 1`] = `
|
||||
/>
|
||||
<ApproveButton
|
||||
className=""
|
||||
disabled={false}
|
||||
invert={false}
|
||||
onClick={[Function]}
|
||||
/>
|
||||
@@ -463,14 +475,20 @@ exports[`renders reply correctly 1`] = `
|
||||
2018-11-29T16:01:51.897Z
|
||||
</Timestamp>
|
||||
<FeatureButton
|
||||
enabled={true}
|
||||
featured={false}
|
||||
onClick={[Function]}
|
||||
/>
|
||||
</ForwardRef(forwardRef)>
|
||||
<div>
|
||||
<InReplyTo>
|
||||
Julian
|
||||
</InReplyTo>
|
||||
<ForwardRef(forwardRef)
|
||||
className="ModerateCard-username"
|
||||
onClick={[Function]}
|
||||
>
|
||||
<InReplyTo>
|
||||
Julian
|
||||
</InReplyTo>
|
||||
</ForwardRef(forwardRef)>
|
||||
</div>
|
||||
</div>
|
||||
<CommentContent
|
||||
@@ -507,6 +525,7 @@ exports[`renders reply correctly 1`] = `
|
||||
</div>
|
||||
<Relay(MarkersContainer)
|
||||
comment={Object {}}
|
||||
onUsernameClick={[Function]}
|
||||
settings={Object {}}
|
||||
/>
|
||||
</ForwardRef(forwardRef)>
|
||||
@@ -535,11 +554,13 @@ exports[`renders reply correctly 1`] = `
|
||||
>
|
||||
<RejectButton
|
||||
className=""
|
||||
disabled={false}
|
||||
invert={false}
|
||||
onClick={[Function]}
|
||||
/>
|
||||
<ApproveButton
|
||||
className=""
|
||||
disabled={false}
|
||||
invert={false}
|
||||
onClick={[Function]}
|
||||
/>
|
||||
@@ -576,6 +597,7 @@ exports[`renders story info 1`] = `
|
||||
2018-11-29T16:01:51.897Z
|
||||
</Timestamp>
|
||||
<FeatureButton
|
||||
enabled={true}
|
||||
featured={false}
|
||||
onClick={[Function]}
|
||||
/>
|
||||
@@ -644,6 +666,7 @@ exports[`renders story info 1`] = `
|
||||
</div>
|
||||
<Relay(MarkersContainer)
|
||||
comment={Object {}}
|
||||
onUsernameClick={[Function]}
|
||||
settings={Object {}}
|
||||
/>
|
||||
</ForwardRef(forwardRef)>
|
||||
@@ -672,11 +695,131 @@ exports[`renders story info 1`] = `
|
||||
>
|
||||
<RejectButton
|
||||
className=""
|
||||
disabled={false}
|
||||
invert={false}
|
||||
onClick={[Function]}
|
||||
/>
|
||||
<ApproveButton
|
||||
className=""
|
||||
disabled={false}
|
||||
invert={false}
|
||||
onClick={[Function]}
|
||||
/>
|
||||
</ForwardRef(forwardRef)>
|
||||
</ForwardRef(forwardRef)>
|
||||
</ForwardRef(forwardRef)>
|
||||
</withPropsOnChange(Card)>
|
||||
`;
|
||||
|
||||
exports[`renders tombstoned when comment is deleted 1`] = `
|
||||
<withPropsOnChange(Card)
|
||||
className="ModerateCard-root ModerateCard-deleted"
|
||||
data-testid="moderate-comment-comment-id"
|
||||
>
|
||||
<ForwardRef(forwardRef)>
|
||||
<div
|
||||
className="ModerateCard-mainContainer"
|
||||
>
|
||||
<div
|
||||
className="ModerateCard-topBar"
|
||||
>
|
||||
<ForwardRef(forwardRef)
|
||||
alignItems="center"
|
||||
>
|
||||
<ForwardRef(forwardRef)
|
||||
className="ModerateCard-username"
|
||||
onClick={[Function]}
|
||||
>
|
||||
<Username>
|
||||
Theon
|
||||
</Username>
|
||||
</ForwardRef(forwardRef)>
|
||||
<Timestamp>
|
||||
2018-11-29T16:01:51.897Z
|
||||
</Timestamp>
|
||||
<FeatureButton
|
||||
enabled={false}
|
||||
featured={false}
|
||||
onClick={[Function]}
|
||||
/>
|
||||
</ForwardRef(forwardRef)>
|
||||
</div>
|
||||
<CommentContent
|
||||
bannedWords={
|
||||
Array [
|
||||
"banned",
|
||||
]
|
||||
}
|
||||
className="ModerateCard-content"
|
||||
suspectWords={
|
||||
Array [
|
||||
"suspect",
|
||||
]
|
||||
}
|
||||
>
|
||||
<Localized
|
||||
id="moderate-comment-deleted-body"
|
||||
>
|
||||
<ForwardRef(forwardRef)>
|
||||
This comment is no longer available. The commenter has deleted their account.
|
||||
</ForwardRef(forwardRef)>
|
||||
</Localized>
|
||||
</CommentContent>
|
||||
<div
|
||||
className="ModerateCard-footer"
|
||||
>
|
||||
<ForwardRef(forwardRef)>
|
||||
<div>
|
||||
<Localized
|
||||
id="moderate-comment-viewContext"
|
||||
>
|
||||
<withPropsOnChange(TextLinkProps)
|
||||
className="ModerateCard-link"
|
||||
href="http://localhost/comment"
|
||||
target="_blank"
|
||||
>
|
||||
View Context
|
||||
</withPropsOnChange(TextLinkProps)>
|
||||
</Localized>
|
||||
</div>
|
||||
<Relay(MarkersContainer)
|
||||
comment={Object {}}
|
||||
onUsernameClick={[Function]}
|
||||
settings={Object {}}
|
||||
/>
|
||||
</ForwardRef(forwardRef)>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="ModerateCard-separator ModerateCard-ruledSeparator"
|
||||
/>
|
||||
<ForwardRef(forwardRef)
|
||||
alignItems="center"
|
||||
className="ModerateCard-aside ModerateCard-asideWithoutReplyTo"
|
||||
direction="column"
|
||||
itemGutter={true}
|
||||
>
|
||||
<Localized
|
||||
id="moderate-comment-decision"
|
||||
>
|
||||
<div
|
||||
className="ModerateCard-decision"
|
||||
>
|
||||
DECISION
|
||||
</div>
|
||||
</Localized>
|
||||
<ForwardRef(forwardRef)
|
||||
itemGutter={true}
|
||||
>
|
||||
<RejectButton
|
||||
className=""
|
||||
disabled={true}
|
||||
invert={false}
|
||||
onClick={[Function]}
|
||||
/>
|
||||
<ApproveButton
|
||||
className=""
|
||||
disabled={true}
|
||||
invert={false}
|
||||
onClick={[Function]}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import React, { FunctionComponent } from "react";
|
||||
|
||||
import BanAction, { BanActionProps } from "./BanAction";
|
||||
import SuspensionAction, { SuspensionActionProps } from "./SuspensionAction";
|
||||
import UsernameChangeAction, {
|
||||
UsernameChangeActionProps,
|
||||
} from "./UsernameChangeAction";
|
||||
|
||||
export interface HistoryActionProps {
|
||||
kind: "username" | "suspension" | "ban";
|
||||
action: UsernameChangeActionProps | SuspensionActionProps | BanActionProps;
|
||||
}
|
||||
|
||||
const AccountHistoryAction: FunctionComponent<HistoryActionProps> = ({
|
||||
kind,
|
||||
action,
|
||||
}) => {
|
||||
switch (kind) {
|
||||
case "username":
|
||||
return <UsernameChangeAction {...action as UsernameChangeActionProps} />;
|
||||
case "suspension":
|
||||
return <SuspensionAction {...action as SuspensionActionProps} />;
|
||||
case "ban":
|
||||
return <BanAction {...action as BanActionProps} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export default AccountHistoryAction;
|
||||
@@ -0,0 +1,29 @@
|
||||
.title {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.subTitle {
|
||||
color: var(--palette-grey-main);
|
||||
font-size: calc(14rem / var(--rem-base));
|
||||
}
|
||||
|
||||
.info {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.amount {
|
||||
font-size: calc(24rem / var(--rem-base));
|
||||
}
|
||||
|
||||
.amountLabel {
|
||||
font-size: calc(14rem / var(--rem-base));
|
||||
}
|
||||
|
||||
.triggered {
|
||||
color: var(--palette-error-main);
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
color: var(--palette-text-primary);
|
||||
padding: 3px;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import cn from "classnames";
|
||||
import { Localized } from "fluent-react/compat";
|
||||
import React, { FunctionComponent } from "react";
|
||||
|
||||
import { reduceSeconds, UNIT } from "coral-common/helpers/i18n";
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
Tooltip,
|
||||
TooltipButton,
|
||||
Typography,
|
||||
} from "coral-ui/components";
|
||||
|
||||
import styles from "./RecentHistory.css";
|
||||
|
||||
interface Props {
|
||||
triggered: boolean;
|
||||
timeFrame: number;
|
||||
rejectionRate: number;
|
||||
submitted: number;
|
||||
}
|
||||
|
||||
const RecentHistory: FunctionComponent<Props> = ({
|
||||
triggered,
|
||||
timeFrame,
|
||||
rejectionRate,
|
||||
submitted,
|
||||
}) => {
|
||||
const { scaled, unit } = reduceSeconds(timeFrame, [UNIT.DAYS]);
|
||||
|
||||
return (
|
||||
<Box mt={3}>
|
||||
<Localized id="moderate-user-drawer-recent-history-title">
|
||||
<Typography className={styles.title} variant="bodyCopyBold">
|
||||
Recent comment history
|
||||
</Typography>
|
||||
</Localized>
|
||||
<Localized
|
||||
id="moderate-user-drawer-recent-history-calculated"
|
||||
$unit={unit}
|
||||
$value={scaled}
|
||||
>
|
||||
<Typography className={styles.subTitle} variant="bodyCopy">
|
||||
Calculated over the last {scaled} {unit}
|
||||
</Typography>
|
||||
</Localized>
|
||||
<Box mt={1}>
|
||||
<Box className={styles.info} mr={4}>
|
||||
<Typography
|
||||
className={cn(styles.amount, {
|
||||
[styles.triggered]: triggered,
|
||||
})}
|
||||
variant="bodyCopyBold"
|
||||
>
|
||||
{Math.round(rejectionRate * 100)}%
|
||||
</Typography>
|
||||
<Flex alignItems="center">
|
||||
<Localized id="moderate-user-drawer-recent-history-rejected">
|
||||
<Typography
|
||||
className={cn(styles.amountLabel, {
|
||||
[styles.triggered]: triggered,
|
||||
})}
|
||||
variant="bodyCopy"
|
||||
container="span"
|
||||
>
|
||||
Rejected
|
||||
</Typography>
|
||||
</Localized>
|
||||
<Tooltip
|
||||
id="recentCommentHistory-rejectionPopover"
|
||||
title={
|
||||
<Localized id="moderate-user-drawer-recent-history-tooltip-title">
|
||||
<span>How is this calculated?</span>
|
||||
</Localized>
|
||||
}
|
||||
body={
|
||||
<Localized id="moderate-user-drawer-recent-history-tooltip-body">
|
||||
<span>
|
||||
Rejected comments divided by the sum of rejected and
|
||||
published comments, during the recent comment history time
|
||||
frame.
|
||||
</span>
|
||||
</Localized>
|
||||
}
|
||||
button={({ toggleVisibility, ref }) => (
|
||||
<Localized
|
||||
id="moderate-user-drawer-recent-history-tooltip-button"
|
||||
attrs={{ "aria-label": true }}
|
||||
>
|
||||
<TooltipButton
|
||||
className={styles.tooltip}
|
||||
aria-label="Toggle recent comment history tooltip"
|
||||
toggleVisibility={toggleVisibility}
|
||||
ref={ref}
|
||||
/>
|
||||
</Localized>
|
||||
)}
|
||||
/>
|
||||
</Flex>
|
||||
</Box>
|
||||
<Box className={styles.info}>
|
||||
<Typography className={styles.amount} variant="bodyCopyBold">
|
||||
{submitted}
|
||||
</Typography>
|
||||
<Localized id="moderate-user-drawer-recent-history-tooltip-submitted">
|
||||
<Typography className={styles.amountLabel} variant="bodyCopy">
|
||||
Submitted
|
||||
</Typography>
|
||||
</Localized>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default RecentHistory;
|
||||
@@ -0,0 +1,82 @@
|
||||
import React, { FunctionComponent, useMemo } from "react";
|
||||
|
||||
import { RecentHistoryContainer_settings } from "coral-admin/__generated__/RecentHistoryContainer_settings.graphql";
|
||||
import { RecentHistoryContainer_user } from "coral-admin/__generated__/RecentHistoryContainer_user.graphql";
|
||||
import { graphql, withFragmentContainer } from "coral-framework/lib/relay";
|
||||
import { GQLCOMMENT_STATUS } from "coral-framework/schema";
|
||||
import RecentHistory from "./RecentHistory";
|
||||
|
||||
const PUBLISHED_STATUSES = [GQLCOMMENT_STATUS.NONE, GQLCOMMENT_STATUS.APPROVED];
|
||||
|
||||
interface Props {
|
||||
user: RecentHistoryContainer_user;
|
||||
settings: RecentHistoryContainer_settings;
|
||||
}
|
||||
|
||||
const RecentHistoryContainer: FunctionComponent<Props> = ({
|
||||
user,
|
||||
settings,
|
||||
}) => {
|
||||
const submitted = useMemo(
|
||||
() =>
|
||||
Object.keys(user.recentCommentHistory.statuses).reduce(
|
||||
(acc, key: keyof typeof user.recentCommentHistory.statuses) =>
|
||||
user.recentCommentHistory.statuses[key] + acc,
|
||||
0
|
||||
),
|
||||
[user]
|
||||
);
|
||||
const rejectionRate = useMemo(() => {
|
||||
const published = PUBLISHED_STATUSES.reduce(
|
||||
(acc, status) => user.recentCommentHistory.statuses[status] + acc,
|
||||
0
|
||||
);
|
||||
const rejected =
|
||||
user.recentCommentHistory.statuses[GQLCOMMENT_STATUS.REJECTED];
|
||||
const total = published + rejected;
|
||||
if (total === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return rejected / total;
|
||||
}, [user]);
|
||||
const triggered =
|
||||
settings.recentCommentHistory.enabled &&
|
||||
rejectionRate >= settings.recentCommentHistory.triggerRejectionRate;
|
||||
|
||||
return (
|
||||
<RecentHistory
|
||||
triggered={triggered}
|
||||
timeFrame={settings.recentCommentHistory.timeFrame}
|
||||
rejectionRate={rejectionRate}
|
||||
submitted={submitted}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const enhanced = withFragmentContainer<Props>({
|
||||
user: graphql`
|
||||
fragment RecentHistoryContainer_user on User {
|
||||
recentCommentHistory {
|
||||
statuses {
|
||||
NONE
|
||||
APPROVED
|
||||
REJECTED
|
||||
PREMOD
|
||||
SYSTEM_WITHHELD
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
settings: graphql`
|
||||
fragment RecentHistoryContainer_settings on Settings {
|
||||
recentCommentHistory {
|
||||
enabled
|
||||
timeFrame
|
||||
triggerRejectionRate
|
||||
}
|
||||
}
|
||||
`,
|
||||
})(RecentHistoryContainer);
|
||||
|
||||
export default enhanced;
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Localized } from "fluent-react/compat";
|
||||
import React, { FunctionComponent } from "react";
|
||||
|
||||
import { DURATION_UNIT } from "coral-framework/components";
|
||||
import { reduceSeconds } from "coral-common/helpers/i18n";
|
||||
|
||||
interface From {
|
||||
start: Date;
|
||||
@@ -13,48 +13,23 @@ export interface SuspensionActionProps {
|
||||
from: From;
|
||||
}
|
||||
|
||||
const UNITS = [
|
||||
DURATION_UNIT.WEEKS,
|
||||
DURATION_UNIT.DAYS,
|
||||
DURATION_UNIT.HOURS,
|
||||
DURATION_UNIT.MINUTES,
|
||||
DURATION_UNIT.SECONDS,
|
||||
];
|
||||
|
||||
const UNIT_MAP = {
|
||||
[DURATION_UNIT.SECONDS]: "second",
|
||||
[DURATION_UNIT.MINUTES]: "minute",
|
||||
[DURATION_UNIT.HOURS]: "hour",
|
||||
[DURATION_UNIT.DAYS]: "day",
|
||||
[DURATION_UNIT.WEEKS]: "week",
|
||||
};
|
||||
|
||||
function reduceValue(value: number) {
|
||||
// Find the largest match for the smallest number.
|
||||
const unit: keyof typeof UNIT_MAP =
|
||||
UNITS.find(compare => value >= compare) || DURATION_UNIT.SECONDS;
|
||||
|
||||
return {
|
||||
value: Math.round((value / unit) * 100) / 100,
|
||||
unit: UNIT_MAP[unit],
|
||||
};
|
||||
}
|
||||
|
||||
const SuspensionAction: FunctionComponent<SuspensionActionProps> = ({
|
||||
action,
|
||||
from,
|
||||
}) => {
|
||||
if (action === "created") {
|
||||
const seconds = (from.finish.getTime() - from.start.getTime()) / 1000;
|
||||
const { value, unit } = reduceValue(seconds);
|
||||
const { scaled, unit } = reduceSeconds(seconds);
|
||||
|
||||
return (
|
||||
<Localized
|
||||
id="moderate-user-drawer-suspension"
|
||||
$unit={unit}
|
||||
$value={value}
|
||||
$value={scaled}
|
||||
>
|
||||
<span>Suspension, {seconds} seconds</span>
|
||||
<span>
|
||||
Suspension, {scaled} {unit}
|
||||
</span>
|
||||
</Localized>
|
||||
);
|
||||
} else if (action === "removed") {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import React, { FunctionComponent } from "react";
|
||||
import { graphql } from "react-relay";
|
||||
|
||||
import { UserBadgesContainer_user as UserData } from "coral-admin/__generated__/UserBadgesContainer_user.graphql";
|
||||
import withFragmentContainer from "coral-framework/lib/relay/withFragmentContainer";
|
||||
|
||||
import CLASSES from "coral-stream/classes";
|
||||
import { Tag } from "coral-ui/components";
|
||||
|
||||
interface Props {
|
||||
user: UserData;
|
||||
}
|
||||
|
||||
const UserBadgesContainer: FunctionComponent<Props> = ({ user }) => {
|
||||
if (!user.badges) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{user.badges.map(badge => (
|
||||
<Tag
|
||||
key={badge}
|
||||
color="dark"
|
||||
className={CLASSES.comment.topBar.userBadge}
|
||||
>
|
||||
{badge}
|
||||
</Tag>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const enhanced = withFragmentContainer<Props>({
|
||||
user: graphql`
|
||||
fragment UserBadgesContainer_user on User {
|
||||
badges
|
||||
}
|
||||
`,
|
||||
})(UserBadgesContainer);
|
||||
|
||||
export default enhanced;
|
||||
@@ -16,8 +16,9 @@ import {
|
||||
TableRow,
|
||||
} from "coral-ui/components";
|
||||
|
||||
import BanAction, { BanActionProps } from "./BanAction";
|
||||
import SuspensionAction, { SuspensionActionProps } from "./SuspensionAction";
|
||||
import AccountHistoryAction, {
|
||||
HistoryActionProps,
|
||||
} from "./AccountHistoryAction";
|
||||
|
||||
import styles from "./UserDrawerAccountHistory.css";
|
||||
|
||||
@@ -30,21 +31,10 @@ interface From {
|
||||
finish: any;
|
||||
}
|
||||
|
||||
interface SuspensionHistoryRecord {
|
||||
kind: "suspension";
|
||||
action: SuspensionActionProps;
|
||||
type HistoryRecord = HistoryActionProps & {
|
||||
date: Date;
|
||||
takenBy: React.ReactNode;
|
||||
}
|
||||
|
||||
interface BanHistoryRecord {
|
||||
kind: "ban";
|
||||
action: BanActionProps;
|
||||
date: Date;
|
||||
takenBy: React.ReactNode;
|
||||
}
|
||||
|
||||
type HistoryRecord = SuspensionHistoryRecord | BanHistoryRecord;
|
||||
};
|
||||
|
||||
const UserDrawerAccountHistory: FunctionComponent<Props> = ({ user }) => {
|
||||
const system = (
|
||||
@@ -117,6 +107,20 @@ const UserDrawerAccountHistory: FunctionComponent<Props> = ({ user }) => {
|
||||
});
|
||||
});
|
||||
|
||||
user.status.username.history.forEach((record, i) => {
|
||||
history.push({
|
||||
kind: "username",
|
||||
action: {
|
||||
username: record.username,
|
||||
// grab username at previous index to show what username was changed from
|
||||
prevUsername:
|
||||
i >= 1 ? user.status.username.history[i - 1].username : null,
|
||||
},
|
||||
date: new Date(record.createdAt),
|
||||
takenBy: record.createdBy ? record.createdBy.username : system,
|
||||
});
|
||||
});
|
||||
|
||||
// Sort the history so that it's in the right order.
|
||||
return history.sort((a, b) => b.date.getTime() - a.date.getTime());
|
||||
}, [user]);
|
||||
@@ -153,11 +157,7 @@ const UserDrawerAccountHistory: FunctionComponent<Props> = ({ user }) => {
|
||||
{formatter.format(history.date)}
|
||||
</TableCell>
|
||||
<TableCell className={styles.action}>
|
||||
{history.kind === "suspension" ? (
|
||||
<SuspensionAction {...history.action} />
|
||||
) : (
|
||||
<BanAction {...history.action} />
|
||||
)}
|
||||
<AccountHistoryAction {...history} />
|
||||
</TableCell>
|
||||
<TableCell className={styles.user}>{history.takenBy}</TableCell>
|
||||
</TableRow>
|
||||
@@ -172,6 +172,15 @@ const enhanced = withFragmentContainer<any>({
|
||||
user: graphql`
|
||||
fragment UserDrawerAccountHistory_user on User {
|
||||
status {
|
||||
username {
|
||||
history {
|
||||
username
|
||||
createdAt
|
||||
createdBy {
|
||||
username
|
||||
}
|
||||
}
|
||||
}
|
||||
ban {
|
||||
history {
|
||||
active
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
.root {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
|
||||
width: 624px;
|
||||
height: 100%;
|
||||
|
||||
display: flex;
|
||||
flex-flow: column nowrap;
|
||||
flex-direction: column;
|
||||
|
||||
background-color: var(--palette-common-white);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import React, { FunctionComponent } from "react";
|
||||
|
||||
import { Card, Modal } from "coral-ui/components";
|
||||
|
||||
import UserHistoryDrawerQuery from "./UserHistoryDrawerQuery";
|
||||
|
||||
import styles from "./UserHistoryDrawer.css";
|
||||
|
||||
interface UserHistoryDrawerProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
userID?: string;
|
||||
}
|
||||
|
||||
const UserHistoryDrawer: FunctionComponent<UserHistoryDrawerProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
userID,
|
||||
}) => {
|
||||
return (
|
||||
<Modal open={open} onClose={onClose}>
|
||||
{({ firstFocusableRef, lastFocusableRef }) => (
|
||||
<Card className={styles.root}>
|
||||
{userID && (
|
||||
<UserHistoryDrawerQuery
|
||||
userID={userID}
|
||||
onClose={onClose}
|
||||
firstFocusableRef={firstFocusableRef}
|
||||
lastFocusableRef={lastFocusableRef}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserHistoryDrawer;
|
||||
@@ -9,7 +9,7 @@
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
border-color: var(--palette-grey-lighter);
|
||||
border-radius: 2px;
|
||||
border-radius: var(--round-corners);
|
||||
|
||||
margin-top: var(--spacing-1);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,97 @@
|
||||
.root {
|
||||
position: fixed;
|
||||
top:0;
|
||||
right: 0;
|
||||
.comments {
|
||||
flex: auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
width: 624px;
|
||||
height: 100%;
|
||||
.close {
|
||||
position: absolute;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
left: -40px;
|
||||
|
||||
display: flex;
|
||||
flex-flow: column nowrap;
|
||||
flex-direction: column;
|
||||
padding: 0px;
|
||||
|
||||
background-color: var(--palette-common-white);
|
||||
}
|
||||
|
||||
border-width: 2px;
|
||||
border-right-width: 0px;
|
||||
border-style: solid;
|
||||
border-color: var(--palette-grey-main);
|
||||
border-radius: 4px 0px 0px 4px;
|
||||
}
|
||||
|
||||
.username {
|
||||
font-family: var(--font-family-serif);
|
||||
font-size: calc(24rem / var(--rem-base));
|
||||
font-weight: 500;
|
||||
font-family: var(--font-family-serif);
|
||||
line-height: calc(36em / 24);
|
||||
letter-spacing: calc(0.2em / 24);
|
||||
margin-bottom: var(--spacing-1);
|
||||
}
|
||||
|
||||
.userDetail {
|
||||
margin-bottom: var(--spacing-1);
|
||||
}
|
||||
|
||||
.userDetailValue {
|
||||
margin-right: var(--spacing-2);
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-right: var(--spacing-2);
|
||||
}
|
||||
|
||||
.copy {
|
||||
border: 1px solid var(--palette-primary-main);
|
||||
background-color: transparent;
|
||||
color: var(--palette-primary-main);
|
||||
padding-top: 2px;
|
||||
padding-bottom: 2px;
|
||||
|
||||
&:active {
|
||||
background-color: var(--palette-primary-lightest);
|
||||
border-color: var(--palette-primary-main);
|
||||
color: var(--palette-primary-main);
|
||||
}
|
||||
}
|
||||
|
||||
hr {
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--palette-divider);
|
||||
padding-top: 1px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.divider {
|
||||
border-bottom: 1px solid var(--palette-grey-lighter);
|
||||
}
|
||||
|
||||
.userStatus {
|
||||
margin-top: var(--spacing-1);
|
||||
margin-bottom: var(--spacing-1);
|
||||
}
|
||||
|
||||
.userStatusLabel {
|
||||
display: inline-block;
|
||||
|
||||
margin-right: var(--spacing-1);
|
||||
|
||||
font-family: var(--font-family-sans-serif);
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
line-height: 14px;
|
||||
}
|
||||
|
||||
.userStatusChange {
|
||||
display: inline-block;
|
||||
border-style: solid;
|
||||
border-color: var(--palette-grey-lighter);
|
||||
border-width: 1px;
|
||||
border-radius: var(--round-corners);
|
||||
padding-left: var(--spacing-2);
|
||||
padding-right: var(--spacing-2);
|
||||
|
||||
min-height: 21px;
|
||||
}
|
||||
|
||||
+146
-23
@@ -1,35 +1,158 @@
|
||||
import { Localized } from "fluent-react/compat";
|
||||
import React, { FunctionComponent } from "react";
|
||||
|
||||
import { Card, Modal } from "coral-ui/components";
|
||||
import { UserHistoryDrawerContainer_settings } from "coral-admin/__generated__/UserHistoryDrawerContainer_settings.graphql";
|
||||
import { UserHistoryDrawerContainer_user } from "coral-admin/__generated__/UserHistoryDrawerContainer_user.graphql";
|
||||
import { UserStatusChangeContainer } from "coral-admin/components/UserStatus";
|
||||
import { CopyButton } from "coral-framework/components";
|
||||
import { useCoralContext } from "coral-framework/lib/bootstrap";
|
||||
import { graphql, withFragmentContainer } from "coral-framework/lib/relay";
|
||||
import { Button, Flex, Icon, Typography } from "coral-ui/components";
|
||||
|
||||
import RecentHistoryContainer from "./RecentHistoryContainer";
|
||||
import Tabs from "./Tabs";
|
||||
import UserBadgesContainer from "./UserBadgesContainer";
|
||||
import UserStatusDetailsContainer from "./UserStatusDetailsContainer";
|
||||
|
||||
import styles from "./UserHistoryDrawerContainer.css";
|
||||
import UserHistoryDrawerQuery from "./UserHistoryDrawerQuery";
|
||||
|
||||
interface UserHistoryDrawerContainerProps {
|
||||
open: boolean;
|
||||
interface Props {
|
||||
user: UserHistoryDrawerContainer_user;
|
||||
settings: UserHistoryDrawerContainer_settings;
|
||||
onClose: () => void;
|
||||
userID?: string;
|
||||
}
|
||||
|
||||
const UserHistoryDrawerContainer: FunctionComponent<
|
||||
UserHistoryDrawerContainerProps
|
||||
> = ({ open, onClose, userID }) => {
|
||||
const UserHistoryDrawerContainer: FunctionComponent<Props> = ({
|
||||
settings,
|
||||
user,
|
||||
onClose,
|
||||
}) => {
|
||||
const { locales } = useCoralContext();
|
||||
const formatter = new Intl.DateTimeFormat(locales, {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose}>
|
||||
{({ firstFocusableRef, lastFocusableRef }) => (
|
||||
<Card className={styles.root}>
|
||||
{userID && (
|
||||
<UserHistoryDrawerQuery
|
||||
userID={userID}
|
||||
onClose={onClose}
|
||||
firstFocusableRef={firstFocusableRef}
|
||||
lastFocusableRef={lastFocusableRef}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
</Modal>
|
||||
<>
|
||||
<Button className={styles.close} onClick={onClose}>
|
||||
<Icon size="md">close</Icon>
|
||||
</Button>
|
||||
<Flex className={styles.username} itemGutter>
|
||||
<span>{user.username}</span>
|
||||
<div>
|
||||
<UserBadgesContainer user={user} />
|
||||
</div>
|
||||
</Flex>
|
||||
<div className={styles.userStatus}>
|
||||
<Flex alignItems="center" itemGutter="half">
|
||||
<div className={styles.userStatusLabel}>
|
||||
<Typography variant="bodyCopyBold" container="div">
|
||||
<Flex alignItems="center" itemGutter="half">
|
||||
<Localized id="moderate-user-drawer-status-label">
|
||||
Status:
|
||||
</Localized>
|
||||
</Flex>
|
||||
</Typography>
|
||||
</div>
|
||||
<div className={styles.userStatusChange}>
|
||||
<UserStatusChangeContainer settings={settings} user={user} />
|
||||
</div>
|
||||
<UserStatusDetailsContainer user={user} />
|
||||
</Flex>
|
||||
</div>
|
||||
<div>
|
||||
<Flex alignItems="center" className={styles.userDetail}>
|
||||
<Localized id="moderate-user-drawer-email" attrs={{ title: true }}>
|
||||
<Icon size="sm" className={styles.icon} title="Email address">
|
||||
mail_outline
|
||||
</Icon>
|
||||
</Localized>
|
||||
<Typography
|
||||
variant="bodyCopy"
|
||||
container="span"
|
||||
className={styles.userDetailValue}
|
||||
>
|
||||
{user.email}
|
||||
</Typography>
|
||||
<CopyButton
|
||||
text={user.email!}
|
||||
variant="regular"
|
||||
className={styles.copy}
|
||||
/>
|
||||
</Flex>
|
||||
<Flex alignItems="center" className={styles.userDetail}>
|
||||
<Localized
|
||||
id="moderate-user-drawer-created-at"
|
||||
attrs={{ title: true }}
|
||||
>
|
||||
<Icon
|
||||
size="sm"
|
||||
className={styles.icon}
|
||||
title="Account creation date"
|
||||
>
|
||||
date_range
|
||||
</Icon>
|
||||
</Localized>
|
||||
<Typography variant="bodyCopy" container="span">
|
||||
{formatter.format(new Date(user.createdAt))}
|
||||
</Typography>
|
||||
</Flex>
|
||||
<Flex alignItems="center" className={styles.userDetail}>
|
||||
<Localized
|
||||
id="moderate-user-drawer-member-id"
|
||||
attrs={{ title: true }}
|
||||
>
|
||||
<Icon size="sm" className={styles.icon} title="Member ID">
|
||||
people_outline
|
||||
</Icon>
|
||||
</Localized>
|
||||
<Typography
|
||||
variant="bodyCopy"
|
||||
container="span"
|
||||
className={styles.userDetailValue}
|
||||
>
|
||||
{user.id}
|
||||
</Typography>
|
||||
<CopyButton
|
||||
text={user.id}
|
||||
variant="regular"
|
||||
className={styles.copy}
|
||||
/>
|
||||
</Flex>
|
||||
<RecentHistoryContainer user={user} settings={settings} />
|
||||
</div>
|
||||
<hr className={styles.divider} />
|
||||
<div className={styles.comments}>
|
||||
<Tabs userID={user.id} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserHistoryDrawerContainer;
|
||||
const enhanced = withFragmentContainer<Props>({
|
||||
user: graphql`
|
||||
fragment UserHistoryDrawerContainer_user on User {
|
||||
...UserBadgesContainer_user
|
||||
...UserStatusChangeContainer_user
|
||||
...UserStatusDetailsContainer_user
|
||||
...RecentHistoryContainer_user
|
||||
id
|
||||
username
|
||||
email
|
||||
createdAt
|
||||
}
|
||||
`,
|
||||
settings: graphql`
|
||||
fragment UserHistoryDrawerContainer_settings on Settings {
|
||||
...RecentHistoryContainer_settings
|
||||
...UserStatusChangeContainer_settings
|
||||
organization {
|
||||
name
|
||||
}
|
||||
}
|
||||
`,
|
||||
})(UserHistoryDrawerContainer);
|
||||
|
||||
export default enhanced;
|
||||
|
||||
@@ -1,121 +1,6 @@
|
||||
.root {
|
||||
position: fixed;
|
||||
top:0;
|
||||
right: 0;
|
||||
|
||||
width: 624px;
|
||||
height: 100%;
|
||||
|
||||
display: flex;
|
||||
flex-flow: column nowrap;
|
||||
flex-direction: column;
|
||||
|
||||
background-color: var(--palette-common-white);
|
||||
}
|
||||
|
||||
.comments {
|
||||
flex: auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.close {
|
||||
position: absolute;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
left: -40px;
|
||||
|
||||
padding: 0px;
|
||||
|
||||
background-color: var(--palette-common-white);
|
||||
|
||||
border-width: 2px;
|
||||
border-right-width: 0px;
|
||||
border-style: solid;
|
||||
border-color: var(--palette-grey-main);
|
||||
border-radius: 4px 0px 0px 4px;
|
||||
}
|
||||
|
||||
.username {
|
||||
font-family: var(--font-family-serif);
|
||||
font-size: calc(24rem / var(--rem-base));
|
||||
font-weight: 500;
|
||||
font-family: var(--font-family-serif);
|
||||
line-height: calc(36em / 24);
|
||||
letter-spacing: calc(0.2em / 24);
|
||||
margin-bottom: var(--spacing-1);
|
||||
}
|
||||
|
||||
.userDetails {
|
||||
margin-bottom: var(--spacing-3);
|
||||
}
|
||||
|
||||
.userDetail {
|
||||
margin-bottom: var(--spacing-1);
|
||||
}
|
||||
|
||||
.userDetailValue {
|
||||
margin-right: var(--spacing-2);
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-right: var(--spacing-2);
|
||||
}
|
||||
|
||||
.copy {
|
||||
border: 1px solid var(--palette-primary-main);
|
||||
background-color: transparent;
|
||||
color: var(--palette-primary-main);
|
||||
padding-top: 2px;
|
||||
padding-bottom: 2px;
|
||||
|
||||
&:active {
|
||||
background-color: var(--palette-primary-lightest);
|
||||
border-color: var(--palette-primary-main);
|
||||
color: var(--palette-primary-main);
|
||||
}
|
||||
}
|
||||
|
||||
.callout {
|
||||
width: 100%;
|
||||
font-family: var(--font-family-sans-serif);
|
||||
align-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
hr {
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--palette-divider);
|
||||
padding-top: 1px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.divider {
|
||||
border-bottom: 1px solid var(--palette-grey-lighter);
|
||||
}
|
||||
|
||||
.userStatus {
|
||||
margin-top: var(--spacing-1);
|
||||
margin-bottom: var(--spacing-1);
|
||||
}
|
||||
|
||||
.userStatusLabel {
|
||||
display: inline-block;
|
||||
|
||||
margin-right: var(--spacing-1);
|
||||
|
||||
font-family: var(--font-family-sans-serif);
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
line-height: 14px;
|
||||
}
|
||||
|
||||
.userStatusChange {
|
||||
display: inline-block;
|
||||
border-style: solid;
|
||||
border-color: var(--palette-grey-lighter);
|
||||
border-width: 1px;
|
||||
border-radius: 2px;
|
||||
padding-left: var(--spacing-2);
|
||||
padding-right: var(--spacing-1);
|
||||
}
|
||||
@@ -3,20 +3,10 @@ import React, { FunctionComponent } from "react";
|
||||
import { ReadyState } from "react-relay";
|
||||
|
||||
import { UserHistoryDrawerQuery as QueryTypes } from "coral-admin/__generated__/UserHistoryDrawerQuery.graphql";
|
||||
import { UserStatusChangeContainer } from "coral-admin/components/UserStatus";
|
||||
import { CopyButton } from "coral-framework/components";
|
||||
import { useCoralContext } from "coral-framework/lib/bootstrap";
|
||||
import { graphql, QueryRenderer } from "coral-framework/lib/relay";
|
||||
import {
|
||||
Button,
|
||||
CallOut,
|
||||
Flex,
|
||||
Icon,
|
||||
Spinner,
|
||||
Typography,
|
||||
} from "coral-ui/components";
|
||||
import { CallOut, Spinner } from "coral-ui/components";
|
||||
|
||||
import Tabs from "./Tabs";
|
||||
import UserHistoryDrawerContainer from "./UserHistoryDrawerContainer";
|
||||
|
||||
import styles from "./UserHistoryDrawerQuery.css";
|
||||
|
||||
@@ -31,29 +21,21 @@ const UserHistoryDrawerQuery: FunctionComponent<Props> = ({
|
||||
userID,
|
||||
onClose,
|
||||
}) => {
|
||||
const { locales } = useCoralContext();
|
||||
const formatter = new Intl.DateTimeFormat(locales, {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
return (
|
||||
<QueryRenderer<QueryTypes>
|
||||
query={graphql`
|
||||
query UserHistoryDrawerQuery($userID: ID!) {
|
||||
user(id: $userID) {
|
||||
id
|
||||
username
|
||||
email
|
||||
createdAt
|
||||
...UserStatusChangeContainer_user
|
||||
...UserHistoryDrawerContainer_user
|
||||
}
|
||||
settings {
|
||||
...UserHistoryDrawerContainer_settings
|
||||
}
|
||||
}
|
||||
`}
|
||||
variables={{ userID }}
|
||||
cacheConfig={{ force: true }}
|
||||
render={({ error, props }: ReadyState<QueryTypes["response"]>) => {
|
||||
render={({ props }: ReadyState<QueryTypes["response"]>) => {
|
||||
if (!props) {
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
@@ -64,9 +46,9 @@ const UserHistoryDrawerQuery: FunctionComponent<Props> = ({
|
||||
|
||||
if (!props.user) {
|
||||
return (
|
||||
<div className={styles.callout}>
|
||||
<div className={styles.root}>
|
||||
<CallOut>
|
||||
<Localized id="moderate-user-drawer-user-not-found ">
|
||||
<Localized id="moderate-user-drawer-user-not-found">
|
||||
User not found.
|
||||
</Localized>
|
||||
</CallOut>
|
||||
@@ -74,90 +56,12 @@ const UserHistoryDrawerQuery: FunctionComponent<Props> = ({
|
||||
);
|
||||
}
|
||||
|
||||
const user = props.user;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button className={styles.close} onClick={onClose}>
|
||||
<Icon size="md">close</Icon>
|
||||
</Button>
|
||||
<Flex className={styles.username}>
|
||||
<span>{user.username}</span>
|
||||
</Flex>
|
||||
<div className={styles.userStatus}>
|
||||
<div className={styles.userStatusLabel}>Status:</div>
|
||||
<div className={styles.userStatusChange}>
|
||||
<UserStatusChangeContainer user={user} />
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.userDetails}>
|
||||
<Flex alignItems="center" className={styles.userDetail}>
|
||||
<Localized
|
||||
id="moderate-user-drawer-email"
|
||||
attrs={{ title: true }}
|
||||
>
|
||||
<Icon size="sm" className={styles.icon} title="Email address">
|
||||
mail_outline
|
||||
</Icon>
|
||||
</Localized>
|
||||
<Typography
|
||||
variant="bodyCopy"
|
||||
container="span"
|
||||
className={styles.userDetailValue}
|
||||
>
|
||||
{user.email}
|
||||
</Typography>
|
||||
<CopyButton
|
||||
text={user.email!}
|
||||
variant="regular"
|
||||
className={styles.copy}
|
||||
/>
|
||||
</Flex>
|
||||
<Flex alignItems="center" className={styles.userDetail}>
|
||||
<Localized
|
||||
id="moderate-user-drawer-created-at"
|
||||
attrs={{ title: true }}
|
||||
>
|
||||
<Icon
|
||||
size="sm"
|
||||
className={styles.icon}
|
||||
title="Account creation date"
|
||||
>
|
||||
date_range
|
||||
</Icon>
|
||||
</Localized>
|
||||
<Typography variant="bodyCopy" container="span">
|
||||
{formatter.format(new Date(user.createdAt))}
|
||||
</Typography>
|
||||
</Flex>
|
||||
<Flex alignItems="center" className={styles.userDetail}>
|
||||
<Localized
|
||||
id="moderate-user-drawer-member-id"
|
||||
attrs={{ title: true }}
|
||||
>
|
||||
<Icon size="sm" className={styles.icon} title="Member ID">
|
||||
people_outline
|
||||
</Icon>
|
||||
</Localized>
|
||||
<Typography
|
||||
variant="bodyCopy"
|
||||
container="span"
|
||||
className={styles.userDetailValue}
|
||||
>
|
||||
{user.id}
|
||||
</Typography>
|
||||
<CopyButton
|
||||
text={user.id}
|
||||
variant="regular"
|
||||
className={styles.copy}
|
||||
/>
|
||||
</Flex>
|
||||
</div>
|
||||
<hr className={styles.divider} />
|
||||
<div className={styles.comments}>
|
||||
<Tabs userID={user.id} />
|
||||
</div>
|
||||
</>
|
||||
<UserHistoryDrawerContainer
|
||||
onClose={onClose}
|
||||
user={props.user}
|
||||
settings={props.settings}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
border-color: var(--palette-grey-lighter);
|
||||
border-radius: 2px;
|
||||
border-radius: var(--round-corners);
|
||||
|
||||
margin-top: var(--spacing-1);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import { graphql, withFragmentContainer } from "coral-framework/lib/relay";
|
||||
import { Localized } from "fluent-react/compat";
|
||||
import React, { FunctionComponent, useMemo } from "react";
|
||||
|
||||
import { UserStatusDetailsContainer_user as UserData } from "coral-admin/__generated__/UserStatusDetailsContainer_user.graphql";
|
||||
import { useCoralContext } from "coral-framework/lib/bootstrap";
|
||||
import {
|
||||
BaseButton,
|
||||
Box,
|
||||
ClickOutside,
|
||||
Icon,
|
||||
Popover,
|
||||
Typography,
|
||||
} from "coral-ui/components";
|
||||
|
||||
interface Props {
|
||||
user: UserData;
|
||||
}
|
||||
|
||||
const UserStatusDetailsContainer: FunctionComponent<Props> = ({ user }) => {
|
||||
if (!user.status.ban.active && !user.status.suspension.active) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const activeBan = useMemo(() => {
|
||||
return user.status.ban.history.find(item => item.active);
|
||||
}, [user]);
|
||||
|
||||
const activeSuspension = useMemo(() => {
|
||||
return user.status.suspension.history.find(item => item.active);
|
||||
}, [user]);
|
||||
|
||||
const { locales } = useCoralContext();
|
||||
|
||||
const formatter = new Intl.DateTimeFormat(locales, {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Popover
|
||||
placement="bottom"
|
||||
id="userStatus-details-popover"
|
||||
body={({ toggleVisibility }) => (
|
||||
<ClickOutside onClickOutside={toggleVisibility}>
|
||||
<Box p={2}>
|
||||
{activeBan && (
|
||||
<div>
|
||||
<Localized
|
||||
id="userDetails-banned-on"
|
||||
$timestamp={formatter.format(new Date(activeBan.createdAt))}
|
||||
strong={<strong />}
|
||||
>
|
||||
<Typography>
|
||||
<strong>Banned on </strong>{" "}
|
||||
{formatter.format(new Date(activeBan.createdAt))}
|
||||
</Typography>
|
||||
</Localized>
|
||||
{activeBan.createdBy && (
|
||||
<Localized
|
||||
id="userDetails-banned-by"
|
||||
strong={<strong />}
|
||||
$username={activeBan.createdBy.username}
|
||||
>
|
||||
<Typography>
|
||||
<strong>by </strong>
|
||||
{activeBan.createdBy.username}
|
||||
</Typography>
|
||||
</Localized>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{activeSuspension && (
|
||||
<div>
|
||||
{activeSuspension.createdBy && (
|
||||
<Localized
|
||||
id="userDetails-suspended-by"
|
||||
strong={<strong />}
|
||||
$username={activeSuspension.createdBy.username}
|
||||
>
|
||||
<Typography>
|
||||
<strong>Suspended by </strong>
|
||||
{activeSuspension.createdBy.username}
|
||||
</Typography>
|
||||
</Localized>
|
||||
)}
|
||||
<Localized
|
||||
id="userDetails-suspension-start"
|
||||
strong={<strong />}
|
||||
$timestamp={formatter.format(
|
||||
new Date(activeSuspension.from.start)
|
||||
)}
|
||||
>
|
||||
<Typography>
|
||||
<strong>Start: </strong>
|
||||
{formatter.format(new Date(activeSuspension.from.start))}
|
||||
</Typography>
|
||||
</Localized>
|
||||
<Localized
|
||||
strong={<strong />}
|
||||
$timestamp={formatter.format(
|
||||
new Date(activeSuspension.from.finish)
|
||||
)}
|
||||
id="userDetails-suspension-finish"
|
||||
>
|
||||
<Typography>
|
||||
<strong>End: </strong>
|
||||
{formatter.format(new Date(activeSuspension.from.finish))}
|
||||
</Typography>
|
||||
</Localized>
|
||||
</div>
|
||||
)}
|
||||
</Box>
|
||||
</ClickOutside>
|
||||
)}
|
||||
>
|
||||
{({ toggleVisibility, ref }) => (
|
||||
<BaseButton
|
||||
onClick={evt => {
|
||||
evt.stopPropagation();
|
||||
toggleVisibility();
|
||||
}}
|
||||
aria-label="View user status details"
|
||||
ref={ref}
|
||||
>
|
||||
<Icon size="md" color="inherit">
|
||||
info
|
||||
</Icon>
|
||||
</BaseButton>
|
||||
)}
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const enhanced = withFragmentContainer<Props>({
|
||||
user: graphql`
|
||||
fragment UserStatusDetailsContainer_user on User {
|
||||
status {
|
||||
ban {
|
||||
active
|
||||
history {
|
||||
active
|
||||
createdAt
|
||||
createdBy {
|
||||
username
|
||||
}
|
||||
}
|
||||
}
|
||||
suspension {
|
||||
until
|
||||
active
|
||||
history {
|
||||
active
|
||||
from {
|
||||
start
|
||||
finish
|
||||
}
|
||||
createdBy {
|
||||
username
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
})(UserStatusDetailsContainer);
|
||||
|
||||
export default enhanced;
|
||||
@@ -0,0 +1,7 @@
|
||||
.tableLight {
|
||||
font-weight: var(--font-weight-regular);
|
||||
}
|
||||
|
||||
.usernameCell {
|
||||
line-height: calc(18em / 14);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Localized } from "fluent-react/compat";
|
||||
import React, { FunctionComponent } from "react";
|
||||
|
||||
import styles from "./UsernameChangeAction.css";
|
||||
|
||||
export interface UsernameChangeActionProps {
|
||||
username: string;
|
||||
prevUsername: string | null;
|
||||
}
|
||||
|
||||
const SuspensionAction: FunctionComponent<UsernameChangeActionProps> = ({
|
||||
username,
|
||||
prevUsername,
|
||||
}) => {
|
||||
return (
|
||||
<div className={styles.usernameCell}>
|
||||
<Localized id="moderate-user-drawer-username-change">
|
||||
<div>Username change</div>
|
||||
</Localized>
|
||||
<div>
|
||||
<Localized id="moderate-user-drawer-username-change-new">
|
||||
<span className={styles.tableLight}>New: </span>
|
||||
</Localized>{" "}
|
||||
{username}
|
||||
</div>
|
||||
{prevUsername && (
|
||||
<div>
|
||||
<Localized id="moderate-user-drawer-username-change-old">
|
||||
<span className={styles.tableLight}>Old: </span>
|
||||
</Localized>{" "}
|
||||
{prevUsername}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SuspensionAction;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "./UserHistoryDrawer";
|
||||
@@ -1,3 +1,10 @@
|
||||
.card {
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.textArea {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
height: calc(12 * var(--mini-unit));
|
||||
padding: calc(0.5 * var(--mini-unit));
|
||||
}
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import { Localized } from "fluent-react/compat";
|
||||
import React, { FunctionComponent } from "react";
|
||||
|
||||
import NotAvailable from "coral-admin/components/NotAvailable";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardCloseButton,
|
||||
CheckBox,
|
||||
Flex,
|
||||
HorizontalGutter,
|
||||
Modal,
|
||||
Typography,
|
||||
} from "coral-ui/components";
|
||||
import { Localized } from "fluent-react/compat";
|
||||
import React, { FunctionComponent, useCallback, useMemo } from "react";
|
||||
import { Field, Form } from "react-final-form";
|
||||
|
||||
import NotAvailable from "coral-admin/components/NotAvailable";
|
||||
import { GetMessage, withGetMessage } from "coral-framework/lib/i18n";
|
||||
|
||||
import styles from "./BanModal.css";
|
||||
|
||||
@@ -18,7 +21,8 @@ interface Props {
|
||||
username: string | null;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
onConfirm: (message?: string) => void;
|
||||
getMessage: GetMessage;
|
||||
}
|
||||
|
||||
const BanModal: FunctionComponent<Props> = ({
|
||||
@@ -26,51 +30,107 @@ const BanModal: FunctionComponent<Props> = ({
|
||||
onClose,
|
||||
onConfirm,
|
||||
username,
|
||||
}) => (
|
||||
<Modal open={open} onClose={onClose} aria-labelledby="banModal-title">
|
||||
{({ firstFocusableRef, lastFocusableRef }) => (
|
||||
<Card className={styles.card}>
|
||||
<CardCloseButton onClick={onClose} ref={firstFocusableRef} />
|
||||
<HorizontalGutter size="double">
|
||||
<HorizontalGutter>
|
||||
<Localized
|
||||
id="community-banModal-areYouSure"
|
||||
strong={<strong />}
|
||||
$username={username || <NotAvailable />}
|
||||
>
|
||||
<Typography variant="header2" id="banModal-title">
|
||||
Are you sure you want to ban{" "}
|
||||
<strong>{username || <NotAvailable />}</strong>?
|
||||
</Typography>
|
||||
</Localized>
|
||||
<Localized id="community-banModal-consequence">
|
||||
<Typography>
|
||||
Once banned, this user will no longer be able to comment, use
|
||||
reactions, or report comments.
|
||||
</Typography>
|
||||
</Localized>
|
||||
</HorizontalGutter>
|
||||
<Flex justifyContent="flex-end" itemGutter="half">
|
||||
<Localized id="community-banModal-cancel">
|
||||
<Button variant="outlined" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
</Localized>
|
||||
<Localized id="community-banModal-banUser">
|
||||
<Button
|
||||
variant="filled"
|
||||
color="primary"
|
||||
onClick={onConfirm}
|
||||
ref={lastFocusableRef}
|
||||
>
|
||||
Ban User
|
||||
</Button>
|
||||
</Localized>
|
||||
</Flex>
|
||||
</HorizontalGutter>
|
||||
</Card>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
getMessage,
|
||||
}) => {
|
||||
const getDefaultMessage = useMemo((): string => {
|
||||
return getMessage(
|
||||
"common-banEmailTemplate",
|
||||
"Someone with access to your account has violated our community guidelines. As a result, your account has been banned. You will no longer be able to comment, react or report comments",
|
||||
{
|
||||
username,
|
||||
}
|
||||
);
|
||||
}, [getMessage, username]);
|
||||
|
||||
export default BanModal;
|
||||
const onFormSubmit = useCallback(
|
||||
({ emailMessage }) => {
|
||||
onConfirm(emailMessage);
|
||||
},
|
||||
[onConfirm]
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} aria-labelledby="banModal-title">
|
||||
{({ firstFocusableRef, lastFocusableRef }) => (
|
||||
<Card className={styles.card}>
|
||||
<CardCloseButton onClick={onClose} ref={firstFocusableRef} />
|
||||
<HorizontalGutter size="double">
|
||||
<HorizontalGutter>
|
||||
<Localized
|
||||
id="community-banModal-areYouSure"
|
||||
strong={<strong />}
|
||||
$username={username || <NotAvailable />}
|
||||
>
|
||||
<Typography variant="header2" id="banModal-title">
|
||||
Are you sure you want to ban{" "}
|
||||
<strong>{username || <NotAvailable />}</strong>?
|
||||
</Typography>
|
||||
</Localized>
|
||||
<Localized id="community-banModal-consequence">
|
||||
<Typography>
|
||||
Once banned, this user will no longer be able to comment, use
|
||||
reactions, or report comments.
|
||||
</Typography>
|
||||
</Localized>
|
||||
</HorizontalGutter>
|
||||
<Form
|
||||
onSubmit={onFormSubmit}
|
||||
initialValues={{
|
||||
showMessage: false,
|
||||
emailMessage: getDefaultMessage,
|
||||
}}
|
||||
>
|
||||
{({ handleSubmit }) => (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Field name="showMessage">
|
||||
{({ input }) => (
|
||||
<Localized id="community-banModal-customize">
|
||||
<CheckBox {...input} id="banModal-showMessage">
|
||||
Customize ban email message
|
||||
</CheckBox>
|
||||
</Localized>
|
||||
)}
|
||||
</Field>
|
||||
<Field name="showMessage" subscription={{ value: true }}>
|
||||
{({ input: { value } }) =>
|
||||
value ? (
|
||||
<Field
|
||||
className={styles.textArea}
|
||||
id="banModal-message"
|
||||
component="textarea"
|
||||
name="emailMessage"
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
</Field>
|
||||
|
||||
<Flex justifyContent="flex-end" itemGutter="half">
|
||||
<Localized id="community-banModal-cancel">
|
||||
<Button variant="outlined" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
</Localized>
|
||||
<Localized id="community-banModal-banUser">
|
||||
<Button
|
||||
variant="filled"
|
||||
color="primary"
|
||||
type="submit"
|
||||
ref={lastFocusableRef}
|
||||
>
|
||||
Ban User
|
||||
</Button>
|
||||
</Localized>
|
||||
</Flex>
|
||||
</form>
|
||||
)}
|
||||
</Form>
|
||||
</HorizontalGutter>
|
||||
</Card>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const enhanced = withGetMessage(BanModal);
|
||||
|
||||
export default enhanced;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { graphql } from "react-relay";
|
||||
import { Environment } from "relay-runtime";
|
||||
|
||||
import { BanUserMutation as MutationTypes } from "coral-admin/__generated__/BanUserMutation.graphql";
|
||||
import { getViewer } from "coral-framework/helpers";
|
||||
import {
|
||||
commitMutationPromiseNormalized,
|
||||
createMutation,
|
||||
@@ -15,6 +16,7 @@ let clientMutationId = 0;
|
||||
const BanUserMutation = createMutation(
|
||||
"banUser",
|
||||
(environment: Environment, input: MutationInput<MutationTypes>) => {
|
||||
const viewer = getViewer(environment)!;
|
||||
return commitMutationPromiseNormalized<MutationTypes>(environment, {
|
||||
mutation: graphql`
|
||||
mutation BanUserMutation($input: BanUserInput!) {
|
||||
@@ -25,6 +27,14 @@ const BanUserMutation = createMutation(
|
||||
current
|
||||
ban {
|
||||
active
|
||||
history {
|
||||
active
|
||||
createdAt
|
||||
createdBy {
|
||||
id
|
||||
username
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -49,6 +59,16 @@ const BanUserMutation = createMutation(
|
||||
)!.status.current.concat(GQLUSER_STATUS.BANNED),
|
||||
ban: {
|
||||
active: true,
|
||||
history: [
|
||||
{
|
||||
active: true,
|
||||
createdAt: new Date(),
|
||||
createdBy: {
|
||||
id: viewer.id,
|
||||
username: viewer.username,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -25,6 +25,13 @@ const RemoveUserBanMutation = createMutation(
|
||||
current
|
||||
ban {
|
||||
active
|
||||
history {
|
||||
active
|
||||
createdAt
|
||||
createdBy {
|
||||
username
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -49,6 +56,7 @@ const RemoveUserBanMutation = createMutation(
|
||||
)!.status.current.filter(s => s !== GQLUSER_STATUS.BANNED),
|
||||
ban: {
|
||||
active: false,
|
||||
history: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { pick } from "lodash";
|
||||
import { graphql } from "react-relay";
|
||||
import { Environment } from "relay-runtime";
|
||||
|
||||
import { RemoveUserSuspensionMutation as MutationTypes } from "coral-admin/__generated__/RemoveUserSuspensionMutation.graphql";
|
||||
import { DeepWritable } from "coral-common/types";
|
||||
import {
|
||||
commitMutationPromiseNormalized,
|
||||
createMutation,
|
||||
lookup,
|
||||
MutationInput,
|
||||
} from "coral-framework/lib/relay";
|
||||
import { GQLUser, GQLUSER_STATUS } from "coral-framework/schema";
|
||||
|
||||
let clientMutationId = 0;
|
||||
|
||||
const RemoveUserSuspensionMutation = createMutation(
|
||||
"removeUserSuspension",
|
||||
(environment: Environment, input: MutationInput<MutationTypes>) => {
|
||||
const user = lookup<GQLUser>(environment, input.userID)!;
|
||||
let newHistory: DeepWritable<
|
||||
MutationTypes["response"]["removeUserSuspension"]["user"]["status"]["suspension"]["history"]
|
||||
> = [];
|
||||
if (user.status.suspension.history) {
|
||||
newHistory = user.status.suspension.history.map(h =>
|
||||
pick(h, [
|
||||
"active",
|
||||
"from.start",
|
||||
"from.finish",
|
||||
"createdBy.id",
|
||||
"createdBy.username",
|
||||
])
|
||||
) as any;
|
||||
newHistory[newHistory.length - 1].active = false;
|
||||
newHistory[newHistory.length - 1].from.finish = new Date().toISOString();
|
||||
}
|
||||
|
||||
return commitMutationPromiseNormalized<MutationTypes>(environment, {
|
||||
mutation: graphql`
|
||||
mutation RemoveUserSuspensionMutation(
|
||||
$input: RemoveUserSuspensionInput!
|
||||
) {
|
||||
removeUserSuspension(input: $input) {
|
||||
user {
|
||||
id
|
||||
status {
|
||||
current
|
||||
suspension {
|
||||
active
|
||||
history {
|
||||
active
|
||||
from {
|
||||
start
|
||||
finish
|
||||
}
|
||||
createdBy {
|
||||
id
|
||||
username
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
clientMutationId
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
input: {
|
||||
...input,
|
||||
clientMutationId: clientMutationId.toString(),
|
||||
},
|
||||
},
|
||||
optimisticResponse: {
|
||||
removeUserSuspension: {
|
||||
user: {
|
||||
id: input.userID,
|
||||
status: {
|
||||
current: user.status.current.concat(GQLUSER_STATUS.SUSPENDED),
|
||||
suspension: {
|
||||
active: false,
|
||||
history: newHistory,
|
||||
},
|
||||
},
|
||||
},
|
||||
clientMutationId: (clientMutationId++).toString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
export default RemoveUserSuspensionMutation;
|
||||
@@ -0,0 +1,203 @@
|
||||
import { Mutator } from "final-form";
|
||||
import { Localized } from "fluent-react/compat";
|
||||
import React, { FunctionComponent, useCallback } from "react";
|
||||
import { Field, Form } from "react-final-form";
|
||||
|
||||
import { ScaledUnit } from "coral-common/helpers/i18n";
|
||||
import { GetMessage, withGetMessage } from "coral-framework/lib/i18n";
|
||||
import {
|
||||
Button,
|
||||
CheckBox,
|
||||
Flex,
|
||||
HorizontalGutter,
|
||||
RadioButton,
|
||||
} from "coral-ui/components";
|
||||
|
||||
import styles from "./SuspendModal.css";
|
||||
|
||||
interface Props {
|
||||
username: string | null;
|
||||
onCancel: () => void;
|
||||
getMessage: GetMessage;
|
||||
organizationName: string;
|
||||
onSubmit: (duration: ScaledUnit, message: string) => void;
|
||||
}
|
||||
|
||||
const DURATIONS: ScaledUnit[] = [
|
||||
{ original: 3600, value: "3600", unit: "hour", scaled: 1 }, // 1 hour
|
||||
{ original: 10800, value: "10800", unit: "hour", scaled: 3 }, // 3 hours
|
||||
{ original: 86400, value: "86400", unit: "hour", scaled: 24 }, // 24 hours
|
||||
{ original: 604800, value: "604800", unit: "day", scaled: 7 }, // 7 days
|
||||
];
|
||||
|
||||
const DEFAULT_DURATION = DURATIONS[0]; // 1 hour
|
||||
|
||||
const SuspendForm: FunctionComponent<Props> = ({
|
||||
onCancel,
|
||||
username,
|
||||
getMessage,
|
||||
onSubmit,
|
||||
organizationName,
|
||||
}) => {
|
||||
const getMessageWithDuration = useCallback(
|
||||
({ scaled, unit }: Pick<ScaledUnit, "scaled" | "unit">): string => {
|
||||
return getMessage(
|
||||
"community-suspendModal-emailTemplate",
|
||||
`your account has been temporarily suspended. During the suspension, you will be unable to comment, flag or engage with fellow commenters. Please rejoin the conversation in ${scaled} ${unit}`,
|
||||
{
|
||||
username,
|
||||
organizationName,
|
||||
value: scaled,
|
||||
unit,
|
||||
}
|
||||
);
|
||||
},
|
||||
[username, organizationName]
|
||||
);
|
||||
|
||||
const onFormSubmit = useCallback(
|
||||
({ duration, emailMessage }) => {
|
||||
const unit = DURATIONS.find(d => d.value === duration);
|
||||
onSubmit(unit!, emailMessage);
|
||||
},
|
||||
[onSubmit]
|
||||
);
|
||||
|
||||
const setMessageValue: Mutator = useCallback(
|
||||
([name, newValue], state, { changeValue }) => {
|
||||
if (state.lastFormState) {
|
||||
const { duration, emailMessage } = state.lastFormState.values;
|
||||
const unit = DURATIONS.find(d => d.value === duration);
|
||||
const expectedEmailMessage = getMessageWithDuration(unit!);
|
||||
if (expectedEmailMessage === emailMessage) {
|
||||
changeValue(state, name, () => newValue);
|
||||
}
|
||||
}
|
||||
},
|
||||
[getMessageWithDuration]
|
||||
);
|
||||
|
||||
const resetMessageValue: Mutator = (
|
||||
[name, checked],
|
||||
state,
|
||||
{ changeValue }
|
||||
) => {
|
||||
if (state.lastFormState && !checked) {
|
||||
const { duration, emailMessage } = state.lastFormState.values;
|
||||
const unit = DURATIONS.find(d => d.value === duration);
|
||||
const expectedEmailMessage = getMessageWithDuration(unit!);
|
||||
if (expectedEmailMessage !== emailMessage) {
|
||||
changeValue(state, name, () => expectedEmailMessage);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form
|
||||
onSubmit={onFormSubmit}
|
||||
mutators={{
|
||||
setMessageValue,
|
||||
resetMessageValue,
|
||||
}}
|
||||
initialValues={{
|
||||
duration: DEFAULT_DURATION.value,
|
||||
emailMessage: getMessageWithDuration(DEFAULT_DURATION),
|
||||
}}
|
||||
>
|
||||
{({ handleSubmit, form }) => (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<HorizontalGutter spacing={2}>
|
||||
<div>
|
||||
{DURATIONS.map(({ original, value, scaled, unit }) => (
|
||||
<Field
|
||||
key={value}
|
||||
name="duration"
|
||||
type="radio"
|
||||
component="input"
|
||||
value={value}
|
||||
>
|
||||
{({ input }) => (
|
||||
<Localized
|
||||
id="framework-timeago-time"
|
||||
$value={scaled}
|
||||
$unit={unit}
|
||||
>
|
||||
<RadioButton
|
||||
id={`duration-${value}`}
|
||||
{...input}
|
||||
onChange={event => {
|
||||
form.mutators.setMessageValue(
|
||||
"emailMessage",
|
||||
getMessageWithDuration({ scaled, unit })
|
||||
);
|
||||
input.onChange(event);
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
{scaled} {unit}
|
||||
</span>
|
||||
</RadioButton>
|
||||
</Localized>
|
||||
)}
|
||||
</Field>
|
||||
))}
|
||||
</div>
|
||||
<Field type="checkbox" name="editMessage">
|
||||
{({ input }) => (
|
||||
<Localized id="community-suspendModal-customize">
|
||||
<CheckBox
|
||||
id="suspendModal-editMessage"
|
||||
{...input}
|
||||
onChange={event => {
|
||||
form.mutators.resetMessageValue(
|
||||
"emailMessage",
|
||||
!input.checked
|
||||
);
|
||||
input.onChange(event);
|
||||
}}
|
||||
>
|
||||
Customize suspension email message
|
||||
</CheckBox>
|
||||
</Localized>
|
||||
)}
|
||||
</Field>
|
||||
<Field name="editMessage" subscription={{ value: true }}>
|
||||
{({ input: { value } }) =>
|
||||
value ? (
|
||||
<Field
|
||||
className={styles.textArea}
|
||||
id="suspendModal-message"
|
||||
component="textarea"
|
||||
name="emailMessage"
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
</Field>
|
||||
</HorizontalGutter>
|
||||
<Flex
|
||||
className={styles.footer}
|
||||
justifyContent="flex-end"
|
||||
itemGutter="half"
|
||||
>
|
||||
<Localized id="community-suspendModal-cancel">
|
||||
<Button variant="outlined" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
</Localized>
|
||||
<Localized id="community-suspendModal-suspendUser">
|
||||
<Button variant="filled" color="primary" type="submit">
|
||||
Suspend User
|
||||
</Button>
|
||||
</Localized>
|
||||
</Flex>
|
||||
</form>
|
||||
)}
|
||||
</Form>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const enhanced = withGetMessage(SuspendForm);
|
||||
|
||||
export default enhanced;
|
||||
@@ -0,0 +1,22 @@
|
||||
.card {
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.radioButton {
|
||||
margin: 0 var(--spacing-1) 0 0 !important;
|
||||
}
|
||||
|
||||
.textArea {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
height: calc(12 * var(--mini-unit));
|
||||
padding: calc(0.5 * var(--mini-unit));
|
||||
}
|
||||
|
||||
.header {
|
||||
margin: 0 0 var(--spacing-3) 0 !important;
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: var(--spacing-3);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { Localized } from "fluent-react/compat";
|
||||
import React, { FunctionComponent, useCallback, useState } from "react";
|
||||
|
||||
import NotAvailable from "coral-admin/components/NotAvailable";
|
||||
import { ScaledUnit } from "coral-common/helpers/i18n";
|
||||
import { GetMessage, withGetMessage } from "coral-framework/lib/i18n";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardCloseButton,
|
||||
Flex,
|
||||
HorizontalGutter,
|
||||
Modal,
|
||||
Typography,
|
||||
} from "coral-ui/components";
|
||||
|
||||
import SuspendForm from "./SuspendForm";
|
||||
|
||||
import styles from "./SuspendModal.css";
|
||||
|
||||
interface Props {
|
||||
username: string | null;
|
||||
getMessage: GetMessage;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: (timeout: number, message: string) => void;
|
||||
organizationName: string;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
const SuspendModal: FunctionComponent<Props> = ({
|
||||
open,
|
||||
onClose,
|
||||
onConfirm,
|
||||
getMessage,
|
||||
username,
|
||||
success,
|
||||
organizationName,
|
||||
}) => {
|
||||
const [successDuration, setSuccessDuration] = useState("");
|
||||
const onFormSubmit = useCallback(
|
||||
({ original, scaled, unit }: ScaledUnit, message: string) => {
|
||||
setSuccessDuration(
|
||||
getMessage("framework-timeago-time", `${scaled} ${unit}`, {
|
||||
value: scaled,
|
||||
unit,
|
||||
})
|
||||
);
|
||||
onConfirm(original, message);
|
||||
},
|
||||
[onConfirm]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal open={open} onClose={onClose} aria-labelledby="suspendModal-title">
|
||||
{({ firstFocusableRef, lastFocusableRef }) => (
|
||||
<Card className={styles.card}>
|
||||
<CardCloseButton onClick={onClose} ref={firstFocusableRef} />
|
||||
{success && (
|
||||
<HorizontalGutter spacing={2}>
|
||||
<HorizontalGutter>
|
||||
<Localized
|
||||
id="community-suspendModal-success"
|
||||
$username={username}
|
||||
strong={<strong />}
|
||||
$duration={successDuration}
|
||||
>
|
||||
<Typography>
|
||||
<strong>{username}</strong> has been suspended for{" "}
|
||||
<strong>{successDuration}</strong>
|
||||
</Typography>
|
||||
</Localized>
|
||||
</HorizontalGutter>
|
||||
|
||||
<Flex justifyContent="flex-end" itemGutter="half">
|
||||
<Localized id="community-suspendModal-success-close">
|
||||
<Button variant="filled" color="primary" onClick={onClose}>
|
||||
Ok
|
||||
</Button>
|
||||
</Localized>
|
||||
</Flex>
|
||||
</HorizontalGutter>
|
||||
)}
|
||||
{!success && (
|
||||
<HorizontalGutter spacing={2}>
|
||||
<Localized
|
||||
id="community-suspendModal-areYouSure"
|
||||
strong={<strong />}
|
||||
$username={username || <NotAvailable />}
|
||||
>
|
||||
<Typography
|
||||
className={styles.header}
|
||||
variant="header2"
|
||||
id="suspendModal-title"
|
||||
>
|
||||
Suspend <strong>{username || <NotAvailable />}</strong>?
|
||||
</Typography>
|
||||
</Localized>
|
||||
<Localized id="community-suspendModal-consequence">
|
||||
<Typography>
|
||||
While suspended, this user will no longer be able to
|
||||
comment, use reactions, or report comments.
|
||||
</Typography>
|
||||
</Localized>
|
||||
|
||||
<Localized id="community-suspendModal-selectDuration">
|
||||
<Typography variant="header3">
|
||||
Select suspension length
|
||||
</Typography>
|
||||
</Localized>
|
||||
|
||||
<SuspendForm
|
||||
username={username}
|
||||
onCancel={onClose}
|
||||
organizationName={organizationName}
|
||||
onSubmit={onFormSubmit}
|
||||
/>
|
||||
</HorizontalGutter>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const enhanced = withGetMessage(SuspendModal);
|
||||
|
||||
export default enhanced;
|
||||
@@ -0,0 +1,93 @@
|
||||
import { DateTime } from "luxon";
|
||||
import { graphql } from "react-relay";
|
||||
import { Environment } from "relay-runtime";
|
||||
|
||||
import { SuspendUserMutation as MutationTypes } from "coral-admin/__generated__/SuspendUserMutation.graphql";
|
||||
import { getViewer } from "coral-framework/helpers";
|
||||
import {
|
||||
commitMutationPromiseNormalized,
|
||||
createMutation,
|
||||
lookup,
|
||||
MutationInput,
|
||||
} from "coral-framework/lib/relay";
|
||||
import { GQLUser, GQLUSER_STATUS } from "coral-framework/schema";
|
||||
|
||||
let clientMutationId = 0;
|
||||
|
||||
const SuspendUserMutation = createMutation(
|
||||
"suspendUser",
|
||||
(environment: Environment, input: MutationInput<MutationTypes>) => {
|
||||
const viewer = getViewer(environment)!;
|
||||
const now = new Date();
|
||||
const finish = DateTime.fromJSDate(now).plus({
|
||||
seconds: input.timeout,
|
||||
});
|
||||
return commitMutationPromiseNormalized<MutationTypes>(environment, {
|
||||
mutation: graphql`
|
||||
mutation SuspendUserMutation($input: SuspendUserInput!) {
|
||||
suspendUser(input: $input) {
|
||||
user {
|
||||
id
|
||||
status {
|
||||
current
|
||||
suspension {
|
||||
active
|
||||
history {
|
||||
active
|
||||
from {
|
||||
start
|
||||
finish
|
||||
}
|
||||
createdBy {
|
||||
id
|
||||
username
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
clientMutationId
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
input: {
|
||||
...input,
|
||||
clientMutationId: clientMutationId.toString(),
|
||||
},
|
||||
},
|
||||
optimisticResponse: {
|
||||
suspendUser: {
|
||||
user: {
|
||||
id: input.userID,
|
||||
status: {
|
||||
current: lookup<GQLUser>(
|
||||
environment,
|
||||
input.userID
|
||||
)!.status.current.concat(GQLUSER_STATUS.SUSPENDED),
|
||||
suspension: {
|
||||
active: true,
|
||||
history: [
|
||||
{
|
||||
active: true,
|
||||
from: {
|
||||
start: now.toISOString(),
|
||||
finish: finish.toISODate(),
|
||||
},
|
||||
createdBy: {
|
||||
id: viewer.id,
|
||||
username: viewer.username,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
clientMutationId: (clientMutationId++).toString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
export default SuspendUserMutation;
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { FunctionComponent, useCallback, useState } from "react";
|
||||
|
||||
import { UserStatusChangeContainer_settings as SettingsData } from "coral-admin/__generated__/UserStatusChangeContainer_settings.graphql";
|
||||
import { UserStatusChangeContainer_user as UserData } from "coral-admin/__generated__/UserStatusChangeContainer_user.graphql";
|
||||
import {
|
||||
graphql,
|
||||
@@ -12,21 +13,27 @@ import ButtonPadding from "../ButtonPadding";
|
||||
import BanModal from "./BanModal";
|
||||
import BanUserMutation from "./BanUserMutation";
|
||||
import RemoveUserBanMutation from "./RemoveUserBanMutation";
|
||||
import RemoveUserSuspensionMutation from "./RemoveUserSuspensionMutation";
|
||||
import SuspendModal from "./SuspendModal";
|
||||
import SuspendUserMutation from "./SuspendUserMutation";
|
||||
import UserStatusChange from "./UserStatusChange";
|
||||
import UserStatusContainer from "./UserStatusContainer";
|
||||
|
||||
interface Props {
|
||||
user: UserData;
|
||||
fullWidth?: boolean;
|
||||
settings: SettingsData;
|
||||
}
|
||||
|
||||
const UserStatusChangeContainer: FunctionComponent<Props> = ({
|
||||
user,
|
||||
fullWidth = false,
|
||||
}) => {
|
||||
const UserStatusChangeContainer: FunctionComponent<Props> = props => {
|
||||
const { user, settings, fullWidth } = props;
|
||||
const banUser = useMutation(BanUserMutation);
|
||||
const suspendUser = useMutation(SuspendUserMutation);
|
||||
const removeUserBan = useMutation(RemoveUserBanMutation);
|
||||
const removeUserSuspension = useMutation(RemoveUserSuspensionMutation);
|
||||
const [showBanned, setShowBanned] = useState<boolean>(false);
|
||||
const [showSuspend, setShowSuspend] = useState<boolean>(false);
|
||||
const [showSuspendSuccess, setShowSuspendSuccess] = useState<boolean>(false);
|
||||
const handleBan = useCallback(() => {
|
||||
if (user.status.ban.active) {
|
||||
return;
|
||||
@@ -43,14 +50,43 @@ const UserStatusChangeContainer: FunctionComponent<Props> = ({
|
||||
if (user.status.suspension.active) {
|
||||
return;
|
||||
}
|
||||
// TODO: (cvle)
|
||||
}, [user]);
|
||||
setShowSuspend(true);
|
||||
}, [user, setShowSuspend]);
|
||||
const handleRemoveSuspension = useCallback(() => {
|
||||
if (!user.status.suspension.active) {
|
||||
return;
|
||||
}
|
||||
// TODO: (cvle)
|
||||
}, [user]);
|
||||
removeUserSuspension({ userID: user.id });
|
||||
}, [user, removeUserSuspension]);
|
||||
|
||||
const handleSuspendModalClose = useCallback(() => {
|
||||
setShowSuspend(false);
|
||||
setShowSuspendSuccess(false);
|
||||
}, [setShowBanned, setShowSuspendSuccess]);
|
||||
|
||||
const handleBanModalClose = useCallback(() => {
|
||||
setShowBanned(false);
|
||||
}, [setShowBanned]);
|
||||
|
||||
const handleSuspendConfirm = useCallback(
|
||||
(timeout, message) => {
|
||||
suspendUser({
|
||||
userID: user.id,
|
||||
timeout,
|
||||
message,
|
||||
});
|
||||
setShowSuspendSuccess(true);
|
||||
},
|
||||
[user, suspendUser, setShowSuspendSuccess]
|
||||
);
|
||||
|
||||
const handleBanConfirm = useCallback(
|
||||
message => {
|
||||
banUser({ userID: user.id, message });
|
||||
setShowBanned(false);
|
||||
},
|
||||
[user, setShowBanned]
|
||||
);
|
||||
|
||||
if (user.role !== GQLUSER_ROLE.COMMENTER) {
|
||||
return (
|
||||
@@ -73,14 +109,19 @@ const UserStatusChangeContainer: FunctionComponent<Props> = ({
|
||||
>
|
||||
<UserStatusContainer user={user} />
|
||||
</UserStatusChange>
|
||||
<SuspendModal
|
||||
username={user.username}
|
||||
open={showSuspend || showSuspendSuccess}
|
||||
success={showSuspendSuccess}
|
||||
onClose={handleSuspendModalClose}
|
||||
organizationName={settings.organization.name}
|
||||
onConfirm={handleSuspendConfirm}
|
||||
/>
|
||||
<BanModal
|
||||
username={user.username}
|
||||
open={showBanned}
|
||||
onClose={() => setShowBanned(false)}
|
||||
onConfirm={() => {
|
||||
banUser({ userID: user.id });
|
||||
setShowBanned(false);
|
||||
}}
|
||||
onClose={handleBanModalClose}
|
||||
onConfirm={handleBanConfirm}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
@@ -89,7 +130,6 @@ const UserStatusChangeContainer: FunctionComponent<Props> = ({
|
||||
const enhanced = withFragmentContainer<Props>({
|
||||
user: graphql`
|
||||
fragment UserStatusChangeContainer_user on User {
|
||||
...UserStatusContainer_user
|
||||
id
|
||||
role
|
||||
username
|
||||
@@ -101,6 +141,14 @@ const enhanced = withFragmentContainer<Props>({
|
||||
active
|
||||
}
|
||||
}
|
||||
...UserStatusContainer_user
|
||||
}
|
||||
`,
|
||||
settings: graphql`
|
||||
fragment UserStatusChangeContainer_settings on Settings {
|
||||
organization {
|
||||
name
|
||||
}
|
||||
}
|
||||
`,
|
||||
})(UserStatusChangeContainer);
|
||||
|
||||
@@ -17,7 +17,7 @@ exports[`renders correctly 1`] = `
|
||||
<div
|
||||
className="AuthBox-brandIcon"
|
||||
>
|
||||
<withPropsOnChange(BrandIcon)
|
||||
<withPropsOnChange(BrandMark)
|
||||
size="lg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user