[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:
Kiwi
2019-04-26 16:23:46 +02:00
committed by Wyatt Johnson
parent a91de05af9
commit ab938985e4
86 changed files with 1934 additions and 319 deletions
+43 -12
View File
@@ -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",
+2
View File
@@ -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;
+2 -2
View File
@@ -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
View File
@@ -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)
+17
View File
@@ -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} />
@@ -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";
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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" />
+2
View File
@@ -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);
}
+8 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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";
+6
View File
@@ -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;
}
+2
View File
@@ -0,0 +1,2 @@
export { default as blur } from "./blur";
export { default as combineEventHandlers } from "./combineEventHandlers";
+4
View File
@@ -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";
+17
View File
@@ -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]
),
};
}
+114
View File
@@ -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);
}
},
},
];
}
+12
View File
@@ -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]
),
};
}
+16
View File
@@ -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);
}
+6
View File
@@ -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])
}
################################################################################
+22
View File
@@ -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