[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 14:23:46 +00:00
committed by Wyatt Johnson
parent a91de05af9
commit ab938985e4
86 changed files with 1934 additions and 319 deletions
@@ -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"