mirror of
https://github.com/wassname/talk.git
synced 2026-06-27 19:33:06 +08:00
[CORL-294] Moderate a single story + quick search (#2286)
* feat: allow passing a `storyID` to `Query.moderationQueues` * feat: moderate by story * feat: implement search story combobox * feat: add translations * fix: tests * fix: duplicate id * fix: rename file * chore: add more comments * fix: add missing translation * review: use query parameter "q" instead of url path * chore: move placeholder logic inside, maybe this makes it clearer :-D
This commit is contained in:
Generated
+43
-12
@@ -2928,6 +2928,12 @@
|
||||
"@types/mime": "*"
|
||||
}
|
||||
},
|
||||
"@types/shallow-equals": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/shallow-equals/-/shallow-equals-1.0.0.tgz",
|
||||
"integrity": "sha512-XtGSj7GYPfJwaklDtMEONj+kmpyCP8OLYoPqp/ROM8BL1VaF2IgYbxiEKfLvOyHN7c2d1KAFYzy6EIu8CSFt1A==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/simplemde": {
|
||||
"version": "1.11.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/simplemde/-/simplemde-1.11.7.tgz",
|
||||
@@ -12117,7 +12123,7 @@
|
||||
"integrity": "sha1-ETOUSrJHeINHOZVZaIPg05z4hc8=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"intl-pluralrules": "github:projectfluent/IntlPluralRules#94cb0fa1c23ad943bc5aafef43cea132fa51d68b"
|
||||
"intl-pluralrules": "github:projectfluent/IntlPluralRules#module"
|
||||
}
|
||||
},
|
||||
"fluent-langneg": {
|
||||
@@ -12427,7 +12433,8 @@
|
||||
"ansi-regex": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz",
|
||||
"integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8="
|
||||
"integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=",
|
||||
"optional": true
|
||||
},
|
||||
"aproba": {
|
||||
"version": "1.2.0",
|
||||
@@ -12448,12 +12455,14 @@
|
||||
"balanced-match": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz",
|
||||
"integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c="
|
||||
"integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=",
|
||||
"optional": true
|
||||
},
|
||||
"brace-expansion": {
|
||||
"version": "1.1.11",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
|
||||
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
@@ -12468,17 +12477,20 @@
|
||||
"code-point-at": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz",
|
||||
"integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c="
|
||||
"integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=",
|
||||
"optional": true
|
||||
},
|
||||
"concat-map": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
"integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s="
|
||||
"integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=",
|
||||
"optional": true
|
||||
},
|
||||
"console-control-strings": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz",
|
||||
"integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4="
|
||||
"integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=",
|
||||
"optional": true
|
||||
},
|
||||
"core-util-is": {
|
||||
"version": "1.0.2",
|
||||
@@ -12595,7 +12607,8 @@
|
||||
"inherits": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz",
|
||||
"integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4="
|
||||
"integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=",
|
||||
"optional": true
|
||||
},
|
||||
"ini": {
|
||||
"version": "1.3.5",
|
||||
@@ -12607,6 +12620,7 @@
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz",
|
||||
"integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=",
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"number-is-nan": "^1.0.0"
|
||||
}
|
||||
@@ -12621,6 +12635,7 @@
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz",
|
||||
"integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==",
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
}
|
||||
@@ -12628,12 +12643,14 @@
|
||||
"minimist": {
|
||||
"version": "0.0.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz",
|
||||
"integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0="
|
||||
"integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=",
|
||||
"optional": true
|
||||
},
|
||||
"minipass": {
|
||||
"version": "2.2.4",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-2.2.4.tgz",
|
||||
"integrity": "sha512-hzXIWWet/BzWhYs2b+u7dRHlruXhwdgvlTMDKC6Cb1U7ps6Ac6yQlR39xsbjWJE377YTCtKwIXIpJ5oP+j5y8g==",
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"safe-buffer": "^5.1.1",
|
||||
"yallist": "^3.0.0"
|
||||
@@ -12652,6 +12669,7 @@
|
||||
"version": "0.5.1",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz",
|
||||
"integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=",
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"minimist": "0.0.8"
|
||||
}
|
||||
@@ -12732,7 +12750,8 @@
|
||||
"number-is-nan": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz",
|
||||
"integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0="
|
||||
"integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=",
|
||||
"optional": true
|
||||
},
|
||||
"object-assign": {
|
||||
"version": "4.1.1",
|
||||
@@ -12744,6 +12763,7 @@
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||
"integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=",
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"wrappy": "1"
|
||||
}
|
||||
@@ -12829,7 +12849,8 @@
|
||||
"safe-buffer": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.1.tgz",
|
||||
"integrity": "sha512-kKvNJn6Mm93gAczWVJg7wH+wGYWNrDHdWvpUmHyEsgCtIwwo3bqPtV4tR5tuPaUhTOo/kvhVwd8XwwOllGYkbg=="
|
||||
"integrity": "sha512-kKvNJn6Mm93gAczWVJg7wH+wGYWNrDHdWvpUmHyEsgCtIwwo3bqPtV4tR5tuPaUhTOo/kvhVwd8XwwOllGYkbg==",
|
||||
"optional": true
|
||||
},
|
||||
"safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
@@ -12865,6 +12886,7 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz",
|
||||
"integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=",
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"code-point-at": "^1.0.0",
|
||||
"is-fullwidth-code-point": "^1.0.0",
|
||||
@@ -12884,6 +12906,7 @@
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz",
|
||||
"integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=",
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"ansi-regex": "^2.0.0"
|
||||
}
|
||||
@@ -12927,12 +12950,14 @@
|
||||
"wrappy": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
"integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8="
|
||||
"integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=",
|
||||
"optional": true
|
||||
},
|
||||
"yallist": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.0.2.tgz",
|
||||
"integrity": "sha1-hFK0u36Dx8GI2AQcGoN8dz1ti7k="
|
||||
"integrity": "sha1-hFK0u36Dx8GI2AQcGoN8dz1ti7k=",
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -25820,6 +25845,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"shallow-equals": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shallow-equals/-/shallow-equals-1.0.0.tgz",
|
||||
"integrity": "sha1-JLdL8cY0wR7Uxxgqbfb7MA3OQ5A=",
|
||||
"dev": true
|
||||
},
|
||||
"shallowequal": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz",
|
||||
|
||||
@@ -182,6 +182,7 @@
|
||||
"@types/recompose": "^0.26.5",
|
||||
"@types/relay-runtime": "^1.3.6",
|
||||
"@types/sane": "^2.0.0",
|
||||
"@types/shallow-equals": "^1.0.0",
|
||||
"@types/simplemde": "^1.11.7",
|
||||
"@types/sinon": "^5.0.1",
|
||||
"@types/source-map-support": "^0.4.1",
|
||||
@@ -284,6 +285,7 @@
|
||||
"relay-local-schema": "^0.7.0",
|
||||
"relay-runtime": "^1.7.0-rc.1",
|
||||
"sane": "^4.0.2",
|
||||
"shallow-equals": "^1.0.0",
|
||||
"simplemde": "^1.11.2",
|
||||
"simulant": "^0.2.2",
|
||||
"sinon": "^6.1.5",
|
||||
|
||||
@@ -2,38 +2,31 @@ import { graphql } from "react-relay";
|
||||
import { Environment } from "relay-runtime";
|
||||
|
||||
import { DiscoverOIDCConfigurationQuery as QueryTypes } from "talk-admin/__generated__/DiscoverOIDCConfigurationQuery.graphql";
|
||||
import { createFetchContainer, fetchQuery } from "talk-framework/lib/relay";
|
||||
import {
|
||||
createFetch,
|
||||
fetchQuery,
|
||||
FetchVariables,
|
||||
} from "talk-framework/lib/relay";
|
||||
|
||||
export type DiscoverOIDCConfigurationVariables = QueryTypes["variables"];
|
||||
|
||||
const query = graphql`
|
||||
query DiscoverOIDCConfigurationQuery($issuer: String!) {
|
||||
discoverOIDCConfiguration(issuer: $issuer) {
|
||||
issuer
|
||||
authorizationURL
|
||||
tokenURL
|
||||
jwksURI
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
function fetch(
|
||||
environment: Environment,
|
||||
variables: DiscoverOIDCConfigurationVariables
|
||||
) {
|
||||
return fetchQuery<QueryTypes["response"]["discoverOIDCConfiguration"]>(
|
||||
environment,
|
||||
query,
|
||||
variables,
|
||||
{ force: true }
|
||||
);
|
||||
}
|
||||
|
||||
export const withDiscoverOIDCConfigurationFetch = createFetchContainer(
|
||||
const DiscoverOIDCConfigurationFetch = createFetch(
|
||||
"discoverOIDCConfiguration",
|
||||
fetch
|
||||
(environment: Environment, variables: FetchVariables<QueryTypes>) => {
|
||||
return fetchQuery<QueryTypes>(
|
||||
environment,
|
||||
graphql`
|
||||
query DiscoverOIDCConfigurationQuery($issuer: String!) {
|
||||
discoverOIDCConfiguration(issuer: $issuer) {
|
||||
issuer
|
||||
authorizationURL
|
||||
tokenURL
|
||||
jwksURI
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables,
|
||||
{ force: true }
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export type DiscoverOIDCConfigurationFetch = (
|
||||
variables: DiscoverOIDCConfigurationVariables
|
||||
) => Promise<QueryTypes["response"]["discoverOIDCConfiguration"]>;
|
||||
export default DiscoverOIDCConfigurationFetch;
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { graphql } from "react-relay";
|
||||
import { Environment } from "relay-runtime";
|
||||
|
||||
import { SearchStoryQuery as QueryTypes } from "talk-admin/__generated__/SearchStoryQuery.graphql";
|
||||
import {
|
||||
createFetch,
|
||||
fetchQuery,
|
||||
FetchVariables,
|
||||
} from "talk-framework/lib/relay";
|
||||
|
||||
const SearchStoryFetch = createFetch(
|
||||
"searchStory",
|
||||
(environment: Environment, variables: FetchVariables<QueryTypes>) => {
|
||||
return fetchQuery<QueryTypes>(
|
||||
environment,
|
||||
graphql`
|
||||
query SearchStoryQuery($query: String!, $limit: Int!) {
|
||||
stories(query: $query, first: $limit) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
metadata {
|
||||
title
|
||||
author
|
||||
}
|
||||
}
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables,
|
||||
{ force: true }
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export default SearchStoryFetch;
|
||||
@@ -1,4 +1,4 @@
|
||||
export {
|
||||
withDiscoverOIDCConfigurationFetch,
|
||||
DiscoverOIDCConfigurationFetch,
|
||||
default as DiscoverOIDCConfigurationFetch,
|
||||
} from "./DiscoverOIDCConfigurationQuery";
|
||||
export { default as SearchStoryFetch } from "./SearchStoryQuery";
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
const basePath = "/admin/moderate";
|
||||
|
||||
export default function getModerationLink(
|
||||
queue?: "default" | "reported" | "pending" | "unmoderated" | "rejected",
|
||||
storyID?: string | null
|
||||
) {
|
||||
const queuePart = queue && queue !== "default" ? `/${queue}` : "";
|
||||
const storyPart = storyID ? `/${storyID}` : "";
|
||||
return `${basePath}${queuePart}${storyPart}`;
|
||||
}
|
||||
@@ -3,16 +3,18 @@ import { ConnectionHandler, RecordSourceSelectorProxy } from "relay-runtime";
|
||||
type Queue = "reported" | "pending" | "unmoderated" | "rejected";
|
||||
|
||||
export default function getQueueConnection(
|
||||
store: RecordSourceSelectorProxy,
|
||||
queue: Queue,
|
||||
store: RecordSourceSelectorProxy
|
||||
storyID?: string
|
||||
) {
|
||||
const root = store.getRoot();
|
||||
if (queue === "rejected") {
|
||||
return ConnectionHandler.getConnection(root, "RejectedQueue_comments", {
|
||||
status: "REJECTED",
|
||||
storyID,
|
||||
});
|
||||
}
|
||||
const queuesRecord = root.getLinkedRecord("moderationQueues")!;
|
||||
const queuesRecord = root.getLinkedRecord("moderationQueues", { storyID })!;
|
||||
if (!queuesRecord) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export { default as getQueueConnection } from "./getQueueConnection";
|
||||
export { default as getModerationLink } from "./getModerationLink";
|
||||
|
||||
@@ -13,16 +13,22 @@ let clientMutationId = 0;
|
||||
|
||||
const AcceptCommentMutation = createMutation(
|
||||
"acceptComment",
|
||||
(environment: Environment, input: MutationInput<MutationTypes>) =>
|
||||
(
|
||||
environment: Environment,
|
||||
input: MutationInput<MutationTypes> & { storyID?: string }
|
||||
) =>
|
||||
commitMutationPromiseNormalized<MutationTypes>(environment, {
|
||||
mutation: graphql`
|
||||
mutation AcceptCommentMutation($input: AcceptCommentInput!) {
|
||||
mutation AcceptCommentMutation(
|
||||
$input: AcceptCommentInput!
|
||||
$storyID: ID
|
||||
) {
|
||||
acceptComment(input: $input) {
|
||||
comment {
|
||||
id
|
||||
status
|
||||
}
|
||||
moderationQueues {
|
||||
moderationQueues(storyID: $storyID) {
|
||||
unmoderated {
|
||||
count
|
||||
}
|
||||
@@ -39,7 +45,8 @@ const AcceptCommentMutation = createMutation(
|
||||
`,
|
||||
variables: {
|
||||
input: {
|
||||
...input,
|
||||
commentID: input.commentID,
|
||||
commentRevisionID: input.commentRevisionID,
|
||||
clientMutationId: clientMutationId.toString(),
|
||||
},
|
||||
},
|
||||
@@ -54,10 +61,10 @@ const AcceptCommentMutation = createMutation(
|
||||
},
|
||||
updater: store => {
|
||||
const connections = [
|
||||
getQueueConnection("reported", store),
|
||||
getQueueConnection("pending", store),
|
||||
getQueueConnection("unmoderated", store),
|
||||
getQueueConnection("rejected", store),
|
||||
getQueueConnection(store, "reported", input.storyID),
|
||||
getQueueConnection(store, "pending", input.storyID),
|
||||
getQueueConnection(store, "unmoderated", input.storyID),
|
||||
getQueueConnection(store, "rejected", input.storyID),
|
||||
].filter(c => c);
|
||||
connections.forEach(con =>
|
||||
ConnectionHandler.deleteNode(con, input.commentID)
|
||||
|
||||
@@ -13,16 +13,22 @@ let clientMutationId = 0;
|
||||
|
||||
const RejectCommentMutation = createMutation(
|
||||
"rejectComment",
|
||||
(environment: Environment, input: MutationInput<MutationTypes>) =>
|
||||
(
|
||||
environment: Environment,
|
||||
input: MutationInput<MutationTypes> & { storyID?: string }
|
||||
) =>
|
||||
commitMutationPromiseNormalized<MutationTypes>(environment, {
|
||||
mutation: graphql`
|
||||
mutation RejectCommentMutation($input: RejectCommentInput!) {
|
||||
mutation RejectCommentMutation(
|
||||
$input: RejectCommentInput!
|
||||
$storyID: ID
|
||||
) {
|
||||
rejectComment(input: $input) {
|
||||
comment {
|
||||
id
|
||||
status
|
||||
}
|
||||
moderationQueues {
|
||||
moderationQueues(storyID: $storyID) {
|
||||
unmoderated {
|
||||
count
|
||||
}
|
||||
@@ -39,7 +45,8 @@ const RejectCommentMutation = createMutation(
|
||||
`,
|
||||
variables: {
|
||||
input: {
|
||||
...input,
|
||||
commentID: input.commentID,
|
||||
commentRevisionID: input.commentRevisionID,
|
||||
clientMutationId: clientMutationId.toString(),
|
||||
},
|
||||
},
|
||||
@@ -54,9 +61,9 @@ const RejectCommentMutation = createMutation(
|
||||
},
|
||||
updater: store => {
|
||||
const connections = [
|
||||
getQueueConnection("reported", store),
|
||||
getQueueConnection("pending", store),
|
||||
getQueueConnection("unmoderated", store),
|
||||
getQueueConnection(store, "reported", input.storyID),
|
||||
getQueueConnection(store, "pending", input.storyID),
|
||||
getQueueConnection(store, "unmoderated", input.storyID),
|
||||
].filter(c => c);
|
||||
connections.forEach(con =>
|
||||
ConnectionHandler.deleteNode(con, input.commentID)
|
||||
|
||||
@@ -36,12 +36,29 @@ export default makeRouteConfig(
|
||||
<Route path="moderate" {...ModerateContainer.routeConfig}>
|
||||
<Redirect from="/" to="/admin/moderate/reported" />
|
||||
<Route path="reported" {...ReportedQueueContainer.routeConfig} />
|
||||
<Route
|
||||
path="reported/:storyID"
|
||||
{...ReportedQueueContainer.routeConfig}
|
||||
/>
|
||||
<Route path="pending" {...PendingQueueContainer.routeConfig} />
|
||||
<Route
|
||||
path="pending/:storyID"
|
||||
{...PendingQueueContainer.routeConfig}
|
||||
/>
|
||||
<Route
|
||||
path="unmoderated"
|
||||
{...UnmoderatedQueueContainer.routeConfig}
|
||||
/>
|
||||
<Route
|
||||
path="unmoderated/:storyID"
|
||||
{...UnmoderatedQueueContainer.routeConfig}
|
||||
/>
|
||||
<Route path="rejected" {...RejectedQueueContainer.routeConfig} />
|
||||
<Route
|
||||
path="rejected/:storyID"
|
||||
{...RejectedQueueContainer.routeConfig}
|
||||
/>
|
||||
<Redirect from=":storyID" to="/admin/moderate/reported/:storyID" />
|
||||
</Route>
|
||||
<Route path="stories" {...StoriesContainer.routeConfig} />
|
||||
<Route path="community" {...CommunityContainer.routeConfig} />
|
||||
|
||||
+7
-6
@@ -5,11 +5,12 @@ import { graphql } from "react-relay";
|
||||
|
||||
import { OIDCConfigContainer_auth as AuthData } from "talk-admin/__generated__/OIDCConfigContainer_auth.graphql";
|
||||
import { OIDCConfigContainer_authReadOnly as AuthReadOnlyData } from "talk-admin/__generated__/OIDCConfigContainer_authReadOnly.graphql";
|
||||
import { DiscoverOIDCConfigurationFetch } from "talk-admin/fetches";
|
||||
import {
|
||||
DiscoverOIDCConfigurationFetch,
|
||||
withDiscoverOIDCConfigurationFetch,
|
||||
} from "talk-admin/fetches";
|
||||
import { withFragmentContainer } from "talk-framework/lib/relay";
|
||||
FetchProp,
|
||||
withFetch,
|
||||
withFragmentContainer,
|
||||
} from "talk-framework/lib/relay";
|
||||
|
||||
import OIDCConfig from "../components/OIDCConfig";
|
||||
|
||||
@@ -18,7 +19,7 @@ interface Props {
|
||||
authReadOnly: AuthReadOnlyData;
|
||||
onInitValues: (values: AuthData) => void;
|
||||
disabled?: boolean;
|
||||
discoverOIDCConfiguration: DiscoverOIDCConfigurationFetch;
|
||||
discoverOIDCConfiguration: FetchProp<typeof DiscoverOIDCConfigurationFetch>;
|
||||
}
|
||||
|
||||
interface State {
|
||||
@@ -74,7 +75,7 @@ class OIDCConfigContainer extends React.Component<Props, State> {
|
||||
}
|
||||
}
|
||||
|
||||
const enhanced = withDiscoverOIDCConfigurationFetch(
|
||||
const enhanced = withFetch(DiscoverOIDCConfigurationFetch)(
|
||||
withFragmentContainer<Props>({
|
||||
auth: graphql`
|
||||
fragment OIDCConfigContainer_auth on Auth {
|
||||
|
||||
@@ -1,22 +1,20 @@
|
||||
import React from "react";
|
||||
import { createRenderer } from "react-test-renderer/shallow";
|
||||
|
||||
import { removeFragmentRefs } from "talk-framework/testHelpers";
|
||||
import { PropTypesOf } from "talk-framework/types";
|
||||
|
||||
import Moderate from "./Moderate";
|
||||
|
||||
import { PropTypesOf } from "talk-framework/types";
|
||||
it("renders correctly", () => {
|
||||
const renderer = createRenderer();
|
||||
renderer.render(<Moderate />);
|
||||
expect(renderer.getRenderOutput()).toMatchSnapshot();
|
||||
});
|
||||
const ModerateN = removeFragmentRefs(Moderate);
|
||||
|
||||
it("renders correctly with counts", () => {
|
||||
const props: PropTypesOf<typeof Moderate> = {
|
||||
unmoderatedCount: 3,
|
||||
reportedCount: 4,
|
||||
pendingCount: 0,
|
||||
it("renders correctly", () => {
|
||||
const props: PropTypesOf<typeof ModerateN> = {
|
||||
allStories: true,
|
||||
moderationQueues: {},
|
||||
story: {},
|
||||
};
|
||||
const renderer = createRenderer();
|
||||
renderer.render(<Moderate {...props} />);
|
||||
renderer.render(<ModerateN {...props} />);
|
||||
expect(renderer.getRenderOutput()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
@@ -1,31 +1,36 @@
|
||||
import React, { StatelessComponent } from "react";
|
||||
|
||||
import MainLayout from "talk-admin/components/MainLayout";
|
||||
import { PropTypesOf } from "talk-framework/types";
|
||||
import { SubBar } from "talk-ui/components/SubBar";
|
||||
|
||||
import Navigation from "./Navigation";
|
||||
import ModerateNavigationContainer from "../containers/ModerateNavigationContainer";
|
||||
import ModerateSearchBarContainer from "../containers/ModerateSearchBarContainer";
|
||||
|
||||
import styles from "./Moderate.css";
|
||||
|
||||
interface Props {
|
||||
unmoderatedCount?: number;
|
||||
reportedCount?: number;
|
||||
pendingCount?: number;
|
||||
story: PropTypesOf<typeof ModerateNavigationContainer>["story"] &
|
||||
PropTypesOf<typeof ModerateSearchBarContainer>["story"];
|
||||
moderationQueues: PropTypesOf<
|
||||
typeof ModerateNavigationContainer
|
||||
>["moderationQueues"];
|
||||
allStories: boolean;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
const Moderate: StatelessComponent<Props> = ({
|
||||
unmoderatedCount,
|
||||
reportedCount,
|
||||
pendingCount,
|
||||
moderationQueues,
|
||||
story,
|
||||
allStories,
|
||||
children,
|
||||
}) => (
|
||||
<div data-testid="moderate-container">
|
||||
<ModerateSearchBarContainer story={story} allStories={allStories} />
|
||||
<SubBar data-testid="moderate-subBar-container">
|
||||
<Navigation
|
||||
unmoderatedCount={unmoderatedCount}
|
||||
reportedCount={reportedCount}
|
||||
pendingCount={pendingCount}
|
||||
<ModerateNavigationContainer
|
||||
moderationQueues={moderationQueues}
|
||||
story={story}
|
||||
/>
|
||||
</SubBar>
|
||||
<div className={styles.background} />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Localized } from "fluent-react/compat";
|
||||
import React, { StatelessComponent } from "react";
|
||||
|
||||
import { getModerationLink } from "talk-admin/helpers";
|
||||
import { Counter, Icon, SubBarNavigation } from "talk-ui/components";
|
||||
|
||||
import NavigationLink from "./NavigationLink";
|
||||
@@ -9,16 +10,17 @@ interface Props {
|
||||
unmoderatedCount?: number;
|
||||
reportedCount?: number;
|
||||
pendingCount?: number;
|
||||
children?: React.ReactNode;
|
||||
storyID?: string | null;
|
||||
}
|
||||
|
||||
const Navigation: StatelessComponent<Props> = ({
|
||||
unmoderatedCount,
|
||||
reportedCount,
|
||||
pendingCount,
|
||||
storyID,
|
||||
}) => (
|
||||
<SubBarNavigation>
|
||||
<NavigationLink to="/admin/moderate/reported">
|
||||
<NavigationLink to={getModerationLink("reported", storyID)}>
|
||||
<Icon>flag</Icon>
|
||||
<Localized id="moderate-navigation-reported">
|
||||
<span>Reported</span>
|
||||
@@ -29,7 +31,7 @@ const Navigation: StatelessComponent<Props> = ({
|
||||
</Counter>
|
||||
)}
|
||||
</NavigationLink>
|
||||
<NavigationLink to="/admin/moderate/pending">
|
||||
<NavigationLink to={getModerationLink("pending", storyID)}>
|
||||
<Icon>access_time</Icon>
|
||||
<Localized id="moderate-navigation-pending">
|
||||
<span>Pending</span>
|
||||
@@ -40,7 +42,7 @@ const Navigation: StatelessComponent<Props> = ({
|
||||
</Counter>
|
||||
)}
|
||||
</NavigationLink>
|
||||
<NavigationLink to="/admin/moderate/unmoderated">
|
||||
<NavigationLink to={getModerationLink("unmoderated", storyID)}>
|
||||
<Icon>forum</Icon>
|
||||
<Localized id="moderate-navigation-unmoderated">
|
||||
<span>Unmoderated</span>
|
||||
@@ -51,7 +53,7 @@ const Navigation: StatelessComponent<Props> = ({
|
||||
</Counter>
|
||||
)}
|
||||
</NavigationLink>
|
||||
<NavigationLink to="/admin/moderate/rejected">
|
||||
<NavigationLink to={getModerationLink("rejected", storyID)}>
|
||||
<Icon>cancel</Icon>
|
||||
<Localized id="moderate-navigation-rejected">
|
||||
<span>Rejected</span>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
.root {
|
||||
height: calc(5 * var(--spacing-unit));
|
||||
background-color: #013f68;
|
||||
margin-top: -1px;
|
||||
}
|
||||
|
||||
.bumpZIndex {
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.popover {
|
||||
width: calc(75 * var(--spacing-unit));
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.listBox {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { Localized } from "fluent-react/compat";
|
||||
import React, { FunctionComponent, useCallback } from "react";
|
||||
import { Form } from "react-final-form";
|
||||
|
||||
import { Backdrop, Icon, Popover, SubBar } from "talk-ui/components";
|
||||
import { combineEventHandlers } from "talk-ui/helpers";
|
||||
import {
|
||||
useBlurOnEsc,
|
||||
useComboBox,
|
||||
useFocus,
|
||||
usePreventFocusLoss,
|
||||
} from "talk-ui/hooks";
|
||||
import { ListBoxOption } from "talk-ui/hooks/useComboBox";
|
||||
|
||||
import Field from "./Field";
|
||||
import Group from "./Group";
|
||||
|
||||
import styles from "./Bar.css";
|
||||
|
||||
/** Group of listbox options. */
|
||||
type Group = "CONTEXT" | "SEARCH";
|
||||
|
||||
interface Props {
|
||||
/** title of the current story */
|
||||
title: string;
|
||||
/** options to show in the combobox listbox */
|
||||
options: Array<ListBoxOption & { group: Group }>;
|
||||
/** onSearch will be called whenenver the user submits the search */
|
||||
onSearch?: (value: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bar is the container of the whole search bar.
|
||||
*/
|
||||
const Bar: FunctionComponent<Props> = ({ title, options, onSearch }) => {
|
||||
const [focused, focusHandlers] = useFocus();
|
||||
const preventFocusLossHandlers = usePreventFocusLoss(focused);
|
||||
const submitHandler = useCallback(
|
||||
({ search }: { search: string }) => onSearch && onSearch(search),
|
||||
[onSearch]
|
||||
);
|
||||
const blurOnEscProps = useBlurOnEsc(focused);
|
||||
const [
|
||||
mappedOptions,
|
||||
activeDescendant,
|
||||
keyboardNavigationHandlers,
|
||||
] = useComboBox("moderate-searchBar-listBoxOption", options);
|
||||
|
||||
const contextOptions = mappedOptions
|
||||
.filter(o => o.group === "CONTEXT")
|
||||
.map(o => o.element);
|
||||
const searchOptions = mappedOptions
|
||||
.filter(o => o.group === "SEARCH")
|
||||
.map(o => o.element);
|
||||
|
||||
return (
|
||||
<Localized id="moderate-searchBar-comboBox" attrs={{ "aria-label": true }}>
|
||||
<SubBar
|
||||
className={styles.root}
|
||||
data-testid="moderate-searchBar-container"
|
||||
role="combobox"
|
||||
aria-owns="moderate-searchBar-listBox"
|
||||
aria-label="Search or jump to story"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={focused}
|
||||
>
|
||||
<Backdrop className={styles.bumpZIndex} active={focused} />
|
||||
<Form onSubmit={submitHandler}>
|
||||
{({ handleSubmit }) => (
|
||||
<Localized
|
||||
id="moderate-searchBar-searchForm"
|
||||
attrs={{ "aria-label": true }}
|
||||
>
|
||||
<form
|
||||
role="search"
|
||||
aria-label="Stories"
|
||||
className={styles.bumpZIndex}
|
||||
onSubmit={handleSubmit}
|
||||
{...preventFocusLossHandlers}
|
||||
>
|
||||
<Popover
|
||||
id={"moderate-searchBar-popover"}
|
||||
placement="bottom"
|
||||
classes={{ popover: styles.popover }}
|
||||
visible={focused}
|
||||
eventsEnabled={false}
|
||||
modifiers={{
|
||||
preventOverflow: { enabled: false },
|
||||
flip: { enabled: false },
|
||||
hide: { enabled: false },
|
||||
}}
|
||||
body={() => (
|
||||
<ul
|
||||
id="moderate-searchBar-listBox"
|
||||
role="listbox"
|
||||
className={styles.listBox}
|
||||
>
|
||||
{contextOptions.length > 0 && (
|
||||
<Localized
|
||||
id="moderate-searchBar-currentlyModerating"
|
||||
attrs={{ title: true }}
|
||||
>
|
||||
<Group
|
||||
title="Currently moderating"
|
||||
id="moderate-searchBar-context"
|
||||
>
|
||||
{contextOptions}
|
||||
</Group>
|
||||
</Localized>
|
||||
)}
|
||||
{searchOptions.length > 0 && (
|
||||
<Group
|
||||
title={
|
||||
<>
|
||||
<Icon>search</Icon>{" "}
|
||||
<Localized id="moderate-searchBar-searchResultsMostRecentFirst">
|
||||
<span>Search results (Most recent first)</span>
|
||||
</Localized>
|
||||
</>
|
||||
}
|
||||
id="moderate-searchBar-search"
|
||||
light
|
||||
>
|
||||
{searchOptions}
|
||||
</Group>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
>
|
||||
{({ ref }) => (
|
||||
<div ref={ref}>
|
||||
<Field
|
||||
title={title}
|
||||
{...combineEventHandlers(
|
||||
focusHandlers,
|
||||
blurOnEscProps,
|
||||
keyboardNavigationHandlers
|
||||
)}
|
||||
focused={focused}
|
||||
aria-controls="moderate-searchBar-listBox"
|
||||
aria-autocomplete="list"
|
||||
aria-activedescendant={activeDescendant}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Popover>
|
||||
</form>
|
||||
</Localized>
|
||||
)}
|
||||
</Form>
|
||||
</SubBar>
|
||||
</Localized>
|
||||
);
|
||||
};
|
||||
|
||||
export default Bar;
|
||||
@@ -0,0 +1,87 @@
|
||||
.root {
|
||||
width: calc(75 * var(--spacing-unit));
|
||||
height: calc(3 * var(--spacing-unit));
|
||||
}
|
||||
|
||||
.begin {
|
||||
background-color: var(--palette-primary-darkest);
|
||||
border-top-left-radius: var(--round-corners);
|
||||
border-bottom-left-radius: var(--round-corners);
|
||||
min-width: calc(4 * var(--spacing-unit));
|
||||
flex-shrink: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.beginStories {
|
||||
font-size: calc(13rem / var(--rem-base));
|
||||
font-weight: var(--font-weight-medium);
|
||||
font-family: var(--font-family-sans-serif);
|
||||
line-height: 1.5;
|
||||
letter-spacing: calc(0.2em / 13);
|
||||
color: var(--palette-text-light);
|
||||
text-transform: uppercase;
|
||||
padding-right: calc(0.25 * var(--spacing-unit));
|
||||
}
|
||||
|
||||
.searchIcon {
|
||||
padding: 0 calc(0.5 * var(--spacing-unit)) 0 calc(0.75 * var(--spacing-unit));
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--palette-text-light);
|
||||
}
|
||||
|
||||
.end {
|
||||
min-width: calc(4 * var(--spacing-unit));
|
||||
background-color: var(--palette-primary-darkest);
|
||||
border-top-right-radius: var(--round-corners);
|
||||
border-bottom-right-radius: var(--round-corners);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.searchButton {
|
||||
composes: button from "talk-ui/shared/typography.css";
|
||||
padding: 0 calc(1 * var(--spacing-unit));
|
||||
color: var(--palette-text-light);
|
||||
border-left: 1px solid var(--palette-text-light);
|
||||
height: calc(3 * var(--spacing-unit) - 4px);
|
||||
|
||||
&:disabled {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.input {
|
||||
composes: inputText placeholderPseudo from "talk-ui/shared/typography.css";
|
||||
position: relative;
|
||||
display: block;
|
||||
padding: calc(0.5 * var(--spacing-unit));
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
line-height: 30px;
|
||||
align-self: stretch;
|
||||
color: var(--palette-text-light);
|
||||
border: 0;
|
||||
background-color: var(--palette-primary-darkest);
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--palette-text-light);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&:read-only {
|
||||
opacity: 0.5;
|
||||
}
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.inputWithTitle {
|
||||
text-align: center;
|
||||
&::placeholder {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import cn from "classnames";
|
||||
import { Localized } from "fluent-react/compat";
|
||||
import React, { FunctionComponent, HTMLAttributes } from "react";
|
||||
import { Field as FormField } from "react-final-form";
|
||||
|
||||
import { BaseButton, Flex, Icon } from "talk-ui/components";
|
||||
|
||||
import styles from "./Field.css";
|
||||
|
||||
interface Props extends HTMLAttributes<HTMLInputElement> {
|
||||
/** title of the story */
|
||||
title: string;
|
||||
className?: string;
|
||||
focused?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Field is the TextField for the search entry.
|
||||
*/
|
||||
const Field: FunctionComponent<Props> = ({
|
||||
title,
|
||||
focused,
|
||||
className,
|
||||
onBlur,
|
||||
onChange,
|
||||
...rest
|
||||
}) => {
|
||||
return (
|
||||
<FormField name="search">
|
||||
{({ input }) => (
|
||||
<Flex className={cn(className, styles.root)} alignItems="stretch">
|
||||
<Flex className={styles.begin} alignItems="center">
|
||||
<Icon className={styles.searchIcon} size="md">
|
||||
search
|
||||
</Icon>
|
||||
{focused && (
|
||||
<Localized id="moderate-searchBar-stories">
|
||||
<div className={styles.beginStories}>Stories:</div>
|
||||
</Localized>
|
||||
)}
|
||||
</Flex>
|
||||
<Localized
|
||||
id="moderate-searchBar-comboBoxTextField"
|
||||
attrs={{ "aria-label": true, placeholder: Boolean(focused) }}
|
||||
>
|
||||
<input
|
||||
name={input.name}
|
||||
onChange={evt => {
|
||||
if (onChange) {
|
||||
onChange(evt);
|
||||
}
|
||||
input.onChange(evt);
|
||||
}}
|
||||
value={input.value}
|
||||
className={cn(styles.input, {
|
||||
[styles.inputWithTitle]: !focused,
|
||||
})}
|
||||
placeholder={
|
||||
focused
|
||||
? "Use quotation marks around each search term (e.g. “team”, “St. Louis”)"
|
||||
: title
|
||||
}
|
||||
aria-label="Search or jump to story..."
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
onBlur={evt => {
|
||||
// Reset value when blurring.
|
||||
input.onChange("");
|
||||
if (onBlur) {
|
||||
onBlur(evt);
|
||||
}
|
||||
}}
|
||||
{...rest}
|
||||
/>
|
||||
</Localized>
|
||||
<Flex className={styles.end} alignItems="center">
|
||||
{focused && (
|
||||
<Localized id="moderate-searchBar-searchButton">
|
||||
<BaseButton
|
||||
className={styles.searchButton}
|
||||
type="submit"
|
||||
disabled={!Boolean(input.value)}
|
||||
>
|
||||
Search
|
||||
</BaseButton>
|
||||
</Localized>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
)}
|
||||
</FormField>
|
||||
);
|
||||
};
|
||||
|
||||
export default Field;
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Localized } from "fluent-react/compat";
|
||||
import React, { FunctionComponent } from "react";
|
||||
|
||||
import { AriaInfo } from "talk-ui/components";
|
||||
|
||||
const GoToAriaInfo: FunctionComponent = () => (
|
||||
<Localized id="moderate-searchBar-goTo">
|
||||
<AriaInfo>Go to</AriaInfo>
|
||||
</Localized>
|
||||
);
|
||||
|
||||
export default GoToAriaInfo;
|
||||
@@ -0,0 +1,24 @@
|
||||
.root {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: calc(3 * var(--spacing-unit));
|
||||
padding-left: calc(1.5 * var(--spacing-unit));
|
||||
background: var(--palette-text-primary);
|
||||
|
||||
font-family: var(--font-family-sans-serif);
|
||||
font-weight: var(--font-weight-medium);
|
||||
font-size: calc(13rem / var(--rem-base));
|
||||
line-height: calc(16em / 14);
|
||||
color: var(--palette-text-light);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.light {
|
||||
background: var(--palette-grey-light);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import cn from "classnames";
|
||||
import React, { FunctionComponent } from "react";
|
||||
|
||||
import styles from "./Group.css";
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
title: React.ReactNode;
|
||||
light?: boolean;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Group represents a ListBox Group
|
||||
*/
|
||||
const Group: FunctionComponent<Props> = ({ title, children, id, light }) => {
|
||||
return (
|
||||
<ul
|
||||
role="group"
|
||||
aria-labelledby={`${id}-title`}
|
||||
id={id}
|
||||
className={styles.root}
|
||||
>
|
||||
<li
|
||||
id={`${id}-title`}
|
||||
className={cn(styles.title, { [styles.light]: light })}
|
||||
>
|
||||
{title}
|
||||
</li>
|
||||
{children}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
export default Group;
|
||||
@@ -0,0 +1,22 @@
|
||||
.root {
|
||||
&:not(:first-child) {
|
||||
border-top: 1px solid var(--palette-divider);
|
||||
}
|
||||
&[aria-selected="true"] {
|
||||
@mixin outline;
|
||||
}
|
||||
}
|
||||
|
||||
.link {
|
||||
justify-content: left;
|
||||
min-height: calc(4 * var(--spacing-unit));
|
||||
font-family: var(--font-family-sans-serif);
|
||||
font-weight: var(--font-weight-medium);
|
||||
font-size: calc(16rem / var(--rem-base));
|
||||
line-height: calc(16em / 16);
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-weight: var(--font-weight-medium);
|
||||
margin-top: -2px;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import cn from "classnames";
|
||||
import { Localized } from "fluent-react/compat";
|
||||
import React, { FunctionComponent, HTMLAttributes } from "react";
|
||||
|
||||
import { Button, Icon } from "talk-ui/components";
|
||||
|
||||
import styles from "./ModerateAllOption.css";
|
||||
|
||||
interface Props extends HTMLAttributes<HTMLLIElement> {
|
||||
href?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* ModerateAllOption is a listbox option that renders a moderate all button.
|
||||
*/
|
||||
const ModerateAllOption: FunctionComponent<Props> = ({
|
||||
className,
|
||||
href,
|
||||
...rest
|
||||
}) => {
|
||||
return (
|
||||
<li role="option" className={cn(className, styles.root)} {...rest}>
|
||||
<Button
|
||||
href={href}
|
||||
color="primary"
|
||||
className={styles.link}
|
||||
anchor
|
||||
fullWidth
|
||||
tabIndex={-1}
|
||||
>
|
||||
<Localized id="moderate-searchBar-moderateAllStories">
|
||||
<span>Moderate all stories</span>
|
||||
</Localized>
|
||||
<span>
|
||||
<Icon className={styles.icon}>arrow_forward</Icon>
|
||||
</span>
|
||||
</Button>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModerateAllOption;
|
||||
@@ -0,0 +1,42 @@
|
||||
.root {
|
||||
&:not(:first-child) {
|
||||
border-top: 1px solid var(--palette-divider);
|
||||
}
|
||||
&[aria-selected="true"] .container {
|
||||
@mixin outline;
|
||||
}
|
||||
}
|
||||
.container {
|
||||
min-height: calc(4 * var(--spacing-unit));
|
||||
padding: var(--spacing-unit) calc(2.5 * var(--spacing-unit));
|
||||
box-sizing: border-box;
|
||||
&:hover {
|
||||
background: var(--palette-grey-lightest);
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
font-family: var(--font-family-sans-serif);
|
||||
font-weight: var(--font-weight-medium);
|
||||
font-size: calc(16rem / var(--rem-base));
|
||||
line-height: calc(16em / 16);
|
||||
color: var(--palette-text-primary);
|
||||
}
|
||||
|
||||
.titleWithDetails {
|
||||
font-size: calc(14rem / var(--rem-base));
|
||||
}
|
||||
|
||||
.details {
|
||||
padding-top: 3px;
|
||||
font-family: var(--font-family-sans-serif);
|
||||
font-weight: var(--font-weight-regular);
|
||||
font-size: calc(14rem / var(--rem-base));
|
||||
line-height: calc(14em / 14);
|
||||
color: var(--palette-grey-dark);
|
||||
}
|
||||
|
||||
.link {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import cn from "classnames";
|
||||
import React, { FunctionComponent, HTMLAttributes } from "react";
|
||||
|
||||
import styles from "./Option.css";
|
||||
|
||||
interface Props extends HTMLAttributes<HTMLLIElement> {
|
||||
href?: string;
|
||||
/** details contains additional information like the author */
|
||||
details?: React.ReactNode;
|
||||
/** children contains e.g. the title of the option */
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Group represents a generic listbox option
|
||||
*/
|
||||
const Option: FunctionComponent<Props> = ({
|
||||
details,
|
||||
children,
|
||||
className,
|
||||
href,
|
||||
...rest
|
||||
}) => {
|
||||
const container = (
|
||||
<div className={styles.container}>
|
||||
<div
|
||||
className={cn(styles.title, {
|
||||
[styles.titleWithDetails]: Boolean(details),
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
<div className={styles.details}>{details}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<li role="option" className={cn(className, styles.root)} {...rest}>
|
||||
{href && (
|
||||
<a href={href} className={styles.link} tabIndex={-1}>
|
||||
{container}
|
||||
</a>
|
||||
)}
|
||||
{!Boolean(href) && container}
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
export default Option;
|
||||
@@ -0,0 +1,33 @@
|
||||
.root {
|
||||
&[aria-selected="true"] {
|
||||
@mixin outline;
|
||||
}
|
||||
}
|
||||
|
||||
.link {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
background: var(--palette-primary-dark);
|
||||
min-height: calc(3 * var(--spacing-unit));
|
||||
|
||||
font-family: var(--font-family-sans-serif);
|
||||
font-weight: var(--font-weight-medium);
|
||||
font-size: calc(13rem / var(--rem-base));
|
||||
line-height: calc(16em / 13);
|
||||
color: var(--palette-text-light);
|
||||
|
||||
text-decoration: none;
|
||||
text-transform: uppercase;
|
||||
&:hover {
|
||||
background: var(--palette-primary-darkest);
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-weight: var(--font-weight-medium);
|
||||
padding-left: calc(0.5 * var(--spacing-unit));
|
||||
line-height: calc(16em / 13);
|
||||
margin-top: -2px;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import cn from "classnames";
|
||||
import { Localized } from "fluent-react/compat";
|
||||
import React, { FunctionComponent, HTMLAttributes } from "react";
|
||||
|
||||
import { Icon } from "talk-ui/components";
|
||||
|
||||
import styles from "./SeeAllOption.css";
|
||||
|
||||
interface Props extends HTMLAttributes<HTMLLIElement> {
|
||||
href?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* SeeAllOption is a listbox option that renders a see all search results button.
|
||||
*/
|
||||
const SeeAllOption: FunctionComponent<Props> = ({
|
||||
className,
|
||||
href,
|
||||
...rest
|
||||
}) => {
|
||||
return (
|
||||
<li role="option" className={cn(className, styles.root)} {...rest}>
|
||||
<a className={styles.link} href={href || "#"} tabIndex={-1}>
|
||||
<Localized id="moderate-searchBar-seeAllResults">
|
||||
<span>See all results</span>
|
||||
</Localized>
|
||||
<Icon className={styles.icon}>arrow_forward</Icon>
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
export default SeeAllOption;
|
||||
@@ -0,0 +1,5 @@
|
||||
export { default as Bar } from "./Bar";
|
||||
export { default as Field } from "./Field";
|
||||
export { default as Option } from "./Option";
|
||||
export { default as ModerateAllOption } from "./ModerateAllOption";
|
||||
export { default as SeeAllOption } from "./SeeAllOption";
|
||||
+6
-25
@@ -4,35 +4,16 @@ exports[`renders correctly 1`] = `
|
||||
<div
|
||||
data-testid="moderate-container"
|
||||
>
|
||||
<withPropsOnChange(SubBar)
|
||||
data-testid="moderate-subBar-container"
|
||||
>
|
||||
<Navigation />
|
||||
</withPropsOnChange(SubBar)>
|
||||
<div
|
||||
className="Moderate-background"
|
||||
<withRouter(Relay(ModerateSearchBarContainer))
|
||||
allStories={true}
|
||||
story={Object {}}
|
||||
/>
|
||||
<MainLayout
|
||||
data-testid="moderate-main-container"
|
||||
>
|
||||
<main
|
||||
className="Moderate-main"
|
||||
/>
|
||||
</MainLayout>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`renders correctly with counts 1`] = `
|
||||
<div
|
||||
data-testid="moderate-container"
|
||||
>
|
||||
<withPropsOnChange(SubBar)
|
||||
data-testid="moderate-subBar-container"
|
||||
>
|
||||
<Navigation
|
||||
pendingCount={0}
|
||||
reportedCount={4}
|
||||
unmoderatedCount={3}
|
||||
<Relay(ModerateNavigationContainer)
|
||||
moderationQueues={Object {}}
|
||||
story={Object {}}
|
||||
/>
|
||||
</withPropsOnChange(SubBar)>
|
||||
<div
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Match, Router, withRouter } from "found";
|
||||
import React from "react";
|
||||
import { graphql } from "react-relay";
|
||||
|
||||
@@ -16,12 +17,14 @@ import {
|
||||
|
||||
import ModerateCard from "../components/ModerateCard";
|
||||
|
||||
interface ModerateCardContainerProps {
|
||||
interface Props {
|
||||
comment: CommentData;
|
||||
settings: SettingsData;
|
||||
acceptComment: MutationProp<typeof AcceptCommentMutation>;
|
||||
rejectComment: MutationProp<typeof RejectCommentMutation>;
|
||||
danglingLogic: (status: COMMENT_STATUS) => boolean;
|
||||
match: Match;
|
||||
router: Router;
|
||||
}
|
||||
|
||||
function getStatus(comment: CommentData) {
|
||||
@@ -35,13 +38,12 @@ function getStatus(comment: CommentData) {
|
||||
}
|
||||
}
|
||||
|
||||
class ModerateCardContainer extends React.Component<
|
||||
ModerateCardContainerProps
|
||||
> {
|
||||
class ModerateCardContainer extends React.Component<Props> {
|
||||
private handleAccept = () => {
|
||||
this.props.acceptComment({
|
||||
commentID: this.props.comment.id,
|
||||
commentRevisionID: this.props.comment.revision.id,
|
||||
storyID: this.props.match.params.storyID,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -49,6 +51,7 @@ class ModerateCardContainer extends React.Component<
|
||||
this.props.rejectComment({
|
||||
commentID: this.props.comment.id,
|
||||
commentRevisionID: this.props.comment.revision.id,
|
||||
storyID: this.props.match.params.storyID,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -74,7 +77,7 @@ class ModerateCardContainer extends React.Component<
|
||||
}
|
||||
}
|
||||
|
||||
const enhanced = withFragmentContainer<ModerateCardContainerProps>({
|
||||
const enhanced = withFragmentContainer<Props>({
|
||||
comment: graphql`
|
||||
fragment ModerateCardContainer_comment on Comment {
|
||||
id
|
||||
@@ -105,8 +108,10 @@ const enhanced = withFragmentContainer<ModerateCardContainerProps>({
|
||||
}
|
||||
`,
|
||||
})(
|
||||
withMutation(AcceptCommentMutation)(
|
||||
withMutation(RejectCommentMutation)(ModerateCardContainer)
|
||||
withRouter(
|
||||
withMutation(AcceptCommentMutation)(
|
||||
withMutation(RejectCommentMutation)(ModerateCardContainer)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
@@ -1,32 +1,40 @@
|
||||
import { RouteProps } from "found";
|
||||
import { Match, RouteProps, Router, withRouter } from "found";
|
||||
import React from "react";
|
||||
import { graphql } from "react-relay";
|
||||
|
||||
import { ModerateContainerQueryResponse } from "talk-admin/__generated__/ModerateContainerQuery.graphql";
|
||||
import { withRouteConfig } from "talk-framework/lib/router";
|
||||
import { Spinner } from "talk-ui/components";
|
||||
|
||||
import Moderate from "../components/Moderate";
|
||||
|
||||
interface RouteParams {
|
||||
storyID?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
data: ModerateContainerQueryResponse;
|
||||
router: Router;
|
||||
match: Match & { params: RouteParams };
|
||||
}
|
||||
|
||||
class ModerateContainer extends React.Component<Props> {
|
||||
public static routeConfig: RouteProps;
|
||||
|
||||
public render() {
|
||||
const allStories = !this.props.match.params.storyID;
|
||||
if (!this.props.data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!this.props.data.moderationQueues) {
|
||||
return <Moderate />;
|
||||
return (
|
||||
<Moderate moderationQueues={null} story={null} allStories={allStories}>
|
||||
<Spinner />
|
||||
</Moderate>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Moderate
|
||||
unmoderatedCount={this.props.data.moderationQueues.unmoderated.count}
|
||||
reportedCount={this.props.data.moderationQueues.reported.count}
|
||||
pendingCount={this.props.data.moderationQueues.pending.count}
|
||||
moderationQueues={this.props.data.moderationQueues}
|
||||
story={this.props.data.story || null}
|
||||
allStories={allStories}
|
||||
>
|
||||
{this.props.children}
|
||||
</Moderate>
|
||||
@@ -36,21 +44,23 @@ class ModerateContainer extends React.Component<Props> {
|
||||
|
||||
const enhanced = withRouteConfig<ModerateContainerQueryResponse>({
|
||||
query: graphql`
|
||||
query ModerateContainerQuery {
|
||||
moderationQueues {
|
||||
unmoderated {
|
||||
count
|
||||
}
|
||||
reported {
|
||||
count
|
||||
}
|
||||
pending {
|
||||
count
|
||||
}
|
||||
query ModerateContainerQuery($storyID: ID, $includeStory: Boolean!) {
|
||||
story(id: $storyID) @include(if: $includeStory) {
|
||||
...ModerateNavigationContainer_story
|
||||
...ModerateSearchBarContainer_story
|
||||
}
|
||||
moderationQueues(storyID: $storyID) {
|
||||
...ModerateNavigationContainer_moderationQueues
|
||||
}
|
||||
}
|
||||
`,
|
||||
cacheConfig: { force: true },
|
||||
})(ModerateContainer);
|
||||
prepareVariables: (params, match) => {
|
||||
return {
|
||||
storyID: match.params.storyID,
|
||||
includeStory: Boolean(match.params.storyID),
|
||||
};
|
||||
},
|
||||
})(withRouter(ModerateContainer));
|
||||
|
||||
export default enhanced;
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import React from "react";
|
||||
import { graphql } from "react-relay";
|
||||
|
||||
import { ModerateNavigationContainer_moderationQueues as ModerationQueuesData } from "talk-admin/__generated__/ModerateNavigationContainer_moderationQueues.graphql";
|
||||
import { ModerateNavigationContainer_story as StoryData } from "talk-admin/__generated__/ModerateNavigationContainer_story.graphql";
|
||||
import { withFragmentContainer } from "talk-framework/lib/relay";
|
||||
|
||||
import Navigation from "../components/Navigation";
|
||||
|
||||
interface Props {
|
||||
moderationQueues: ModerationQueuesData | null;
|
||||
story: StoryData | null;
|
||||
}
|
||||
|
||||
const ModerateNavigationContainer: React.FunctionComponent<Props> = props => {
|
||||
if (!props.moderationQueues) {
|
||||
return <Navigation />;
|
||||
}
|
||||
return (
|
||||
<Navigation
|
||||
unmoderatedCount={props.moderationQueues.unmoderated.count}
|
||||
reportedCount={props.moderationQueues.reported.count}
|
||||
pendingCount={props.moderationQueues.pending.count}
|
||||
storyID={props.story && props.story.id}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const enhanced = withFragmentContainer<Props>({
|
||||
story: graphql`
|
||||
fragment ModerateNavigationContainer_story on Story {
|
||||
id
|
||||
}
|
||||
`,
|
||||
moderationQueues: graphql`
|
||||
fragment ModerateNavigationContainer_moderationQueues on ModerationQueues {
|
||||
unmoderated {
|
||||
count
|
||||
}
|
||||
reported {
|
||||
count
|
||||
}
|
||||
pending {
|
||||
count
|
||||
}
|
||||
}
|
||||
`,
|
||||
})(ModerateNavigationContainer);
|
||||
|
||||
export default enhanced;
|
||||
@@ -0,0 +1,264 @@
|
||||
import { Localized } from "fluent-react/compat";
|
||||
import { Match, Router, withRouter } from "found";
|
||||
import React, {
|
||||
KeyboardEvent,
|
||||
MouseEvent,
|
||||
useCallback,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { graphql } from "react-relay";
|
||||
|
||||
import { ModerateSearchBarContainer_story as ModerationQueuesData } from "talk-admin/__generated__/ModerateSearchBarContainer_story.graphql";
|
||||
import { SearchStoryFetch } from "talk-admin/fetches";
|
||||
import { useEffectWhenChanged } from "talk-framework/hooks";
|
||||
import { useFetch, withFragmentContainer } from "talk-framework/lib/relay";
|
||||
import { PropTypesOf } from "talk-framework/types";
|
||||
import { Spinner } from "talk-ui/components";
|
||||
import { blur } from "talk-ui/helpers";
|
||||
import {
|
||||
ListBoxOptionClickOrEnterHandler,
|
||||
ListBoxOptionElement,
|
||||
} from "talk-ui/hooks/useComboBox";
|
||||
|
||||
import * as Search from "../components/Search";
|
||||
import GoToAriaInfo from "../components/Search/GoToAriaInfo";
|
||||
|
||||
interface Props {
|
||||
router: Router;
|
||||
match: Match;
|
||||
story: ModerationQueuesData | null;
|
||||
allStories: boolean;
|
||||
}
|
||||
|
||||
type SearchBarOptions = PropTypesOf<typeof Search.Bar>["options"];
|
||||
|
||||
/**
|
||||
* useLinkNavHandler returns a handler that navigates to `href` prop and blurs
|
||||
* the TextField.
|
||||
* @param router Router from the _found_ library
|
||||
* @returns A handler for ListBoxOption
|
||||
*/
|
||||
function useLinkNavHandler(router: Router): ListBoxOptionClickOrEnterHandler {
|
||||
return useCallback(
|
||||
(evt: MouseEvent | KeyboardEvent, element: ListBoxOptionElement) => {
|
||||
if (element.props.href) {
|
||||
router.push(element.props.href);
|
||||
if (evt.preventDefault) {
|
||||
// We prevent default behavior because we handled navigation ourselves
|
||||
// and the browser don't need to follow anchor hrefs natively.
|
||||
evt.preventDefault();
|
||||
}
|
||||
// Blur will inactivate the textfield and close the popover/listbox.
|
||||
blur();
|
||||
}
|
||||
},
|
||||
[router]
|
||||
);
|
||||
}
|
||||
|
||||
function getContextOptionsWhenModeratingAll(
|
||||
onClickOrEnter: ListBoxOptionClickOrEnterHandler
|
||||
): SearchBarOptions {
|
||||
return [
|
||||
{
|
||||
element: (
|
||||
<Search.Option href="/admin/moderate">
|
||||
<GoToAriaInfo />
|
||||
<Localized id="moderate-searchBar-allStories">
|
||||
<span>All stories</span>
|
||||
</Localized>
|
||||
</Search.Option>
|
||||
),
|
||||
onClickOrEnter,
|
||||
group: "CONTEXT",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function getContextOptionsWhenModeratingStory(
|
||||
onClickOrEnter: ListBoxOptionClickOrEnterHandler,
|
||||
story: ModerationQueuesData | null
|
||||
): SearchBarOptions {
|
||||
if (story === null) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
element: (
|
||||
<Search.Option
|
||||
href={`/admin/moderate/${story.id}`}
|
||||
details={story.metadata && story.metadata.author}
|
||||
>
|
||||
<GoToAriaInfo /> {story.metadata && story.metadata.title}
|
||||
</Search.Option>
|
||||
),
|
||||
onClickOrEnter,
|
||||
group: "CONTEXT",
|
||||
},
|
||||
{
|
||||
element: <Search.ModerateAllOption href="/admin/moderate" />,
|
||||
onClickOrEnter,
|
||||
group: "CONTEXT",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
type OnSearchCallback = (search: string) => void;
|
||||
|
||||
/**
|
||||
* useSearchOptions
|
||||
* @param onClickOrEnter A handler that reacts to click or enter for the search options
|
||||
* @param story Current active story
|
||||
*/
|
||||
function useSearchOptions(
|
||||
onClickOrEnter: ListBoxOptionClickOrEnterHandler,
|
||||
story: ModerationQueuesData | null
|
||||
): [SearchBarOptions, OnSearchCallback] {
|
||||
const searchStory = useFetch(SearchStoryFetch);
|
||||
|
||||
const [searchOptions, setSearchOptions] = useState<SearchBarOptions>([]);
|
||||
|
||||
useEffectWhenChanged(() => {
|
||||
setSearchOptions([]);
|
||||
}, [story]);
|
||||
|
||||
const searchCountRef = useRef(0);
|
||||
|
||||
const onSearch = useCallback(
|
||||
async (search: string) => {
|
||||
const nextSearchOptions: SearchBarOptions = [];
|
||||
|
||||
const searchCount = ++searchCountRef.current;
|
||||
|
||||
setSearchOptions([
|
||||
{
|
||||
element: (
|
||||
<Search.Option>
|
||||
<Spinner size="xs" />
|
||||
</Search.Option>
|
||||
),
|
||||
group: "SEARCH",
|
||||
},
|
||||
]);
|
||||
|
||||
const stories = await searchStory({ query: search, limit: 5 });
|
||||
if (searchCount !== searchCountRef.current) {
|
||||
// This result is old, so we can discard it.
|
||||
return;
|
||||
}
|
||||
|
||||
if (stories.edges.length > 0) {
|
||||
stories.edges.forEach(e => {
|
||||
// Don't show current story in search results.
|
||||
if (story && story.id === e.node.id) {
|
||||
return;
|
||||
}
|
||||
nextSearchOptions.push({
|
||||
element: (
|
||||
<Search.Option
|
||||
href={`/admin/moderate/${e.node.id}`}
|
||||
details={e.node.metadata && e.node.metadata.author}
|
||||
>
|
||||
<GoToAriaInfo /> {e.node.metadata && e.node.metadata.title}
|
||||
</Search.Option>
|
||||
),
|
||||
onClickOrEnter,
|
||||
group: "SEARCH",
|
||||
});
|
||||
});
|
||||
} else {
|
||||
nextSearchOptions.push({
|
||||
element: (
|
||||
<Search.Option>
|
||||
<Localized id="moderate-searchBar-noResults">
|
||||
<span>No results</span>
|
||||
</Localized>
|
||||
</Search.Option>
|
||||
),
|
||||
group: "SEARCH",
|
||||
});
|
||||
}
|
||||
if (stories.pageInfo.hasNextPage) {
|
||||
nextSearchOptions.push({
|
||||
element: (
|
||||
<Search.SeeAllOption
|
||||
href={`/admin/stories?q=${encodeURIComponent(search)}`}
|
||||
/>
|
||||
),
|
||||
onClickOrEnter,
|
||||
group: "SEARCH",
|
||||
});
|
||||
}
|
||||
setSearchOptions(nextSearchOptions);
|
||||
},
|
||||
[story, searchStory, setSearchOptions]
|
||||
);
|
||||
|
||||
return [searchOptions, onSearch];
|
||||
}
|
||||
|
||||
const ModerateSearchBarContainer: React.FunctionComponent<Props> = props => {
|
||||
const linkNavHandler = useLinkNavHandler(props.router);
|
||||
const contextOptions: PropTypesOf<
|
||||
typeof Search.Bar
|
||||
>["options"] = props.allStories
|
||||
? getContextOptionsWhenModeratingAll(linkNavHandler)
|
||||
: getContextOptionsWhenModeratingStory(linkNavHandler, props.story);
|
||||
|
||||
const [searchOptions, onSearch] = useSearchOptions(
|
||||
linkNavHandler,
|
||||
props.story
|
||||
);
|
||||
|
||||
const options = [...contextOptions, ...searchOptions];
|
||||
|
||||
const childProps = {
|
||||
options,
|
||||
onSearch,
|
||||
};
|
||||
|
||||
// Still loading the story..
|
||||
if (props.allStories) {
|
||||
return (
|
||||
<Localized id="moderate-searchBar-allStories" attrs={{ title: true }}>
|
||||
<Search.Bar title="All stories" {...childProps} />
|
||||
</Localized>
|
||||
);
|
||||
}
|
||||
if (!props.story) {
|
||||
return <Search.Bar title={""} {...childProps} />;
|
||||
}
|
||||
const t = props.story!.metadata && props.story!.metadata.title;
|
||||
if (t) {
|
||||
return <Search.Bar title={t} {...childProps} />;
|
||||
}
|
||||
return (
|
||||
<Localized
|
||||
id="moderate-searchBar-titleNotAvailable"
|
||||
attrs={{ title: true }}
|
||||
>
|
||||
<Search.Bar
|
||||
title={"Title not available"}
|
||||
options={options}
|
||||
onSearch={onSearch}
|
||||
/>
|
||||
</Localized>
|
||||
);
|
||||
};
|
||||
|
||||
const enhanced = withRouter(
|
||||
withFragmentContainer<Props>({
|
||||
story: graphql`
|
||||
fragment ModerateSearchBarContainer_story on Story {
|
||||
id
|
||||
metadata {
|
||||
title
|
||||
author
|
||||
}
|
||||
}
|
||||
`,
|
||||
})(ModerateSearchBarContainer)
|
||||
);
|
||||
|
||||
export default enhanced;
|
||||
@@ -113,6 +113,7 @@ const createQueueContainer = (
|
||||
},
|
||||
getVariables(props, { count, cursor }, fragmentVariables) {
|
||||
return {
|
||||
...fragmentVariables,
|
||||
count,
|
||||
cursor,
|
||||
};
|
||||
@@ -141,8 +142,8 @@ const createQueueContainer = (
|
||||
|
||||
export const PendingQueueContainer = createQueueContainer(
|
||||
graphql`
|
||||
query QueueContainerPendingQuery {
|
||||
moderationQueues {
|
||||
query QueueContainerPendingQuery($storyID: ID) {
|
||||
moderationQueues(storyID: $storyID) {
|
||||
pending {
|
||||
...QueueContainer_queue
|
||||
}
|
||||
@@ -155,8 +156,12 @@ export const PendingQueueContainer = createQueueContainer(
|
||||
graphql`
|
||||
# Pagination query to be fetched upon calling 'loadMore'.
|
||||
# Notice that we re-use our fragment, and the shape of this query matches our fragment spec.
|
||||
query QueueContainerPaginationPendingQuery($count: Int!, $cursor: Cursor) {
|
||||
moderationQueues {
|
||||
query QueueContainerPaginationPendingQuery(
|
||||
$storyID: ID
|
||||
$count: Int!
|
||||
$cursor: Cursor
|
||||
) {
|
||||
moderationQueues(storyID: $storyID) {
|
||||
pending {
|
||||
...QueueContainer_queue @arguments(count: $count, cursor: $cursor)
|
||||
}
|
||||
@@ -167,8 +172,8 @@ export const PendingQueueContainer = createQueueContainer(
|
||||
|
||||
export const ReportedQueueContainer = createQueueContainer(
|
||||
graphql`
|
||||
query QueueContainerReportedQuery {
|
||||
moderationQueues {
|
||||
query QueueContainerReportedQuery($storyID: ID) {
|
||||
moderationQueues(storyID: $storyID) {
|
||||
reported {
|
||||
...QueueContainer_queue
|
||||
}
|
||||
@@ -181,8 +186,12 @@ export const ReportedQueueContainer = createQueueContainer(
|
||||
graphql`
|
||||
# Pagination query to be fetched upon calling 'loadMore'.
|
||||
# Notice that we re-use our fragment, and the shape of this query matches our fragment spec.
|
||||
query QueueContainerPaginationReportedQuery($count: Int!, $cursor: Cursor) {
|
||||
moderationQueues {
|
||||
query QueueContainerPaginationReportedQuery(
|
||||
$storyID: ID
|
||||
$count: Int!
|
||||
$cursor: Cursor
|
||||
) {
|
||||
moderationQueues(storyID: $storyID) {
|
||||
reported {
|
||||
...QueueContainer_queue @arguments(count: $count, cursor: $cursor)
|
||||
}
|
||||
@@ -193,8 +202,8 @@ export const ReportedQueueContainer = createQueueContainer(
|
||||
|
||||
export const UnmoderatedQueueContainer = createQueueContainer(
|
||||
graphql`
|
||||
query QueueContainerUnmoderatedQuery {
|
||||
moderationQueues {
|
||||
query QueueContainerUnmoderatedQuery($storyID: ID) {
|
||||
moderationQueues(storyID: $storyID) {
|
||||
unmoderated {
|
||||
...QueueContainer_queue
|
||||
}
|
||||
@@ -208,10 +217,11 @@ export const UnmoderatedQueueContainer = createQueueContainer(
|
||||
# Pagination query to be fetched upon calling 'loadMore'.
|
||||
# Notice that we re-use our fragment, and the shape of this query matches our fragment spec.
|
||||
query QueueContainerPaginationUnmoderatedQuery(
|
||||
$storyID: ID
|
||||
$count: Int!
|
||||
$cursor: Cursor
|
||||
) {
|
||||
moderationQueues {
|
||||
moderationQueues(storyID: $storyID) {
|
||||
unmoderated {
|
||||
...QueueContainer_queue @arguments(count: $count, cursor: $cursor)
|
||||
}
|
||||
|
||||
@@ -75,9 +75,14 @@ const enhanced = (withPaginationContainer<
|
||||
@argumentDefinitions(
|
||||
count: { type: "Int!", defaultValue: 5 }
|
||||
cursor: { type: "Cursor" }
|
||||
storyID: { type: "ID" }
|
||||
) {
|
||||
comments(status: REJECTED, first: $count, after: $cursor)
|
||||
@connection(key: "RejectedQueue_comments") {
|
||||
comments(
|
||||
status: REJECTED
|
||||
storyID: $storyID
|
||||
first: $count
|
||||
after: $cursor
|
||||
) @connection(key: "RejectedQueue_comments") {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
@@ -105,6 +110,7 @@ const enhanced = (withPaginationContainer<
|
||||
},
|
||||
getVariables(props, { count, cursor }, fragmentVariables) {
|
||||
return {
|
||||
...fragmentVariables,
|
||||
count,
|
||||
cursor,
|
||||
};
|
||||
@@ -113,11 +119,12 @@ const enhanced = (withPaginationContainer<
|
||||
# Pagination query to be fetched upon calling 'loadMore'.
|
||||
# Notice that we re-use our fragment, and the shape of this query matches our fragment spec.
|
||||
query RejectedQueueContainerPaginationQuery(
|
||||
$storyID: ID
|
||||
$count: Int!
|
||||
$cursor: Cursor
|
||||
) {
|
||||
...RejectedQueueContainer_query
|
||||
@arguments(count: $count, cursor: $cursor)
|
||||
@arguments(storyID: $storyID, count: $count, cursor: $cursor)
|
||||
}
|
||||
`,
|
||||
}
|
||||
@@ -126,8 +133,8 @@ const enhanced = (withPaginationContainer<
|
||||
enhanced.routeConfig = {
|
||||
Component: enhanced,
|
||||
query: graphql`
|
||||
query RejectedQueueContainerQuery {
|
||||
...RejectedQueueContainer_query
|
||||
query RejectedQueueContainerQuery($storyID: ID) {
|
||||
...RejectedQueueContainer_query @arguments(storyID: $storyID)
|
||||
}
|
||||
`,
|
||||
cacheConfig: { force: true },
|
||||
|
||||
@@ -9,11 +9,15 @@ import styles from "./Stories.css";
|
||||
|
||||
interface Props {
|
||||
query: PropTypesOf<typeof StoryTableContainer>["query"];
|
||||
initialSearchFilter?: string;
|
||||
}
|
||||
|
||||
const Stories: StatelessComponent<Props> = props => (
|
||||
<MainLayout className={styles.root} data-testid="stories-container">
|
||||
<StoryTableContainer query={props.query} />
|
||||
<StoryTableContainer
|
||||
query={props.query}
|
||||
initialSearchFilter={props.initialSearchFilter}
|
||||
/>
|
||||
</MainLayout>
|
||||
);
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Link } from "found";
|
||||
import React, { StatelessComponent } from "react";
|
||||
|
||||
import NotAvailable from "talk-admin/components/NotAvailable";
|
||||
import { getModerationLink } from "talk-admin/helpers";
|
||||
import { PropTypesOf } from "talk-framework/types";
|
||||
import { TableCell, TableRow } from "talk-ui/components";
|
||||
import { TableCell, TableRow, TextLink } from "talk-ui/components";
|
||||
|
||||
import StatusChangeContainer from "../containers/StatusChangeContainer";
|
||||
import StatusText from "./StatusText";
|
||||
@@ -21,7 +23,12 @@ interface Props {
|
||||
const UserRow: StatelessComponent<Props> = props => (
|
||||
<TableRow>
|
||||
<TableCell className={styles.titleColumn}>
|
||||
{props.title || <NotAvailable />}
|
||||
<Link
|
||||
to={getModerationLink("default", props.storyID)}
|
||||
Component={TextLink}
|
||||
>
|
||||
{props.title || <NotAvailable />}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className={styles.authorColumn}>
|
||||
{props.author || <NotAvailable />}
|
||||
|
||||
@@ -10,3 +10,6 @@
|
||||
.statusColumn {
|
||||
width: 15%;
|
||||
}
|
||||
.clickToModerate {
|
||||
font-size: calc(12rem / var(--rem-base));
|
||||
}
|
||||
|
||||
@@ -38,9 +38,19 @@ const StoryTable: StatelessComponent<Props> = props => (
|
||||
<Table fullWidth>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<Localized id="stories-column-title">
|
||||
<TableCell className={styles.titleColumn}>Title</TableCell>
|
||||
</Localized>
|
||||
<TableCell className={styles.titleColumn}>
|
||||
<Localized id="stories-column-title">
|
||||
<span>Title</span>
|
||||
</Localized>{" "}
|
||||
<span className={styles.clickToModerate}>
|
||||
(
|
||||
<Localized id="stories-column-clickToModerate">
|
||||
<span>Click title to moderate story</span>
|
||||
</Localized>
|
||||
)
|
||||
</span>
|
||||
</TableCell>
|
||||
|
||||
<Localized id="stories-column-author">
|
||||
<TableCell className={styles.authorColumn}>Author</TableCell>
|
||||
</Localized>
|
||||
|
||||
@@ -36,6 +36,7 @@ const StoryTableFilter: StatelessComponent<Props> = props => (
|
||||
</Typography>
|
||||
</Localized>
|
||||
<Form
|
||||
initialValues={{ search: props.searchFilter }}
|
||||
onSubmit={({ search }: { search: string }) =>
|
||||
props.onSetSearchFilter(search)
|
||||
}
|
||||
|
||||
@@ -10,19 +10,33 @@ import Stories from "../components/Stories";
|
||||
interface Props {
|
||||
data: StoriesContainerQueryResponse | null;
|
||||
form: FormApi;
|
||||
initialSearchFilter?: string;
|
||||
}
|
||||
|
||||
const StoriesContainer: StatelessComponent<Props> = props => {
|
||||
return <Stories query={props.data} />;
|
||||
return (
|
||||
<Stories
|
||||
query={props.data}
|
||||
initialSearchFilter={props.initialSearchFilter}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const enhanced = withRouteConfig({
|
||||
query: graphql`
|
||||
query StoriesContainerQuery {
|
||||
...StoryTableContainer_query
|
||||
query StoriesContainerQuery($searchFilter: String) {
|
||||
...StoryTableContainer_query @arguments(searchFilter: $searchFilter)
|
||||
}
|
||||
`,
|
||||
cacheConfig: { force: true },
|
||||
prepareVariables: (params, match) => {
|
||||
return {
|
||||
searchFilter: match.location.query.q,
|
||||
};
|
||||
},
|
||||
render: ({ match, Component, ...rest }) => (
|
||||
<Component initialSearchFilter={match.location.query.q} {...rest} />
|
||||
),
|
||||
})(StoriesContainer);
|
||||
|
||||
export default enhanced;
|
||||
|
||||
@@ -16,6 +16,7 @@ import StoryTable from "../components/StoryTable";
|
||||
import StoryTableFilter from "../components/StoryTableFilter";
|
||||
|
||||
interface Props {
|
||||
initialSearchFilter?: string;
|
||||
query: QueryData | null;
|
||||
relay: RelayPaginationProp;
|
||||
}
|
||||
@@ -26,7 +27,9 @@ const StoryTableContainer: StatelessComponent<Props> = props => {
|
||||
: [];
|
||||
|
||||
const [loadMore, isLoadingMore] = useLoadMore(props.relay, 10);
|
||||
const [searchFilter, setSearchFilter] = useState<string>("");
|
||||
const [searchFilter, setSearchFilter] = useState<string>(
|
||||
props.initialSearchFilter || ""
|
||||
);
|
||||
const [statusFilter, setStatusFilter] = useState<GQLSTORY_STATUS_RL | null>(
|
||||
null
|
||||
);
|
||||
|
||||
@@ -45,8 +45,18 @@ async function createTestRenderer(
|
||||
Query: {
|
||||
settings: () => settings,
|
||||
viewer: () => viewer,
|
||||
moderationQueues: () => emptyModerationQueues,
|
||||
comments: () => emptyRejectedComments,
|
||||
moderationQueues: ({ variables }) => {
|
||||
expectAndFail(variables).toEqual({
|
||||
storyID: null,
|
||||
});
|
||||
return emptyModerationQueues;
|
||||
},
|
||||
comments: ({ variables }) => {
|
||||
expectAndFail(variables).toEqual({
|
||||
storyID: null,
|
||||
});
|
||||
return emptyRejectedComments;
|
||||
},
|
||||
},
|
||||
}),
|
||||
params.resolvers
|
||||
@@ -382,6 +392,7 @@ describe("rejected queue", () => {
|
||||
expectAndFail(variables).toEqual({
|
||||
first: 5,
|
||||
status: "REJECTED",
|
||||
storyID: null,
|
||||
});
|
||||
return {
|
||||
edges: [
|
||||
@@ -418,6 +429,7 @@ describe("rejected queue", () => {
|
||||
expectAndFail(variables).toEqual({
|
||||
first: 5,
|
||||
status: GQLCOMMENT_STATUS.REJECTED,
|
||||
storyID: null,
|
||||
});
|
||||
return {
|
||||
edges: [
|
||||
@@ -440,6 +452,7 @@ describe("rejected queue", () => {
|
||||
first: 10,
|
||||
after: rejectedComments[1].createdAt,
|
||||
status: GQLCOMMENT_STATUS.REJECTED,
|
||||
storyID: null,
|
||||
});
|
||||
return {
|
||||
edges: [
|
||||
@@ -519,6 +532,7 @@ describe("rejected queue", () => {
|
||||
expectAndFail(variables).toEqual({
|
||||
first: 5,
|
||||
status: "REJECTED",
|
||||
storyID: null,
|
||||
});
|
||||
return {
|
||||
edges: [
|
||||
|
||||
@@ -124,7 +124,19 @@ exports[`renders empty stories 1`] = `
|
||||
<th
|
||||
className="TableCell-root StoryTable-titleColumn TableCell-header"
|
||||
>
|
||||
Title
|
||||
<span>
|
||||
Title
|
||||
</span>
|
||||
|
||||
<span
|
||||
className="StoryTable-clickToModerate"
|
||||
>
|
||||
(
|
||||
<span>
|
||||
Click title to moderate story
|
||||
</span>
|
||||
)
|
||||
</span>
|
||||
</th>
|
||||
<th
|
||||
className="TableCell-root StoryTable-authorColumn TableCell-header"
|
||||
@@ -152,7 +164,13 @@ exports[`renders empty stories 1`] = `
|
||||
<td
|
||||
className="TableCell-root StoryRow-titleColumn TableCell-body"
|
||||
>
|
||||
Finally a Cure for Cancer
|
||||
<a
|
||||
className="TextLink-root"
|
||||
href="/admin/moderate/story-1"
|
||||
onClick={[Function]}
|
||||
>
|
||||
Finally a Cure for Cancer
|
||||
</a>
|
||||
</td>
|
||||
<td
|
||||
className="TableCell-root StoryRow-authorColumn TableCell-body"
|
||||
@@ -215,7 +233,13 @@ exports[`renders empty stories 1`] = `
|
||||
<td
|
||||
className="TableCell-root StoryRow-titleColumn TableCell-body"
|
||||
>
|
||||
First Colony on Mars
|
||||
<a
|
||||
className="TextLink-root"
|
||||
href="/admin/moderate/story-2"
|
||||
onClick={[Function]}
|
||||
>
|
||||
First Colony on Mars
|
||||
</a>
|
||||
</td>
|
||||
<td
|
||||
className="TableCell-root StoryRow-authorColumn TableCell-body"
|
||||
@@ -403,7 +427,19 @@ exports[`renders stories 1`] = `
|
||||
<th
|
||||
className="TableCell-root StoryTable-titleColumn TableCell-header"
|
||||
>
|
||||
Title
|
||||
<span>
|
||||
Title
|
||||
</span>
|
||||
|
||||
<span
|
||||
className="StoryTable-clickToModerate"
|
||||
>
|
||||
(
|
||||
<span>
|
||||
Click title to moderate story
|
||||
</span>
|
||||
)
|
||||
</span>
|
||||
</th>
|
||||
<th
|
||||
className="TableCell-root StoryTable-authorColumn TableCell-header"
|
||||
@@ -431,7 +467,13 @@ exports[`renders stories 1`] = `
|
||||
<td
|
||||
className="TableCell-root StoryRow-titleColumn TableCell-body"
|
||||
>
|
||||
Finally a Cure for Cancer
|
||||
<a
|
||||
className="TextLink-root"
|
||||
href="/admin/moderate/story-1"
|
||||
onClick={[Function]}
|
||||
>
|
||||
Finally a Cure for Cancer
|
||||
</a>
|
||||
</td>
|
||||
<td
|
||||
className="TableCell-root StoryRow-authorColumn TableCell-body"
|
||||
@@ -494,7 +536,13 @@ exports[`renders stories 1`] = `
|
||||
<td
|
||||
className="TableCell-root StoryRow-titleColumn TableCell-body"
|
||||
>
|
||||
First Colony on Mars
|
||||
<a
|
||||
className="TextLink-root"
|
||||
href="/admin/moderate/story-2"
|
||||
onClick={[Function]}
|
||||
>
|
||||
First Colony on Mars
|
||||
</a>
|
||||
</td>
|
||||
<td
|
||||
className="TableCell-root StoryRow-authorColumn TableCell-body"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Talk 5.0 – Embed Stream</title>
|
||||
<title>Talk 5.0 – Embed Stream – Story</title>
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="Content-type" content="text/html; charset=utf-8" />
|
||||
<meta name="viewport" content="width=device-width, user-scalable=no" />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Talk 5.0 – Embed Stream</title>
|
||||
<title>Talk 5.0 – Embed Stream – Story with Button</title>
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="Content-type" content="text/html; charset=utf-8" />
|
||||
<meta name="viewport" content="width=device-width, user-scalable=no" />
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
export { default as useEffectAfterMount } from "./useEffectAfterMount";
|
||||
export { default as usePrevious } from "./usePrevious";
|
||||
export { default as useEffectWhenChanged } from "./useEffectWhenChanged";
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import equals from "shallow-equals";
|
||||
|
||||
import useEffectAfterMount from "./useEffectAfterMount";
|
||||
import usePrevious from "./usePrevious";
|
||||
|
||||
/**
|
||||
* useEffectWhenChanged is a react hook that will run effects
|
||||
* when value changed.
|
||||
*/
|
||||
export default function useEffectWhenChanged(
|
||||
callback: () => void,
|
||||
deps: ReadonlyArray<any>
|
||||
) {
|
||||
const previous = usePrevious(deps);
|
||||
// We use `useEffectAfterMount` to make sure `previous` has an assigned value.
|
||||
useEffectAfterMount(() => {
|
||||
if (!equals(deps, previous)) {
|
||||
callback();
|
||||
}
|
||||
}, deps);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
/**
|
||||
* usePrevious is a react hook that will return the
|
||||
* previous value.
|
||||
*/
|
||||
export default function usePrevious<T>(value: T): T {
|
||||
const ref = useRef<T>();
|
||||
useEffect(() => {
|
||||
ref.current = value;
|
||||
});
|
||||
return ref.current as T;
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import React, { useCallback } from "react";
|
||||
import { fetchQuery as relayFetchQuery } from "react-relay";
|
||||
import {
|
||||
compose,
|
||||
hoistStatics,
|
||||
InferableComponentEnhancer,
|
||||
wrapDisplayName,
|
||||
} from "recompose";
|
||||
import {
|
||||
CacheConfig,
|
||||
Environment,
|
||||
GraphQLTaggedNode,
|
||||
Variables,
|
||||
} from "relay-runtime";
|
||||
|
||||
import { TalkContext, useTalkContext, withContext } from "../bootstrap";
|
||||
import extractPayload from "./extractPayload";
|
||||
|
||||
export interface Fetch<N, V, R> {
|
||||
name: N;
|
||||
fetch: (environment: Environment, variables: V, context: TalkContext) => R;
|
||||
}
|
||||
|
||||
export type FetchVariables<T extends { variables: any }> = T["variables"];
|
||||
export type FetchProp<T extends Fetch<any, any, any>> = T extends Fetch<
|
||||
any,
|
||||
infer V,
|
||||
infer R
|
||||
>
|
||||
? Parameters<T["fetch"]>[1] extends undefined
|
||||
? () => R
|
||||
: keyof Parameters<T["fetch"]>[1] extends never
|
||||
? () => R
|
||||
: (variables: V) => R
|
||||
: never;
|
||||
|
||||
export function createFetch<N extends string, V, R>(
|
||||
name: N,
|
||||
fetch: (environment: Environment, variables: V, context: TalkContext) => R
|
||||
): Fetch<N, V, R> {
|
||||
return {
|
||||
name,
|
||||
fetch,
|
||||
} as any;
|
||||
}
|
||||
|
||||
export async function fetchQuery<T extends { response: any }>(
|
||||
environment: Environment,
|
||||
taggedNode: GraphQLTaggedNode,
|
||||
variables: Variables,
|
||||
cacheConfig?: CacheConfig
|
||||
): Promise<T["response"][keyof T["response"]]> {
|
||||
const result = await relayFetchQuery(
|
||||
environment,
|
||||
taggedNode,
|
||||
variables,
|
||||
cacheConfig
|
||||
);
|
||||
return extractPayload(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* useFetch is a React Hook that
|
||||
* returns a callback to call the fetch.
|
||||
*/
|
||||
export function useFetch<V, R>(
|
||||
fetch: Fetch<any, V, R>
|
||||
): FetchProp<typeof fetch> {
|
||||
const context = useTalkContext();
|
||||
return useCallback<FetchProp<typeof fetch>>(
|
||||
((variables: V) => {
|
||||
context.eventEmitter.emit(`fetch.${fetch.name}`, variables);
|
||||
return fetch.fetch(context.relayEnvironment, variables, context);
|
||||
}) as any,
|
||||
[context]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* withFetch creates a HOC that injects the fetch as
|
||||
* a property.
|
||||
*/
|
||||
export function withFetch<N extends string, V, R>(
|
||||
fetch: Fetch<N, V, R>
|
||||
): InferableComponentEnhancer<{ [P in N]: FetchProp<typeof fetch> }> {
|
||||
return compose(
|
||||
withContext(context => ({ context })),
|
||||
hoistStatics((BaseComponent: React.ComponentType<any>) => {
|
||||
class WithFetch extends React.Component<{
|
||||
context: TalkContext;
|
||||
}> {
|
||||
public static displayName = wrapDisplayName(BaseComponent, "withFetch");
|
||||
|
||||
private fetch = (variables: V) => {
|
||||
this.props.context.eventEmitter.emit(
|
||||
`fetch.${fetch.name}`,
|
||||
variables
|
||||
);
|
||||
return fetch.fetch(
|
||||
this.props.context.relayEnvironment,
|
||||
variables,
|
||||
this.props.context
|
||||
);
|
||||
};
|
||||
|
||||
public render() {
|
||||
const { context: _, ...rest } = this.props;
|
||||
const inject = {
|
||||
[fetch.name]: this.fetch,
|
||||
};
|
||||
return <BaseComponent {...rest} {...inject} />;
|
||||
}
|
||||
}
|
||||
return WithFetch as React.ComponentType<any>;
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { fetchQuery as relayFetchQuery } from "react-relay";
|
||||
import {
|
||||
CacheConfig,
|
||||
Environment,
|
||||
GraphQLTaggedNode,
|
||||
Variables,
|
||||
} from "relay-runtime";
|
||||
|
||||
import extractPayload from "./extractPayload";
|
||||
|
||||
export default async function fetchQuery<R = any>(
|
||||
environment: Environment,
|
||||
taggedNode: GraphQLTaggedNode,
|
||||
variables: Variables,
|
||||
cacheConfig?: CacheConfig
|
||||
): Promise<R> {
|
||||
const result = await relayFetchQuery(
|
||||
environment,
|
||||
taggedNode,
|
||||
variables,
|
||||
cacheConfig
|
||||
);
|
||||
return extractPayload(result);
|
||||
}
|
||||
@@ -27,7 +27,14 @@ export {
|
||||
default as commitLocalUpdatePromisified,
|
||||
} from "./commitLocalUpdatePromisified";
|
||||
export { initLocalBaseState, setAccessTokenInLocalState } from "./localState";
|
||||
export { default as fetchQuery } from "./fetchQuery";
|
||||
export {
|
||||
fetchQuery,
|
||||
createFetch,
|
||||
FetchVariables,
|
||||
FetchProp,
|
||||
withFetch,
|
||||
useFetch,
|
||||
} from "./fetch";
|
||||
export { default as useRefetch } from "./useRefetch";
|
||||
export { default as useLoadMore } from "./useLoadMore";
|
||||
export { default as lookup } from "./lookup";
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState } from "react";
|
||||
import { RelayPaginationProp } from "react-relay";
|
||||
import { Variables } from "relay-runtime";
|
||||
|
||||
import { useEffectAfterMount } from "talk-framework/hooks";
|
||||
import { useEffectWhenChanged } from "talk-framework/hooks";
|
||||
|
||||
/**
|
||||
* useRefetch is a react hook that returns a `refetch` callback
|
||||
@@ -15,7 +15,7 @@ export default function useRefetch<V = Variables>(
|
||||
): [() => void, boolean] {
|
||||
const [manualRefetchCount, setManualRefetchCount] = useState(0);
|
||||
const [refetching, setRefetching] = useState(false);
|
||||
useEffectAfterMount(() => {
|
||||
useEffectWhenChanged(() => {
|
||||
setRefetching(true);
|
||||
const disposable = relay.refetchConnection(
|
||||
10,
|
||||
@@ -34,7 +34,7 @@ export default function useRefetch<V = Variables>(
|
||||
}
|
||||
};
|
||||
}, [
|
||||
relay,
|
||||
relay.environment,
|
||||
manualRefetchCount,
|
||||
...Object.keys(variables).reduce<any[]>((a, k) => {
|
||||
a.push((variables as any)[k]);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { RouteProps } from "found";
|
||||
import { RouteMatch, RouteProps } from "found";
|
||||
import * as React from "react";
|
||||
|
||||
interface InjectedProps<T> {
|
||||
@@ -7,25 +7,48 @@ interface InjectedProps<T> {
|
||||
retry?: Error | null;
|
||||
}
|
||||
|
||||
type RouteConfig = Partial<Pick<RouteProps, "query" | "getQuery">> &
|
||||
type RouteConfig<QueryResponse> = Partial<
|
||||
Pick<RouteProps, "query" | "getQuery">
|
||||
> &
|
||||
Partial<Pick<RouteProps, "data" | "getData" | "defer">> & {
|
||||
cacheConfig?: {
|
||||
force?: boolean;
|
||||
};
|
||||
prepareVariables?: (
|
||||
params: Record<string, string>,
|
||||
match: RouteMatch
|
||||
) => Record<string, any>;
|
||||
render?: (args: {
|
||||
error: Error;
|
||||
data: QueryResponse | null;
|
||||
retry: () => void;
|
||||
match: RouteMatch;
|
||||
Component: React.ComponentType<any>;
|
||||
}) => React.ReactElement;
|
||||
};
|
||||
|
||||
function withRouteConfig<QueryResponse>(config: RouteConfig) {
|
||||
function withRouteConfig<QueryResponse>(config: RouteConfig<QueryResponse>) {
|
||||
const hoc = <T extends InjectedProps<QueryResponse>>(
|
||||
component: React.ComponentType<T>
|
||||
) => {
|
||||
(component as any).routeConfig = {
|
||||
...config,
|
||||
Component: component,
|
||||
render: ({ error, props, retry, Component }: any) => {
|
||||
return React.createElement(Component, { error, data: props, retry });
|
||||
render: ({ error, props: data, retry, match, Component }: any) => {
|
||||
if (config.render) {
|
||||
return config.render({ error, data, retry, match, Component });
|
||||
}
|
||||
return React.createElement(Component, {
|
||||
error,
|
||||
data,
|
||||
retry,
|
||||
match,
|
||||
});
|
||||
},
|
||||
};
|
||||
return component as React.ComponentClass<T> & { routeConfig: RouteConfig };
|
||||
return component as React.ComponentClass<T> & {
|
||||
routeConfig: RouteConfig<QueryResponse>;
|
||||
};
|
||||
};
|
||||
return hoc;
|
||||
}
|
||||
|
||||
@@ -1,39 +1,35 @@
|
||||
import { graphql } from "react-relay";
|
||||
import { Environment } from "relay-runtime";
|
||||
|
||||
import { createFetchContainer, fetchQuery } from "talk-framework/lib/relay";
|
||||
import {
|
||||
createFetch,
|
||||
fetchQuery,
|
||||
FetchVariables,
|
||||
} from "talk-framework/lib/relay";
|
||||
import { RefreshSettingsQuery as QueryTypes } from "talk-stream/__generated__/RefreshSettingsQuery.graphql";
|
||||
|
||||
export type RefreshSettingsVariables = QueryTypes["variables"];
|
||||
|
||||
const query = graphql`
|
||||
query RefreshSettingsQuery($storyID: ID!) {
|
||||
settings {
|
||||
...StreamContainer_settings
|
||||
}
|
||||
# We also refrech story props that are
|
||||
# dependent on the settings.
|
||||
story(id: $storyID) {
|
||||
closedAt
|
||||
isClosed
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
function fetch(environment: Environment, variables: RefreshSettingsVariables) {
|
||||
return fetchQuery<QueryTypes["response"]["settings"]>(
|
||||
environment,
|
||||
query,
|
||||
variables,
|
||||
{ force: true }
|
||||
);
|
||||
}
|
||||
|
||||
export const withRefreshSettingsFetch = createFetchContainer(
|
||||
const RefreshSettingsFetch = createFetch(
|
||||
"refreshSettings",
|
||||
fetch
|
||||
(environment: Environment, variables: FetchVariables<QueryTypes>) => {
|
||||
return fetchQuery<QueryTypes>(
|
||||
environment,
|
||||
graphql`
|
||||
query RefreshSettingsQuery($storyID: ID!) {
|
||||
settings {
|
||||
...StreamContainer_settings
|
||||
}
|
||||
# We also refrech story props that are
|
||||
# dependent on the settings.
|
||||
story(id: $storyID) {
|
||||
closedAt
|
||||
isClosed
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables,
|
||||
{ force: true }
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export type RefreshSettingsFetch = (
|
||||
variables: RefreshSettingsVariables
|
||||
) => Promise<QueryTypes["response"]["settings"]>;
|
||||
export default RefreshSettingsFetch;
|
||||
|
||||
@@ -1,4 +1 @@
|
||||
export {
|
||||
withRefreshSettingsFetch,
|
||||
RefreshSettingsFetch,
|
||||
} from "./RefreshSettingsQuery";
|
||||
export { default as RefreshSettingsFetch } from "./RefreshSettingsQuery";
|
||||
|
||||
@@ -24,7 +24,7 @@ exports[`renders correctly 1`] = `
|
||||
}
|
||||
}
|
||||
/>
|
||||
<withContext(withContext(createMutationContainer(withContext(createFetchContainer(withContext(withLocalStateContainer(Relay(PostCommentFormContainer))))))))
|
||||
<withContext(withContext(createMutationContainer(withContext(withFetch(withContext(withLocalStateContainer(Relay(PostCommentFormContainer))))))))
|
||||
settings={
|
||||
Object {
|
||||
"reaction": Object {
|
||||
@@ -170,7 +170,7 @@ exports[`when there is more disables load more button 1`] = `
|
||||
}
|
||||
}
|
||||
/>
|
||||
<withContext(withContext(createMutationContainer(withContext(createFetchContainer(withContext(withLocalStateContainer(Relay(PostCommentFormContainer))))))))
|
||||
<withContext(withContext(createMutationContainer(withContext(withFetch(withContext(withLocalStateContainer(Relay(PostCommentFormContainer))))))))
|
||||
settings={
|
||||
Object {
|
||||
"reaction": Object {
|
||||
@@ -330,7 +330,7 @@ exports[`when there is more renders a load more button 1`] = `
|
||||
}
|
||||
}
|
||||
/>
|
||||
<withContext(withContext(createMutationContainer(withContext(createFetchContainer(withContext(withLocalStateContainer(Relay(PostCommentFormContainer))))))))
|
||||
<withContext(withContext(createMutationContainer(withContext(withFetch(withContext(withLocalStateContainer(Relay(PostCommentFormContainer))))))))
|
||||
settings={
|
||||
Object {
|
||||
"reaction": Object {
|
||||
@@ -490,7 +490,7 @@ exports[`when use is logged in renders correctly 1`] = `
|
||||
}
|
||||
}
|
||||
/>
|
||||
<withContext(withContext(createMutationContainer(withContext(createFetchContainer(withContext(withLocalStateContainer(Relay(PostCommentFormContainer))))))))
|
||||
<withContext(withContext(createMutationContainer(withContext(withFetch(withContext(withLocalStateContainer(Relay(PostCommentFormContainer))))))))
|
||||
settings={
|
||||
Object {
|
||||
"reaction": Object {
|
||||
|
||||
@@ -5,12 +5,13 @@ import { graphql } from "react-relay";
|
||||
import { isBeforeDate } from "talk-common/utils";
|
||||
import { withContext } from "talk-framework/lib/bootstrap";
|
||||
import { InvalidRequestError } from "talk-framework/lib/errors";
|
||||
import { withFragmentContainer } from "talk-framework/lib/relay";
|
||||
import { PropTypesOf } from "talk-framework/types";
|
||||
import {
|
||||
RefreshSettingsFetch,
|
||||
withRefreshSettingsFetch,
|
||||
} from "talk-stream/fetches";
|
||||
FetchProp,
|
||||
withFetch,
|
||||
withFragmentContainer,
|
||||
} from "talk-framework/lib/relay";
|
||||
import { PropTypesOf } from "talk-framework/types";
|
||||
import { RefreshSettingsFetch } from "talk-stream/fetches";
|
||||
|
||||
import { EditCommentFormContainer_comment as CommentData } from "talk-stream/__generated__/EditCommentFormContainer_comment.graphql";
|
||||
import { EditCommentFormContainer_settings as SettingsData } from "talk-stream/__generated__/EditCommentFormContainer_settings.graphql";
|
||||
@@ -37,7 +38,7 @@ interface Props {
|
||||
story: StoryData;
|
||||
onClose?: () => void;
|
||||
autofocus: boolean;
|
||||
refreshSettings: RefreshSettingsFetch;
|
||||
refreshSettings: FetchProp<typeof RefreshSettingsFetch>;
|
||||
}
|
||||
|
||||
interface State {
|
||||
@@ -156,7 +157,7 @@ const enhanced = withContext(({ sessionStorage, browserInfo }) => ({
|
||||
// Disable autofocus on ios and enable for the rest.
|
||||
autofocus: !browserInfo.ios,
|
||||
}))(
|
||||
withRefreshSettingsFetch(
|
||||
withFetch(RefreshSettingsFetch)(
|
||||
withEditCommentMutation(
|
||||
withFragmentContainer<Props>({
|
||||
comment: graphql`
|
||||
|
||||
@@ -7,7 +7,9 @@ import {
|
||||
ModerationNudgeError,
|
||||
} from "talk-framework/lib/errors";
|
||||
import {
|
||||
FetchProp,
|
||||
graphql,
|
||||
withFetch,
|
||||
withFragmentContainer,
|
||||
withLocalStateContainer,
|
||||
} from "talk-framework/lib/relay";
|
||||
@@ -16,10 +18,7 @@ import { PropTypesOf } from "talk-framework/types";
|
||||
import { PostCommentFormContainer_settings as SettingsData } from "talk-stream/__generated__/PostCommentFormContainer_settings.graphql";
|
||||
import { PostCommentFormContainer_story as StoryData } from "talk-stream/__generated__/PostCommentFormContainer_story.graphql";
|
||||
import { PostCommentFormContainerLocal as Local } from "talk-stream/__generated__/PostCommentFormContainerLocal.graphql";
|
||||
import {
|
||||
RefreshSettingsFetch,
|
||||
withRefreshSettingsFetch,
|
||||
} from "talk-stream/fetches";
|
||||
import { RefreshSettingsFetch } from "talk-stream/fetches";
|
||||
import {
|
||||
CreateCommentMutation,
|
||||
withCreateCommentMutation,
|
||||
@@ -37,7 +36,7 @@ import {
|
||||
|
||||
interface Props {
|
||||
createComment: CreateCommentMutation;
|
||||
refreshSettings: RefreshSettingsFetch;
|
||||
refreshSettings: FetchProp<typeof RefreshSettingsFetch>;
|
||||
sessionStorage: PromisifiedStorage;
|
||||
settings: SettingsData;
|
||||
local: Local;
|
||||
@@ -214,7 +213,7 @@ const enhanced = withContext(({ sessionStorage }) => ({
|
||||
sessionStorage,
|
||||
}))(
|
||||
withCreateCommentMutation(
|
||||
withRefreshSettingsFetch(
|
||||
withFetch(RefreshSettingsFetch)(
|
||||
withLocalStateContainer(
|
||||
graphql`
|
||||
fragment PostCommentFormContainerLocal on Local {
|
||||
|
||||
@@ -8,16 +8,17 @@ import {
|
||||
InvalidRequestError,
|
||||
ModerationNudgeError,
|
||||
} from "talk-framework/lib/errors";
|
||||
import { withFragmentContainer } from "talk-framework/lib/relay";
|
||||
import {
|
||||
FetchProp,
|
||||
withFetch,
|
||||
withFragmentContainer,
|
||||
} from "talk-framework/lib/relay";
|
||||
import { PromisifiedStorage } from "talk-framework/lib/storage";
|
||||
import { PropTypesOf } from "talk-framework/types";
|
||||
import { ReplyCommentFormContainer_comment as CommentData } from "talk-stream/__generated__/ReplyCommentFormContainer_comment.graphql";
|
||||
import { ReplyCommentFormContainer_settings as SettingsData } from "talk-stream/__generated__/ReplyCommentFormContainer_settings.graphql";
|
||||
import { ReplyCommentFormContainer_story as StoryData } from "talk-stream/__generated__/ReplyCommentFormContainer_story.graphql";
|
||||
import {
|
||||
RefreshSettingsFetch,
|
||||
withRefreshSettingsFetch,
|
||||
} from "talk-stream/fetches";
|
||||
import { RefreshSettingsFetch } from "talk-stream/fetches";
|
||||
import {
|
||||
CreateCommentReplyMutation,
|
||||
withCreateCommentReplyMutation,
|
||||
@@ -42,7 +43,7 @@ interface Props {
|
||||
onClose?: () => void;
|
||||
autofocus: boolean;
|
||||
localReply?: boolean;
|
||||
refreshSettings: RefreshSettingsFetch;
|
||||
refreshSettings: FetchProp<typeof RefreshSettingsFetch>;
|
||||
}
|
||||
|
||||
interface State {
|
||||
@@ -204,7 +205,7 @@ const enhanced = withContext(({ sessionStorage, browserInfo }) => ({
|
||||
// Disable autofocus on ios and enable for the rest.
|
||||
autofocus: !browserInfo.ios,
|
||||
}))(
|
||||
withRefreshSettingsFetch(
|
||||
withFetch(RefreshSettingsFetch)(
|
||||
withCreateCommentReplyMutation(
|
||||
withFragmentContainer<Props>({
|
||||
settings: graphql`
|
||||
|
||||
@@ -3,10 +3,7 @@
|
||||
}
|
||||
|
||||
.keyboardFocus {
|
||||
outline-width: 3px;
|
||||
outline-color: Highlight;
|
||||
outline-color: -webkit-focus-ring-color;
|
||||
outline-style: auto;
|
||||
@mixin outline;
|
||||
}
|
||||
|
||||
.mouseHover {
|
||||
|
||||
@@ -40,10 +40,7 @@
|
||||
|
||||
/* Box focus */
|
||||
.label.focus:before {
|
||||
outline-width: 3px;
|
||||
outline-color: Highlight;
|
||||
outline-color: -webkit-focus-ring-color;
|
||||
outline-style: auto;
|
||||
@mixin outline;
|
||||
}
|
||||
|
||||
/* Box checked */
|
||||
|
||||
@@ -7,6 +7,7 @@ import { withStyles } from "talk-ui/hocs";
|
||||
|
||||
import AriaInfo from "../AriaInfo";
|
||||
|
||||
import { PropTypesOf } from "talk-ui/types";
|
||||
import styles from "./Popover.css";
|
||||
|
||||
type Placement =
|
||||
@@ -40,15 +41,19 @@ interface ChildrenRenderProps {
|
||||
interface PopoverProps {
|
||||
body: (props: BodyRenderProps) => React.ReactNode | React.ReactElement<any>;
|
||||
children: (props: ChildrenRenderProps) => React.ReactNode;
|
||||
description: string;
|
||||
description?: string;
|
||||
id: string;
|
||||
className?: string;
|
||||
placement?: Placement;
|
||||
visible?: boolean;
|
||||
classes: typeof styles;
|
||||
modifiers?: PropTypesOf<typeof Popper>["modifiers"];
|
||||
eventsEnabled?: PropTypesOf<typeof Popper>["eventsEnabled"];
|
||||
positionFixed?: PropTypesOf<typeof Popper>["positionFixed"];
|
||||
}
|
||||
|
||||
interface State {
|
||||
visible: false;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
class Popover extends React.Component<PopoverProps> {
|
||||
@@ -109,10 +114,15 @@ class Popover extends React.Component<PopoverProps> {
|
||||
className,
|
||||
placement,
|
||||
classes,
|
||||
visible: controlledVisible,
|
||||
positionFixed,
|
||||
modifiers,
|
||||
eventsEnabled,
|
||||
...rest
|
||||
} = this.props;
|
||||
|
||||
const { visible } = this.state;
|
||||
const visible =
|
||||
controlledVisible !== undefined ? controlledVisible : this.state.visible;
|
||||
const popoverClassName = cn(classes.popover, {
|
||||
[classes.top]: placement!.startsWith("top"),
|
||||
[classes.left]: placement!.startsWith("left"),
|
||||
@@ -128,11 +138,16 @@ class Popover extends React.Component<PopoverProps> {
|
||||
children({
|
||||
ref: props.ref,
|
||||
toggleVisibility: this.toggleVisibility,
|
||||
visible: this.state.visible,
|
||||
visible,
|
||||
})
|
||||
}
|
||||
</Reference>
|
||||
<Popper placement={placement} eventsEnabled positionFixed={false}>
|
||||
<Popper
|
||||
placement={placement}
|
||||
eventsEnabled={eventsEnabled}
|
||||
positionFixed={positionFixed}
|
||||
modifiers={modifiers}
|
||||
>
|
||||
{props => (
|
||||
<div
|
||||
id={id}
|
||||
@@ -140,7 +155,9 @@ class Popover extends React.Component<PopoverProps> {
|
||||
aria-labelledby={`${id}-ariainfo`}
|
||||
aria-hidden={!visible}
|
||||
>
|
||||
<AriaInfo id={`${id}-ariainfo`}>{description}</AriaInfo>
|
||||
{description && (
|
||||
<AriaInfo id={`${id}-ariainfo`}>{description}</AriaInfo>
|
||||
)}
|
||||
{visible && (
|
||||
<div
|
||||
style={props.style}
|
||||
@@ -151,7 +168,7 @@ class Popover extends React.Component<PopoverProps> {
|
||||
? body({
|
||||
scheduleUpdate: props.scheduleUpdate,
|
||||
toggleVisibility: this.toggleVisibility,
|
||||
visible: this.state.visible,
|
||||
visible,
|
||||
})
|
||||
: body}
|
||||
</div>
|
||||
|
||||
@@ -35,10 +35,7 @@
|
||||
|
||||
/* Box focus */
|
||||
.label.focus:before {
|
||||
outline-width: 3px;
|
||||
outline-color: Highlight;
|
||||
outline-color: -webkit-focus-ring-color;
|
||||
outline-style: auto;
|
||||
@mixin outline;
|
||||
}
|
||||
|
||||
/* Box checked */
|
||||
|
||||
@@ -15,10 +15,7 @@
|
||||
}
|
||||
|
||||
.keyboardFocus:focus {
|
||||
outline-width: 3px;
|
||||
outline-color: Highlight;
|
||||
outline-color: -webkit-focus-ring-color;
|
||||
outline-style: auto;
|
||||
@mixin outline;
|
||||
}
|
||||
|
||||
.select {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { withStyles } from "talk-ui/hocs";
|
||||
|
||||
import styles from "./Spinner.css";
|
||||
|
||||
type Size = "sm" | "md";
|
||||
type Size = "xs" | "sm" | "md";
|
||||
|
||||
export interface SpinnerProps {
|
||||
/**
|
||||
@@ -21,6 +21,8 @@ export interface SpinnerProps {
|
||||
|
||||
function calculateSize(size: Size): number {
|
||||
switch (size) {
|
||||
case "xs":
|
||||
return 15;
|
||||
case "sm":
|
||||
return 30;
|
||||
case "md":
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import cn from "classnames";
|
||||
import React, { StatelessComponent } from "react";
|
||||
import React, { HTMLAttributes, StatelessComponent } from "react";
|
||||
|
||||
import { Flex } from "talk-ui/components";
|
||||
import { withStyles } from "talk-ui/hocs";
|
||||
|
||||
import styles from "./SubBar.css";
|
||||
|
||||
interface Props {
|
||||
interface Props extends HTMLAttributes<HTMLDivElement> {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
gutterBegin?: boolean;
|
||||
|
||||
@@ -7,13 +7,11 @@
|
||||
}
|
||||
|
||||
.header {
|
||||
composes: bodyCopy from "talk-ui/shared/typography.css";
|
||||
color: var(--palette-grey-dark);
|
||||
composes: tableHeading from "talk-ui/shared/typography.css";
|
||||
}
|
||||
|
||||
.body {
|
||||
composes: detail from "talk-ui/shared/typography.css";
|
||||
color: var(--palette-grey-dark);
|
||||
composes: tableData from "talk-ui/shared/typography.css";
|
||||
}
|
||||
|
||||
.alignCenter {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export { default as BaseButton } from "./BaseButton";
|
||||
export { default as Button } from "./Button";
|
||||
export { default as Backdrop } from "./Backdrop";
|
||||
export { default as ButtonIcon } from "./Button/ButtonIcon";
|
||||
export { default as Typography } from "./Typography";
|
||||
export { default as Popover } from "./Popover";
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
/** blur removes focus from current active element */
|
||||
export default function blur() {
|
||||
if (window.document.activeElement) {
|
||||
(window.document.activeElement as any).blur();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
export default function combineEventHandlers<A, B, C, D>(
|
||||
a: A,
|
||||
b: B,
|
||||
c: C,
|
||||
d: D
|
||||
): A & B & C & D;
|
||||
export default function combineEventHandlers<A, B, C>(
|
||||
a: A,
|
||||
b: B,
|
||||
c: C
|
||||
): A & B & C;
|
||||
export default function combineEventHandlers<A, B>(a: A, b: B): A & B;
|
||||
|
||||
/**
|
||||
* combineEventHandlers expects prop objects with React
|
||||
* like onEvent handlers e.g. onClick, onKeydown and combine them
|
||||
* by calling them one after another.
|
||||
*/
|
||||
export default function combineEventHandlers(...propObjects: any[]): any {
|
||||
const result: any = {};
|
||||
propObjects.forEach(o => {
|
||||
Object.keys(o).forEach(k => {
|
||||
if (k in result) {
|
||||
const prev = result[k];
|
||||
result[k] = (...args: any[]) => {
|
||||
prev(...args);
|
||||
(o as any)[k](...args);
|
||||
};
|
||||
} else {
|
||||
result[k] = (o as any)[k];
|
||||
}
|
||||
});
|
||||
});
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as blur } from "./blur";
|
||||
export { default as combineEventHandlers } from "./combineEventHandlers";
|
||||
@@ -0,0 +1,4 @@
|
||||
export { default as useFocus } from "./useFocus";
|
||||
export { default as usePreventFocusLoss } from "./usePreventFocusLoss";
|
||||
export { default as useBlurOnEsc } from "./useBlurOnEsc";
|
||||
export { default as useComboBox } from "./useComboBox";
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { blur } from "../helpers";
|
||||
|
||||
/** useBlurOnEsc returns handlers that calls blur when pressing ESC */
|
||||
export default function useBlurOnEsc(active: boolean) {
|
||||
return {
|
||||
onKeyDown: useCallback(
|
||||
(evt: React.KeyboardEvent) => {
|
||||
if (evt.keyCode === 27) {
|
||||
blur();
|
||||
}
|
||||
},
|
||||
[active]
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import React, { useState } from "react";
|
||||
|
||||
/**
|
||||
* ActiveDescendant is the id of the current active selection, which needs to be passed to `aria-activedescendant` prop
|
||||
*/
|
||||
export type ActiveDescendant = string | undefined;
|
||||
|
||||
/** EventHandlers are the ones that should be passed to the TextField */
|
||||
export interface EventHandlers {
|
||||
onBlur: React.EventHandler<React.FocusEvent>;
|
||||
onChange: React.EventHandler<React.FormEvent<HTMLInputElement>>;
|
||||
onKeyDown: React.EventHandler<React.KeyboardEvent>;
|
||||
}
|
||||
|
||||
export type ListBoxOptionElement = React.ReactElement<{
|
||||
id: string;
|
||||
"aria-selected": boolean;
|
||||
onClick: React.EventHandler<React.MouseEvent>;
|
||||
href?: string;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* ListBoxOptionClickOrEnterHandler is called whenever a click or enter
|
||||
* occurs on a list box option
|
||||
*/
|
||||
export type ListBoxOptionClickOrEnterHandler = (
|
||||
evt: React.KeyboardEvent | React.MouseEvent,
|
||||
element: ListBoxOptionElement
|
||||
) => void;
|
||||
|
||||
/**
|
||||
* ListBoxOption is the data for the options to pass to `useComboBox`.
|
||||
*/
|
||||
export interface ListBoxOption {
|
||||
element: ListBoxOptionElement;
|
||||
onClickOrEnter?: ListBoxOptionClickOrEnterHandler;
|
||||
}
|
||||
|
||||
/**
|
||||
* useComboBox accepts an `id` and a list of `ListBoxOption` and returns
|
||||
* a managed list of `ListBoxOption`, the active descendant and event handlers
|
||||
* for the textfield. You can find the managed props of the elements in
|
||||
* the type `ListBoxOptionElements`.
|
||||
*
|
||||
* @param id unique identifier for accessibility purposes
|
||||
* @param opts options to show in the listbox
|
||||
*/
|
||||
export default function useComboBox<T extends ListBoxOption>(
|
||||
id: string,
|
||||
opts: T[]
|
||||
): [T[], ActiveDescendant, EventHandlers] {
|
||||
const [activeIndex, setActiveIndex] = useState<number | null>(null);
|
||||
return [
|
||||
opts.map((item, i) => ({
|
||||
...item,
|
||||
element: React.cloneElement(item.element, {
|
||||
id: `${id}-${i}`,
|
||||
"aria-selected": activeIndex === i,
|
||||
key: `${id}-${i}`,
|
||||
onClick:
|
||||
item.element.props.onClick ||
|
||||
((evt: React.MouseEvent) => {
|
||||
if (item.onClickOrEnter) {
|
||||
item.onClickOrEnter!(evt, item.element);
|
||||
}
|
||||
}),
|
||||
}),
|
||||
})),
|
||||
activeIndex !== null ? `${id}-${activeIndex}` : undefined,
|
||||
{
|
||||
onBlur: () => {
|
||||
/** Reset active selection when the textfield is blurred */
|
||||
setActiveIndex(null);
|
||||
},
|
||||
onChange: () => {
|
||||
/** Reset active selection when the text changes */
|
||||
setActiveIndex(null);
|
||||
},
|
||||
onKeyDown: (evt: React.KeyboardEvent) => {
|
||||
// On Arror Down
|
||||
if (evt.keyCode === 40) {
|
||||
if (activeIndex !== opts.length - 1) {
|
||||
if (activeIndex === null) {
|
||||
setActiveIndex(0);
|
||||
} else {
|
||||
setActiveIndex((activeIndex || 0) + 1);
|
||||
}
|
||||
}
|
||||
evt.preventDefault();
|
||||
}
|
||||
// On Arrow Up
|
||||
else if (evt.keyCode === 38) {
|
||||
if (activeIndex !== 0) {
|
||||
if (activeIndex === null) {
|
||||
setActiveIndex(0);
|
||||
} else {
|
||||
setActiveIndex((activeIndex || 0) - 1);
|
||||
}
|
||||
}
|
||||
evt.preventDefault();
|
||||
}
|
||||
// On ENTER
|
||||
else if (evt.keyCode === 13) {
|
||||
if (activeIndex !== null && opts[activeIndex].onClickOrEnter) {
|
||||
opts[activeIndex].onClickOrEnter!(evt, opts[activeIndex].element);
|
||||
}
|
||||
} else {
|
||||
// Any other key.
|
||||
setActiveIndex(null);
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { EventHandler, FocusEvent, useCallback, useState } from "react";
|
||||
|
||||
/** useFocus tracks the focus state and returns the event handlers to do so */
|
||||
export default function useFocus(): [
|
||||
boolean,
|
||||
{ onFocus: EventHandler<FocusEvent>; onBlur: EventHandler<FocusEvent> }
|
||||
] {
|
||||
const [focused, setFocused] = useState<boolean>(false);
|
||||
const onFocus = useCallback(() => setFocused(true), [setFocused]);
|
||||
const onBlur = useCallback(() => setFocused(false), [setFocused]);
|
||||
return [focused, { onFocus, onBlur }];
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
/** usePreventFocusLoss returns event handlers that will prevent the current focus from being lost */
|
||||
export default function usePreventFocusLoss(active: boolean) {
|
||||
return {
|
||||
onMouseDown: useCallback(
|
||||
(evt: React.MouseEvent) => {
|
||||
if (!active || evt.target === window.document.activeElement) {
|
||||
return;
|
||||
}
|
||||
evt.preventDefault();
|
||||
},
|
||||
[active]
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -260,3 +260,19 @@
|
||||
letter-spacing: calc(0.1em / 18);
|
||||
color: var(--palette-text-primary);
|
||||
}
|
||||
|
||||
.tableHeading {
|
||||
font-family: var(--font-family-sans-serif);
|
||||
font-weight: var(--font-weight-medium);
|
||||
font-size: calc(14rem / var(--rem-base));
|
||||
line-height: calc(20em / 14);
|
||||
color: var(--palette-grey-dark);
|
||||
}
|
||||
|
||||
.tableData {
|
||||
font-family: var(--font-family-sans-serif);
|
||||
font-weight: var(--font-weight-regular);
|
||||
font-size: calc(14rem / var(--rem-base));
|
||||
line-height: calc(14em / 14);
|
||||
color: var(--palette-grey-dark);
|
||||
}
|
||||
|
||||
@@ -94,3 +94,9 @@
|
||||
letter-spacing: calc(0.2em / 16);
|
||||
color: var(--palette-grey-darkest);
|
||||
}
|
||||
@define-mixin outline {
|
||||
outline-width: 3px;
|
||||
outline-color: Highlight;
|
||||
outline-color: -webkit-focus-ring-color;
|
||||
outline-style: auto;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { GQLAcceptCommentPayloadTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
|
||||
import { moderationQueuesPayloadResolver } from "./ModerationQueues";
|
||||
import { moderationQueuesResolver } from "./ModerationQueues";
|
||||
|
||||
export const AcceptCommentPayload: GQLAcceptCommentPayloadTypeResolver = {
|
||||
moderationQueues: moderationQueuesPayloadResolver,
|
||||
moderationQueues: moderationQueuesResolver,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
AcceptCommentPayloadToModerationQueuesResolver,
|
||||
GQLModerationQueuesTypeResolver,
|
||||
QueryToModerationQueuesResolver,
|
||||
RejectCommentPayloadToModerationQueuesResolver,
|
||||
} from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
import { CommentConnectionInput } from "talk-server/models/comment";
|
||||
@@ -78,14 +79,16 @@ export const sharedModerationInputResolver = async (
|
||||
});
|
||||
|
||||
/**
|
||||
* moderationQueuesPayloadResolver implements the resolver that can be used for
|
||||
* moderation actions payloads.
|
||||
* moderationQueuesResolver implements the resolver that resolves to the
|
||||
* shared moderation queues or if `storyID` is provided to the story moderation
|
||||
* queues.
|
||||
*
|
||||
* @param source the source of the payload, not used
|
||||
* @param args the args of the payload containing potentially a Story ID
|
||||
* @param ctx the TenantContext for which we can use to retrieve the shared data
|
||||
*/
|
||||
export const moderationQueuesPayloadResolver:
|
||||
export const moderationQueuesResolver:
|
||||
| QueryToModerationQueuesResolver
|
||||
| AcceptCommentPayloadToModerationQueuesResolver
|
||||
| RejectCommentPayloadToModerationQueuesResolver = async (
|
||||
source,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { GQLQueryTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
|
||||
import { sharedModerationInputResolver } from "./ModerationQueues";
|
||||
import { moderationQueuesResolver } from "./ModerationQueues";
|
||||
|
||||
export const Query: Required<GQLQueryTypeResolver<void>> = {
|
||||
story: (source, args, ctx) => ctx.loaders.Stories.findOrCreate.load(args),
|
||||
@@ -16,5 +16,5 @@ export const Query: Required<GQLQueryTypeResolver<void>> = {
|
||||
ctx.loaders.Auth.discoverOIDCConfiguration.load(issuer),
|
||||
debugScrapeStoryMetadata: (source, { url }, ctx) =>
|
||||
ctx.loaders.Stories.debugScrapeMetadata.load(url),
|
||||
moderationQueues: sharedModerationInputResolver,
|
||||
moderationQueues: moderationQueuesResolver,
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { GQLRejectCommentPayloadTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
|
||||
import { moderationQueuesPayloadResolver } from "./ModerationQueues";
|
||||
import { moderationQueuesResolver } from "./ModerationQueues";
|
||||
|
||||
export const RejectCommentPayload: GQLRejectCommentPayloadTypeResolver = {
|
||||
moderationQueues: moderationQueuesPayloadResolver,
|
||||
moderationQueues: moderationQueuesResolver,
|
||||
};
|
||||
|
||||
@@ -2081,9 +2081,10 @@ type Query {
|
||||
|
||||
"""
|
||||
moderationQueues returns the set of ModerationQueues that are available for
|
||||
all stories.
|
||||
all stories or if given the story identified by the `storyID`.
|
||||
"""
|
||||
moderationQueues: ModerationQueues! @auth(roles: [ADMIN, MODERATOR])
|
||||
moderationQueues(storyID: ID): ModerationQueues!
|
||||
@auth(roles: [ADMIN, MODERATOR])
|
||||
}
|
||||
|
||||
################################################################################
|
||||
|
||||
@@ -330,6 +330,27 @@ moderate-decision = Decision
|
||||
moderate-single-goToModerationQueues = Go to moderation queues
|
||||
moderate-single-singleCommentView = Single Comment View
|
||||
|
||||
### Moderate Search Bar
|
||||
moderate-searchBar-allStories = All stories
|
||||
.title = All stories
|
||||
moderate-searchBar-noResults = No results
|
||||
moderate-searchBar-stories = Stories:
|
||||
moderate-searchBar-searchButton = Search
|
||||
moderate-searchBar-titleNotAvailable =
|
||||
.title = Title not available
|
||||
moderate-searchBar-comboBox =
|
||||
.aria-label = Search or jump to story
|
||||
moderate-searchBar-searchForm =
|
||||
.aria-label = Stories
|
||||
moderate-searchBar-currentlyModerating =
|
||||
.title = Currently moderating
|
||||
moderate-searchBar-searchResultsMostRecentFirst = Search results (Most recent first)
|
||||
moderate-searchBar-moderateAllStories = Moderate all stories
|
||||
moderate-searchBar-comboBoxTextField =
|
||||
.aria-label = Search or jump to story...
|
||||
.placeholder = Use quotation marks around each search term (e.g. “team”, “St. Louis”)
|
||||
moderate-searchBar-goTo = Go to
|
||||
moderate-searchBar-seeAllResults = See all results
|
||||
|
||||
## Create Username
|
||||
|
||||
@@ -452,6 +473,7 @@ stories-column-title = Title
|
||||
stories-column-author = Author
|
||||
stories-column-publishDate = Publish Date
|
||||
stories-column-status = Status
|
||||
stories-column-clickToModerate = Click title to moderate story
|
||||
|
||||
stories-status-popover =
|
||||
.description = A dropdown to change the story status
|
||||
|
||||
Reference in New Issue
Block a user