mirror of
https://github.com/wassname/talk.git
synced 2026-08-08 11:28:12 +08:00
[CORL-294] Moderate a single story + quick search (#2286)
* feat: allow passing a `storyID` to `Query.moderationQueues` * feat: moderate by story * feat: implement search story combobox * feat: add translations * fix: tests * fix: duplicate id * fix: rename file * chore: add more comments * fix: add missing translation * review: use query parameter "q" instead of url path * chore: move placeholder logic inside, maybe this makes it clearer :-D
This commit is contained in:
@@ -2,38 +2,31 @@ import { graphql } from "react-relay";
|
||||
import { Environment } from "relay-runtime";
|
||||
|
||||
import { DiscoverOIDCConfigurationQuery as QueryTypes } from "talk-admin/__generated__/DiscoverOIDCConfigurationQuery.graphql";
|
||||
import { createFetchContainer, fetchQuery } from "talk-framework/lib/relay";
|
||||
import {
|
||||
createFetch,
|
||||
fetchQuery,
|
||||
FetchVariables,
|
||||
} from "talk-framework/lib/relay";
|
||||
|
||||
export type DiscoverOIDCConfigurationVariables = QueryTypes["variables"];
|
||||
|
||||
const query = graphql`
|
||||
query DiscoverOIDCConfigurationQuery($issuer: String!) {
|
||||
discoverOIDCConfiguration(issuer: $issuer) {
|
||||
issuer
|
||||
authorizationURL
|
||||
tokenURL
|
||||
jwksURI
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
function fetch(
|
||||
environment: Environment,
|
||||
variables: DiscoverOIDCConfigurationVariables
|
||||
) {
|
||||
return fetchQuery<QueryTypes["response"]["discoverOIDCConfiguration"]>(
|
||||
environment,
|
||||
query,
|
||||
variables,
|
||||
{ force: true }
|
||||
);
|
||||
}
|
||||
|
||||
export const withDiscoverOIDCConfigurationFetch = createFetchContainer(
|
||||
const DiscoverOIDCConfigurationFetch = createFetch(
|
||||
"discoverOIDCConfiguration",
|
||||
fetch
|
||||
(environment: Environment, variables: FetchVariables<QueryTypes>) => {
|
||||
return fetchQuery<QueryTypes>(
|
||||
environment,
|
||||
graphql`
|
||||
query DiscoverOIDCConfigurationQuery($issuer: String!) {
|
||||
discoverOIDCConfiguration(issuer: $issuer) {
|
||||
issuer
|
||||
authorizationURL
|
||||
tokenURL
|
||||
jwksURI
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables,
|
||||
{ force: true }
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export type DiscoverOIDCConfigurationFetch = (
|
||||
variables: DiscoverOIDCConfigurationVariables
|
||||
) => Promise<QueryTypes["response"]["discoverOIDCConfiguration"]>;
|
||||
export default DiscoverOIDCConfigurationFetch;
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { graphql } from "react-relay";
|
||||
import { Environment } from "relay-runtime";
|
||||
|
||||
import { SearchStoryQuery as QueryTypes } from "talk-admin/__generated__/SearchStoryQuery.graphql";
|
||||
import {
|
||||
createFetch,
|
||||
fetchQuery,
|
||||
FetchVariables,
|
||||
} from "talk-framework/lib/relay";
|
||||
|
||||
const SearchStoryFetch = createFetch(
|
||||
"searchStory",
|
||||
(environment: Environment, variables: FetchVariables<QueryTypes>) => {
|
||||
return fetchQuery<QueryTypes>(
|
||||
environment,
|
||||
graphql`
|
||||
query SearchStoryQuery($query: String!, $limit: Int!) {
|
||||
stories(query: $query, first: $limit) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
metadata {
|
||||
title
|
||||
author
|
||||
}
|
||||
}
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables,
|
||||
{ force: true }
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export default SearchStoryFetch;
|
||||
@@ -1,4 +1,4 @@
|
||||
export {
|
||||
withDiscoverOIDCConfigurationFetch,
|
||||
DiscoverOIDCConfigurationFetch,
|
||||
default as DiscoverOIDCConfigurationFetch,
|
||||
} from "./DiscoverOIDCConfigurationQuery";
|
||||
export { default as SearchStoryFetch } from "./SearchStoryQuery";
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
const basePath = "/admin/moderate";
|
||||
|
||||
export default function getModerationLink(
|
||||
queue?: "default" | "reported" | "pending" | "unmoderated" | "rejected",
|
||||
storyID?: string | null
|
||||
) {
|
||||
const queuePart = queue && queue !== "default" ? `/${queue}` : "";
|
||||
const storyPart = storyID ? `/${storyID}` : "";
|
||||
return `${basePath}${queuePart}${storyPart}`;
|
||||
}
|
||||
@@ -3,16 +3,18 @@ import { ConnectionHandler, RecordSourceSelectorProxy } from "relay-runtime";
|
||||
type Queue = "reported" | "pending" | "unmoderated" | "rejected";
|
||||
|
||||
export default function getQueueConnection(
|
||||
store: RecordSourceSelectorProxy,
|
||||
queue: Queue,
|
||||
store: RecordSourceSelectorProxy
|
||||
storyID?: string
|
||||
) {
|
||||
const root = store.getRoot();
|
||||
if (queue === "rejected") {
|
||||
return ConnectionHandler.getConnection(root, "RejectedQueue_comments", {
|
||||
status: "REJECTED",
|
||||
storyID,
|
||||
});
|
||||
}
|
||||
const queuesRecord = root.getLinkedRecord("moderationQueues")!;
|
||||
const queuesRecord = root.getLinkedRecord("moderationQueues", { storyID })!;
|
||||
if (!queuesRecord) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export { default as getQueueConnection } from "./getQueueConnection";
|
||||
export { default as getModerationLink } from "./getModerationLink";
|
||||
|
||||
@@ -13,16 +13,22 @@ let clientMutationId = 0;
|
||||
|
||||
const AcceptCommentMutation = createMutation(
|
||||
"acceptComment",
|
||||
(environment: Environment, input: MutationInput<MutationTypes>) =>
|
||||
(
|
||||
environment: Environment,
|
||||
input: MutationInput<MutationTypes> & { storyID?: string }
|
||||
) =>
|
||||
commitMutationPromiseNormalized<MutationTypes>(environment, {
|
||||
mutation: graphql`
|
||||
mutation AcceptCommentMutation($input: AcceptCommentInput!) {
|
||||
mutation AcceptCommentMutation(
|
||||
$input: AcceptCommentInput!
|
||||
$storyID: ID
|
||||
) {
|
||||
acceptComment(input: $input) {
|
||||
comment {
|
||||
id
|
||||
status
|
||||
}
|
||||
moderationQueues {
|
||||
moderationQueues(storyID: $storyID) {
|
||||
unmoderated {
|
||||
count
|
||||
}
|
||||
@@ -39,7 +45,8 @@ const AcceptCommentMutation = createMutation(
|
||||
`,
|
||||
variables: {
|
||||
input: {
|
||||
...input,
|
||||
commentID: input.commentID,
|
||||
commentRevisionID: input.commentRevisionID,
|
||||
clientMutationId: clientMutationId.toString(),
|
||||
},
|
||||
},
|
||||
@@ -54,10 +61,10 @@ const AcceptCommentMutation = createMutation(
|
||||
},
|
||||
updater: store => {
|
||||
const connections = [
|
||||
getQueueConnection("reported", store),
|
||||
getQueueConnection("pending", store),
|
||||
getQueueConnection("unmoderated", store),
|
||||
getQueueConnection("rejected", store),
|
||||
getQueueConnection(store, "reported", input.storyID),
|
||||
getQueueConnection(store, "pending", input.storyID),
|
||||
getQueueConnection(store, "unmoderated", input.storyID),
|
||||
getQueueConnection(store, "rejected", input.storyID),
|
||||
].filter(c => c);
|
||||
connections.forEach(con =>
|
||||
ConnectionHandler.deleteNode(con, input.commentID)
|
||||
|
||||
@@ -13,16 +13,22 @@ let clientMutationId = 0;
|
||||
|
||||
const RejectCommentMutation = createMutation(
|
||||
"rejectComment",
|
||||
(environment: Environment, input: MutationInput<MutationTypes>) =>
|
||||
(
|
||||
environment: Environment,
|
||||
input: MutationInput<MutationTypes> & { storyID?: string }
|
||||
) =>
|
||||
commitMutationPromiseNormalized<MutationTypes>(environment, {
|
||||
mutation: graphql`
|
||||
mutation RejectCommentMutation($input: RejectCommentInput!) {
|
||||
mutation RejectCommentMutation(
|
||||
$input: RejectCommentInput!
|
||||
$storyID: ID
|
||||
) {
|
||||
rejectComment(input: $input) {
|
||||
comment {
|
||||
id
|
||||
status
|
||||
}
|
||||
moderationQueues {
|
||||
moderationQueues(storyID: $storyID) {
|
||||
unmoderated {
|
||||
count
|
||||
}
|
||||
@@ -39,7 +45,8 @@ const RejectCommentMutation = createMutation(
|
||||
`,
|
||||
variables: {
|
||||
input: {
|
||||
...input,
|
||||
commentID: input.commentID,
|
||||
commentRevisionID: input.commentRevisionID,
|
||||
clientMutationId: clientMutationId.toString(),
|
||||
},
|
||||
},
|
||||
@@ -54,9 +61,9 @@ const RejectCommentMutation = createMutation(
|
||||
},
|
||||
updater: store => {
|
||||
const connections = [
|
||||
getQueueConnection("reported", store),
|
||||
getQueueConnection("pending", store),
|
||||
getQueueConnection("unmoderated", store),
|
||||
getQueueConnection(store, "reported", input.storyID),
|
||||
getQueueConnection(store, "pending", input.storyID),
|
||||
getQueueConnection(store, "unmoderated", input.storyID),
|
||||
].filter(c => c);
|
||||
connections.forEach(con =>
|
||||
ConnectionHandler.deleteNode(con, input.commentID)
|
||||
|
||||
@@ -36,12 +36,29 @@ export default makeRouteConfig(
|
||||
<Route path="moderate" {...ModerateContainer.routeConfig}>
|
||||
<Redirect from="/" to="/admin/moderate/reported" />
|
||||
<Route path="reported" {...ReportedQueueContainer.routeConfig} />
|
||||
<Route
|
||||
path="reported/:storyID"
|
||||
{...ReportedQueueContainer.routeConfig}
|
||||
/>
|
||||
<Route path="pending" {...PendingQueueContainer.routeConfig} />
|
||||
<Route
|
||||
path="pending/:storyID"
|
||||
{...PendingQueueContainer.routeConfig}
|
||||
/>
|
||||
<Route
|
||||
path="unmoderated"
|
||||
{...UnmoderatedQueueContainer.routeConfig}
|
||||
/>
|
||||
<Route
|
||||
path="unmoderated/:storyID"
|
||||
{...UnmoderatedQueueContainer.routeConfig}
|
||||
/>
|
||||
<Route path="rejected" {...RejectedQueueContainer.routeConfig} />
|
||||
<Route
|
||||
path="rejected/:storyID"
|
||||
{...RejectedQueueContainer.routeConfig}
|
||||
/>
|
||||
<Redirect from=":storyID" to="/admin/moderate/reported/:storyID" />
|
||||
</Route>
|
||||
<Route path="stories" {...StoriesContainer.routeConfig} />
|
||||
<Route path="community" {...CommunityContainer.routeConfig} />
|
||||
|
||||
+7
-6
@@ -5,11 +5,12 @@ import { graphql } from "react-relay";
|
||||
|
||||
import { OIDCConfigContainer_auth as AuthData } from "talk-admin/__generated__/OIDCConfigContainer_auth.graphql";
|
||||
import { OIDCConfigContainer_authReadOnly as AuthReadOnlyData } from "talk-admin/__generated__/OIDCConfigContainer_authReadOnly.graphql";
|
||||
import { DiscoverOIDCConfigurationFetch } from "talk-admin/fetches";
|
||||
import {
|
||||
DiscoverOIDCConfigurationFetch,
|
||||
withDiscoverOIDCConfigurationFetch,
|
||||
} from "talk-admin/fetches";
|
||||
import { withFragmentContainer } from "talk-framework/lib/relay";
|
||||
FetchProp,
|
||||
withFetch,
|
||||
withFragmentContainer,
|
||||
} from "talk-framework/lib/relay";
|
||||
|
||||
import OIDCConfig from "../components/OIDCConfig";
|
||||
|
||||
@@ -18,7 +19,7 @@ interface Props {
|
||||
authReadOnly: AuthReadOnlyData;
|
||||
onInitValues: (values: AuthData) => void;
|
||||
disabled?: boolean;
|
||||
discoverOIDCConfiguration: DiscoverOIDCConfigurationFetch;
|
||||
discoverOIDCConfiguration: FetchProp<typeof DiscoverOIDCConfigurationFetch>;
|
||||
}
|
||||
|
||||
interface State {
|
||||
@@ -74,7 +75,7 @@ class OIDCConfigContainer extends React.Component<Props, State> {
|
||||
}
|
||||
}
|
||||
|
||||
const enhanced = withDiscoverOIDCConfigurationFetch(
|
||||
const enhanced = withFetch(DiscoverOIDCConfigurationFetch)(
|
||||
withFragmentContainer<Props>({
|
||||
auth: graphql`
|
||||
fragment OIDCConfigContainer_auth on Auth {
|
||||
|
||||
@@ -1,22 +1,20 @@
|
||||
import React from "react";
|
||||
import { createRenderer } from "react-test-renderer/shallow";
|
||||
|
||||
import { removeFragmentRefs } from "talk-framework/testHelpers";
|
||||
import { PropTypesOf } from "talk-framework/types";
|
||||
|
||||
import Moderate from "./Moderate";
|
||||
|
||||
import { PropTypesOf } from "talk-framework/types";
|
||||
it("renders correctly", () => {
|
||||
const renderer = createRenderer();
|
||||
renderer.render(<Moderate />);
|
||||
expect(renderer.getRenderOutput()).toMatchSnapshot();
|
||||
});
|
||||
const ModerateN = removeFragmentRefs(Moderate);
|
||||
|
||||
it("renders correctly with counts", () => {
|
||||
const props: PropTypesOf<typeof Moderate> = {
|
||||
unmoderatedCount: 3,
|
||||
reportedCount: 4,
|
||||
pendingCount: 0,
|
||||
it("renders correctly", () => {
|
||||
const props: PropTypesOf<typeof ModerateN> = {
|
||||
allStories: true,
|
||||
moderationQueues: {},
|
||||
story: {},
|
||||
};
|
||||
const renderer = createRenderer();
|
||||
renderer.render(<Moderate {...props} />);
|
||||
renderer.render(<ModerateN {...props} />);
|
||||
expect(renderer.getRenderOutput()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
@@ -1,31 +1,36 @@
|
||||
import React, { StatelessComponent } from "react";
|
||||
|
||||
import MainLayout from "talk-admin/components/MainLayout";
|
||||
import { PropTypesOf } from "talk-framework/types";
|
||||
import { SubBar } from "talk-ui/components/SubBar";
|
||||
|
||||
import Navigation from "./Navigation";
|
||||
import ModerateNavigationContainer from "../containers/ModerateNavigationContainer";
|
||||
import ModerateSearchBarContainer from "../containers/ModerateSearchBarContainer";
|
||||
|
||||
import styles from "./Moderate.css";
|
||||
|
||||
interface Props {
|
||||
unmoderatedCount?: number;
|
||||
reportedCount?: number;
|
||||
pendingCount?: number;
|
||||
story: PropTypesOf<typeof ModerateNavigationContainer>["story"] &
|
||||
PropTypesOf<typeof ModerateSearchBarContainer>["story"];
|
||||
moderationQueues: PropTypesOf<
|
||||
typeof ModerateNavigationContainer
|
||||
>["moderationQueues"];
|
||||
allStories: boolean;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
const Moderate: StatelessComponent<Props> = ({
|
||||
unmoderatedCount,
|
||||
reportedCount,
|
||||
pendingCount,
|
||||
moderationQueues,
|
||||
story,
|
||||
allStories,
|
||||
children,
|
||||
}) => (
|
||||
<div data-testid="moderate-container">
|
||||
<ModerateSearchBarContainer story={story} allStories={allStories} />
|
||||
<SubBar data-testid="moderate-subBar-container">
|
||||
<Navigation
|
||||
unmoderatedCount={unmoderatedCount}
|
||||
reportedCount={reportedCount}
|
||||
pendingCount={pendingCount}
|
||||
<ModerateNavigationContainer
|
||||
moderationQueues={moderationQueues}
|
||||
story={story}
|
||||
/>
|
||||
</SubBar>
|
||||
<div className={styles.background} />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Localized } from "fluent-react/compat";
|
||||
import React, { StatelessComponent } from "react";
|
||||
|
||||
import { getModerationLink } from "talk-admin/helpers";
|
||||
import { Counter, Icon, SubBarNavigation } from "talk-ui/components";
|
||||
|
||||
import NavigationLink from "./NavigationLink";
|
||||
@@ -9,16 +10,17 @@ interface Props {
|
||||
unmoderatedCount?: number;
|
||||
reportedCount?: number;
|
||||
pendingCount?: number;
|
||||
children?: React.ReactNode;
|
||||
storyID?: string | null;
|
||||
}
|
||||
|
||||
const Navigation: StatelessComponent<Props> = ({
|
||||
unmoderatedCount,
|
||||
reportedCount,
|
||||
pendingCount,
|
||||
storyID,
|
||||
}) => (
|
||||
<SubBarNavigation>
|
||||
<NavigationLink to="/admin/moderate/reported">
|
||||
<NavigationLink to={getModerationLink("reported", storyID)}>
|
||||
<Icon>flag</Icon>
|
||||
<Localized id="moderate-navigation-reported">
|
||||
<span>Reported</span>
|
||||
@@ -29,7 +31,7 @@ const Navigation: StatelessComponent<Props> = ({
|
||||
</Counter>
|
||||
)}
|
||||
</NavigationLink>
|
||||
<NavigationLink to="/admin/moderate/pending">
|
||||
<NavigationLink to={getModerationLink("pending", storyID)}>
|
||||
<Icon>access_time</Icon>
|
||||
<Localized id="moderate-navigation-pending">
|
||||
<span>Pending</span>
|
||||
@@ -40,7 +42,7 @@ const Navigation: StatelessComponent<Props> = ({
|
||||
</Counter>
|
||||
)}
|
||||
</NavigationLink>
|
||||
<NavigationLink to="/admin/moderate/unmoderated">
|
||||
<NavigationLink to={getModerationLink("unmoderated", storyID)}>
|
||||
<Icon>forum</Icon>
|
||||
<Localized id="moderate-navigation-unmoderated">
|
||||
<span>Unmoderated</span>
|
||||
@@ -51,7 +53,7 @@ const Navigation: StatelessComponent<Props> = ({
|
||||
</Counter>
|
||||
)}
|
||||
</NavigationLink>
|
||||
<NavigationLink to="/admin/moderate/rejected">
|
||||
<NavigationLink to={getModerationLink("rejected", storyID)}>
|
||||
<Icon>cancel</Icon>
|
||||
<Localized id="moderate-navigation-rejected">
|
||||
<span>Rejected</span>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
.root {
|
||||
height: calc(5 * var(--spacing-unit));
|
||||
background-color: #013f68;
|
||||
margin-top: -1px;
|
||||
}
|
||||
|
||||
.bumpZIndex {
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.popover {
|
||||
width: calc(75 * var(--spacing-unit));
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.listBox {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { Localized } from "fluent-react/compat";
|
||||
import React, { FunctionComponent, useCallback } from "react";
|
||||
import { Form } from "react-final-form";
|
||||
|
||||
import { Backdrop, Icon, Popover, SubBar } from "talk-ui/components";
|
||||
import { combineEventHandlers } from "talk-ui/helpers";
|
||||
import {
|
||||
useBlurOnEsc,
|
||||
useComboBox,
|
||||
useFocus,
|
||||
usePreventFocusLoss,
|
||||
} from "talk-ui/hooks";
|
||||
import { ListBoxOption } from "talk-ui/hooks/useComboBox";
|
||||
|
||||
import Field from "./Field";
|
||||
import Group from "./Group";
|
||||
|
||||
import styles from "./Bar.css";
|
||||
|
||||
/** Group of listbox options. */
|
||||
type Group = "CONTEXT" | "SEARCH";
|
||||
|
||||
interface Props {
|
||||
/** title of the current story */
|
||||
title: string;
|
||||
/** options to show in the combobox listbox */
|
||||
options: Array<ListBoxOption & { group: Group }>;
|
||||
/** onSearch will be called whenenver the user submits the search */
|
||||
onSearch?: (value: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bar is the container of the whole search bar.
|
||||
*/
|
||||
const Bar: FunctionComponent<Props> = ({ title, options, onSearch }) => {
|
||||
const [focused, focusHandlers] = useFocus();
|
||||
const preventFocusLossHandlers = usePreventFocusLoss(focused);
|
||||
const submitHandler = useCallback(
|
||||
({ search }: { search: string }) => onSearch && onSearch(search),
|
||||
[onSearch]
|
||||
);
|
||||
const blurOnEscProps = useBlurOnEsc(focused);
|
||||
const [
|
||||
mappedOptions,
|
||||
activeDescendant,
|
||||
keyboardNavigationHandlers,
|
||||
] = useComboBox("moderate-searchBar-listBoxOption", options);
|
||||
|
||||
const contextOptions = mappedOptions
|
||||
.filter(o => o.group === "CONTEXT")
|
||||
.map(o => o.element);
|
||||
const searchOptions = mappedOptions
|
||||
.filter(o => o.group === "SEARCH")
|
||||
.map(o => o.element);
|
||||
|
||||
return (
|
||||
<Localized id="moderate-searchBar-comboBox" attrs={{ "aria-label": true }}>
|
||||
<SubBar
|
||||
className={styles.root}
|
||||
data-testid="moderate-searchBar-container"
|
||||
role="combobox"
|
||||
aria-owns="moderate-searchBar-listBox"
|
||||
aria-label="Search or jump to story"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={focused}
|
||||
>
|
||||
<Backdrop className={styles.bumpZIndex} active={focused} />
|
||||
<Form onSubmit={submitHandler}>
|
||||
{({ handleSubmit }) => (
|
||||
<Localized
|
||||
id="moderate-searchBar-searchForm"
|
||||
attrs={{ "aria-label": true }}
|
||||
>
|
||||
<form
|
||||
role="search"
|
||||
aria-label="Stories"
|
||||
className={styles.bumpZIndex}
|
||||
onSubmit={handleSubmit}
|
||||
{...preventFocusLossHandlers}
|
||||
>
|
||||
<Popover
|
||||
id={"moderate-searchBar-popover"}
|
||||
placement="bottom"
|
||||
classes={{ popover: styles.popover }}
|
||||
visible={focused}
|
||||
eventsEnabled={false}
|
||||
modifiers={{
|
||||
preventOverflow: { enabled: false },
|
||||
flip: { enabled: false },
|
||||
hide: { enabled: false },
|
||||
}}
|
||||
body={() => (
|
||||
<ul
|
||||
id="moderate-searchBar-listBox"
|
||||
role="listbox"
|
||||
className={styles.listBox}
|
||||
>
|
||||
{contextOptions.length > 0 && (
|
||||
<Localized
|
||||
id="moderate-searchBar-currentlyModerating"
|
||||
attrs={{ title: true }}
|
||||
>
|
||||
<Group
|
||||
title="Currently moderating"
|
||||
id="moderate-searchBar-context"
|
||||
>
|
||||
{contextOptions}
|
||||
</Group>
|
||||
</Localized>
|
||||
)}
|
||||
{searchOptions.length > 0 && (
|
||||
<Group
|
||||
title={
|
||||
<>
|
||||
<Icon>search</Icon>{" "}
|
||||
<Localized id="moderate-searchBar-searchResultsMostRecentFirst">
|
||||
<span>Search results (Most recent first)</span>
|
||||
</Localized>
|
||||
</>
|
||||
}
|
||||
id="moderate-searchBar-search"
|
||||
light
|
||||
>
|
||||
{searchOptions}
|
||||
</Group>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
>
|
||||
{({ ref }) => (
|
||||
<div ref={ref}>
|
||||
<Field
|
||||
title={title}
|
||||
{...combineEventHandlers(
|
||||
focusHandlers,
|
||||
blurOnEscProps,
|
||||
keyboardNavigationHandlers
|
||||
)}
|
||||
focused={focused}
|
||||
aria-controls="moderate-searchBar-listBox"
|
||||
aria-autocomplete="list"
|
||||
aria-activedescendant={activeDescendant}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Popover>
|
||||
</form>
|
||||
</Localized>
|
||||
)}
|
||||
</Form>
|
||||
</SubBar>
|
||||
</Localized>
|
||||
);
|
||||
};
|
||||
|
||||
export default Bar;
|
||||
@@ -0,0 +1,87 @@
|
||||
.root {
|
||||
width: calc(75 * var(--spacing-unit));
|
||||
height: calc(3 * var(--spacing-unit));
|
||||
}
|
||||
|
||||
.begin {
|
||||
background-color: var(--palette-primary-darkest);
|
||||
border-top-left-radius: var(--round-corners);
|
||||
border-bottom-left-radius: var(--round-corners);
|
||||
min-width: calc(4 * var(--spacing-unit));
|
||||
flex-shrink: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.beginStories {
|
||||
font-size: calc(13rem / var(--rem-base));
|
||||
font-weight: var(--font-weight-medium);
|
||||
font-family: var(--font-family-sans-serif);
|
||||
line-height: 1.5;
|
||||
letter-spacing: calc(0.2em / 13);
|
||||
color: var(--palette-text-light);
|
||||
text-transform: uppercase;
|
||||
padding-right: calc(0.25 * var(--spacing-unit));
|
||||
}
|
||||
|
||||
.searchIcon {
|
||||
padding: 0 calc(0.5 * var(--spacing-unit)) 0 calc(0.75 * var(--spacing-unit));
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--palette-text-light);
|
||||
}
|
||||
|
||||
.end {
|
||||
min-width: calc(4 * var(--spacing-unit));
|
||||
background-color: var(--palette-primary-darkest);
|
||||
border-top-right-radius: var(--round-corners);
|
||||
border-bottom-right-radius: var(--round-corners);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.searchButton {
|
||||
composes: button from "talk-ui/shared/typography.css";
|
||||
padding: 0 calc(1 * var(--spacing-unit));
|
||||
color: var(--palette-text-light);
|
||||
border-left: 1px solid var(--palette-text-light);
|
||||
height: calc(3 * var(--spacing-unit) - 4px);
|
||||
|
||||
&:disabled {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.input {
|
||||
composes: inputText placeholderPseudo from "talk-ui/shared/typography.css";
|
||||
position: relative;
|
||||
display: block;
|
||||
padding: calc(0.5 * var(--spacing-unit));
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
line-height: 30px;
|
||||
align-self: stretch;
|
||||
color: var(--palette-text-light);
|
||||
border: 0;
|
||||
background-color: var(--palette-primary-darkest);
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--palette-text-light);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&:read-only {
|
||||
opacity: 0.5;
|
||||
}
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.inputWithTitle {
|
||||
text-align: center;
|
||||
&::placeholder {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import cn from "classnames";
|
||||
import { Localized } from "fluent-react/compat";
|
||||
import React, { FunctionComponent, HTMLAttributes } from "react";
|
||||
import { Field as FormField } from "react-final-form";
|
||||
|
||||
import { BaseButton, Flex, Icon } from "talk-ui/components";
|
||||
|
||||
import styles from "./Field.css";
|
||||
|
||||
interface Props extends HTMLAttributes<HTMLInputElement> {
|
||||
/** title of the story */
|
||||
title: string;
|
||||
className?: string;
|
||||
focused?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Field is the TextField for the search entry.
|
||||
*/
|
||||
const Field: FunctionComponent<Props> = ({
|
||||
title,
|
||||
focused,
|
||||
className,
|
||||
onBlur,
|
||||
onChange,
|
||||
...rest
|
||||
}) => {
|
||||
return (
|
||||
<FormField name="search">
|
||||
{({ input }) => (
|
||||
<Flex className={cn(className, styles.root)} alignItems="stretch">
|
||||
<Flex className={styles.begin} alignItems="center">
|
||||
<Icon className={styles.searchIcon} size="md">
|
||||
search
|
||||
</Icon>
|
||||
{focused && (
|
||||
<Localized id="moderate-searchBar-stories">
|
||||
<div className={styles.beginStories}>Stories:</div>
|
||||
</Localized>
|
||||
)}
|
||||
</Flex>
|
||||
<Localized
|
||||
id="moderate-searchBar-comboBoxTextField"
|
||||
attrs={{ "aria-label": true, placeholder: Boolean(focused) }}
|
||||
>
|
||||
<input
|
||||
name={input.name}
|
||||
onChange={evt => {
|
||||
if (onChange) {
|
||||
onChange(evt);
|
||||
}
|
||||
input.onChange(evt);
|
||||
}}
|
||||
value={input.value}
|
||||
className={cn(styles.input, {
|
||||
[styles.inputWithTitle]: !focused,
|
||||
})}
|
||||
placeholder={
|
||||
focused
|
||||
? "Use quotation marks around each search term (e.g. “team”, “St. Louis”)"
|
||||
: title
|
||||
}
|
||||
aria-label="Search or jump to story..."
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
onBlur={evt => {
|
||||
// Reset value when blurring.
|
||||
input.onChange("");
|
||||
if (onBlur) {
|
||||
onBlur(evt);
|
||||
}
|
||||
}}
|
||||
{...rest}
|
||||
/>
|
||||
</Localized>
|
||||
<Flex className={styles.end} alignItems="center">
|
||||
{focused && (
|
||||
<Localized id="moderate-searchBar-searchButton">
|
||||
<BaseButton
|
||||
className={styles.searchButton}
|
||||
type="submit"
|
||||
disabled={!Boolean(input.value)}
|
||||
>
|
||||
Search
|
||||
</BaseButton>
|
||||
</Localized>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
)}
|
||||
</FormField>
|
||||
);
|
||||
};
|
||||
|
||||
export default Field;
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Localized } from "fluent-react/compat";
|
||||
import React, { FunctionComponent } from "react";
|
||||
|
||||
import { AriaInfo } from "talk-ui/components";
|
||||
|
||||
const GoToAriaInfo: FunctionComponent = () => (
|
||||
<Localized id="moderate-searchBar-goTo">
|
||||
<AriaInfo>Go to</AriaInfo>
|
||||
</Localized>
|
||||
);
|
||||
|
||||
export default GoToAriaInfo;
|
||||
@@ -0,0 +1,24 @@
|
||||
.root {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: calc(3 * var(--spacing-unit));
|
||||
padding-left: calc(1.5 * var(--spacing-unit));
|
||||
background: var(--palette-text-primary);
|
||||
|
||||
font-family: var(--font-family-sans-serif);
|
||||
font-weight: var(--font-weight-medium);
|
||||
font-size: calc(13rem / var(--rem-base));
|
||||
line-height: calc(16em / 14);
|
||||
color: var(--palette-text-light);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.light {
|
||||
background: var(--palette-grey-light);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import cn from "classnames";
|
||||
import React, { FunctionComponent } from "react";
|
||||
|
||||
import styles from "./Group.css";
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
title: React.ReactNode;
|
||||
light?: boolean;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Group represents a ListBox Group
|
||||
*/
|
||||
const Group: FunctionComponent<Props> = ({ title, children, id, light }) => {
|
||||
return (
|
||||
<ul
|
||||
role="group"
|
||||
aria-labelledby={`${id}-title`}
|
||||
id={id}
|
||||
className={styles.root}
|
||||
>
|
||||
<li
|
||||
id={`${id}-title`}
|
||||
className={cn(styles.title, { [styles.light]: light })}
|
||||
>
|
||||
{title}
|
||||
</li>
|
||||
{children}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
export default Group;
|
||||
@@ -0,0 +1,22 @@
|
||||
.root {
|
||||
&:not(:first-child) {
|
||||
border-top: 1px solid var(--palette-divider);
|
||||
}
|
||||
&[aria-selected="true"] {
|
||||
@mixin outline;
|
||||
}
|
||||
}
|
||||
|
||||
.link {
|
||||
justify-content: left;
|
||||
min-height: calc(4 * var(--spacing-unit));
|
||||
font-family: var(--font-family-sans-serif);
|
||||
font-weight: var(--font-weight-medium);
|
||||
font-size: calc(16rem / var(--rem-base));
|
||||
line-height: calc(16em / 16);
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-weight: var(--font-weight-medium);
|
||||
margin-top: -2px;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import cn from "classnames";
|
||||
import { Localized } from "fluent-react/compat";
|
||||
import React, { FunctionComponent, HTMLAttributes } from "react";
|
||||
|
||||
import { Button, Icon } from "talk-ui/components";
|
||||
|
||||
import styles from "./ModerateAllOption.css";
|
||||
|
||||
interface Props extends HTMLAttributes<HTMLLIElement> {
|
||||
href?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* ModerateAllOption is a listbox option that renders a moderate all button.
|
||||
*/
|
||||
const ModerateAllOption: FunctionComponent<Props> = ({
|
||||
className,
|
||||
href,
|
||||
...rest
|
||||
}) => {
|
||||
return (
|
||||
<li role="option" className={cn(className, styles.root)} {...rest}>
|
||||
<Button
|
||||
href={href}
|
||||
color="primary"
|
||||
className={styles.link}
|
||||
anchor
|
||||
fullWidth
|
||||
tabIndex={-1}
|
||||
>
|
||||
<Localized id="moderate-searchBar-moderateAllStories">
|
||||
<span>Moderate all stories</span>
|
||||
</Localized>
|
||||
<span>
|
||||
<Icon className={styles.icon}>arrow_forward</Icon>
|
||||
</span>
|
||||
</Button>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModerateAllOption;
|
||||
@@ -0,0 +1,42 @@
|
||||
.root {
|
||||
&:not(:first-child) {
|
||||
border-top: 1px solid var(--palette-divider);
|
||||
}
|
||||
&[aria-selected="true"] .container {
|
||||
@mixin outline;
|
||||
}
|
||||
}
|
||||
.container {
|
||||
min-height: calc(4 * var(--spacing-unit));
|
||||
padding: var(--spacing-unit) calc(2.5 * var(--spacing-unit));
|
||||
box-sizing: border-box;
|
||||
&:hover {
|
||||
background: var(--palette-grey-lightest);
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
font-family: var(--font-family-sans-serif);
|
||||
font-weight: var(--font-weight-medium);
|
||||
font-size: calc(16rem / var(--rem-base));
|
||||
line-height: calc(16em / 16);
|
||||
color: var(--palette-text-primary);
|
||||
}
|
||||
|
||||
.titleWithDetails {
|
||||
font-size: calc(14rem / var(--rem-base));
|
||||
}
|
||||
|
||||
.details {
|
||||
padding-top: 3px;
|
||||
font-family: var(--font-family-sans-serif);
|
||||
font-weight: var(--font-weight-regular);
|
||||
font-size: calc(14rem / var(--rem-base));
|
||||
line-height: calc(14em / 14);
|
||||
color: var(--palette-grey-dark);
|
||||
}
|
||||
|
||||
.link {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import cn from "classnames";
|
||||
import React, { FunctionComponent, HTMLAttributes } from "react";
|
||||
|
||||
import styles from "./Option.css";
|
||||
|
||||
interface Props extends HTMLAttributes<HTMLLIElement> {
|
||||
href?: string;
|
||||
/** details contains additional information like the author */
|
||||
details?: React.ReactNode;
|
||||
/** children contains e.g. the title of the option */
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Group represents a generic listbox option
|
||||
*/
|
||||
const Option: FunctionComponent<Props> = ({
|
||||
details,
|
||||
children,
|
||||
className,
|
||||
href,
|
||||
...rest
|
||||
}) => {
|
||||
const container = (
|
||||
<div className={styles.container}>
|
||||
<div
|
||||
className={cn(styles.title, {
|
||||
[styles.titleWithDetails]: Boolean(details),
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
<div className={styles.details}>{details}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<li role="option" className={cn(className, styles.root)} {...rest}>
|
||||
{href && (
|
||||
<a href={href} className={styles.link} tabIndex={-1}>
|
||||
{container}
|
||||
</a>
|
||||
)}
|
||||
{!Boolean(href) && container}
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
export default Option;
|
||||
@@ -0,0 +1,33 @@
|
||||
.root {
|
||||
&[aria-selected="true"] {
|
||||
@mixin outline;
|
||||
}
|
||||
}
|
||||
|
||||
.link {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
background: var(--palette-primary-dark);
|
||||
min-height: calc(3 * var(--spacing-unit));
|
||||
|
||||
font-family: var(--font-family-sans-serif);
|
||||
font-weight: var(--font-weight-medium);
|
||||
font-size: calc(13rem / var(--rem-base));
|
||||
line-height: calc(16em / 13);
|
||||
color: var(--palette-text-light);
|
||||
|
||||
text-decoration: none;
|
||||
text-transform: uppercase;
|
||||
&:hover {
|
||||
background: var(--palette-primary-darkest);
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-weight: var(--font-weight-medium);
|
||||
padding-left: calc(0.5 * var(--spacing-unit));
|
||||
line-height: calc(16em / 13);
|
||||
margin-top: -2px;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import cn from "classnames";
|
||||
import { Localized } from "fluent-react/compat";
|
||||
import React, { FunctionComponent, HTMLAttributes } from "react";
|
||||
|
||||
import { Icon } from "talk-ui/components";
|
||||
|
||||
import styles from "./SeeAllOption.css";
|
||||
|
||||
interface Props extends HTMLAttributes<HTMLLIElement> {
|
||||
href?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* SeeAllOption is a listbox option that renders a see all search results button.
|
||||
*/
|
||||
const SeeAllOption: FunctionComponent<Props> = ({
|
||||
className,
|
||||
href,
|
||||
...rest
|
||||
}) => {
|
||||
return (
|
||||
<li role="option" className={cn(className, styles.root)} {...rest}>
|
||||
<a className={styles.link} href={href || "#"} tabIndex={-1}>
|
||||
<Localized id="moderate-searchBar-seeAllResults">
|
||||
<span>See all results</span>
|
||||
</Localized>
|
||||
<Icon className={styles.icon}>arrow_forward</Icon>
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
export default SeeAllOption;
|
||||
@@ -0,0 +1,5 @@
|
||||
export { default as Bar } from "./Bar";
|
||||
export { default as Field } from "./Field";
|
||||
export { default as Option } from "./Option";
|
||||
export { default as ModerateAllOption } from "./ModerateAllOption";
|
||||
export { default as SeeAllOption } from "./SeeAllOption";
|
||||
+6
-25
@@ -4,35 +4,16 @@ exports[`renders correctly 1`] = `
|
||||
<div
|
||||
data-testid="moderate-container"
|
||||
>
|
||||
<withPropsOnChange(SubBar)
|
||||
data-testid="moderate-subBar-container"
|
||||
>
|
||||
<Navigation />
|
||||
</withPropsOnChange(SubBar)>
|
||||
<div
|
||||
className="Moderate-background"
|
||||
<withRouter(Relay(ModerateSearchBarContainer))
|
||||
allStories={true}
|
||||
story={Object {}}
|
||||
/>
|
||||
<MainLayout
|
||||
data-testid="moderate-main-container"
|
||||
>
|
||||
<main
|
||||
className="Moderate-main"
|
||||
/>
|
||||
</MainLayout>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`renders correctly with counts 1`] = `
|
||||
<div
|
||||
data-testid="moderate-container"
|
||||
>
|
||||
<withPropsOnChange(SubBar)
|
||||
data-testid="moderate-subBar-container"
|
||||
>
|
||||
<Navigation
|
||||
pendingCount={0}
|
||||
reportedCount={4}
|
||||
unmoderatedCount={3}
|
||||
<Relay(ModerateNavigationContainer)
|
||||
moderationQueues={Object {}}
|
||||
story={Object {}}
|
||||
/>
|
||||
</withPropsOnChange(SubBar)>
|
||||
<div
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Match, Router, withRouter } from "found";
|
||||
import React from "react";
|
||||
import { graphql } from "react-relay";
|
||||
|
||||
@@ -16,12 +17,14 @@ import {
|
||||
|
||||
import ModerateCard from "../components/ModerateCard";
|
||||
|
||||
interface ModerateCardContainerProps {
|
||||
interface Props {
|
||||
comment: CommentData;
|
||||
settings: SettingsData;
|
||||
acceptComment: MutationProp<typeof AcceptCommentMutation>;
|
||||
rejectComment: MutationProp<typeof RejectCommentMutation>;
|
||||
danglingLogic: (status: COMMENT_STATUS) => boolean;
|
||||
match: Match;
|
||||
router: Router;
|
||||
}
|
||||
|
||||
function getStatus(comment: CommentData) {
|
||||
@@ -35,13 +38,12 @@ function getStatus(comment: CommentData) {
|
||||
}
|
||||
}
|
||||
|
||||
class ModerateCardContainer extends React.Component<
|
||||
ModerateCardContainerProps
|
||||
> {
|
||||
class ModerateCardContainer extends React.Component<Props> {
|
||||
private handleAccept = () => {
|
||||
this.props.acceptComment({
|
||||
commentID: this.props.comment.id,
|
||||
commentRevisionID: this.props.comment.revision.id,
|
||||
storyID: this.props.match.params.storyID,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -49,6 +51,7 @@ class ModerateCardContainer extends React.Component<
|
||||
this.props.rejectComment({
|
||||
commentID: this.props.comment.id,
|
||||
commentRevisionID: this.props.comment.revision.id,
|
||||
storyID: this.props.match.params.storyID,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -74,7 +77,7 @@ class ModerateCardContainer extends React.Component<
|
||||
}
|
||||
}
|
||||
|
||||
const enhanced = withFragmentContainer<ModerateCardContainerProps>({
|
||||
const enhanced = withFragmentContainer<Props>({
|
||||
comment: graphql`
|
||||
fragment ModerateCardContainer_comment on Comment {
|
||||
id
|
||||
@@ -105,8 +108,10 @@ const enhanced = withFragmentContainer<ModerateCardContainerProps>({
|
||||
}
|
||||
`,
|
||||
})(
|
||||
withMutation(AcceptCommentMutation)(
|
||||
withMutation(RejectCommentMutation)(ModerateCardContainer)
|
||||
withRouter(
|
||||
withMutation(AcceptCommentMutation)(
|
||||
withMutation(RejectCommentMutation)(ModerateCardContainer)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
@@ -1,32 +1,40 @@
|
||||
import { RouteProps } from "found";
|
||||
import { Match, RouteProps, Router, withRouter } from "found";
|
||||
import React from "react";
|
||||
import { graphql } from "react-relay";
|
||||
|
||||
import { ModerateContainerQueryResponse } from "talk-admin/__generated__/ModerateContainerQuery.graphql";
|
||||
import { withRouteConfig } from "talk-framework/lib/router";
|
||||
import { Spinner } from "talk-ui/components";
|
||||
|
||||
import Moderate from "../components/Moderate";
|
||||
|
||||
interface RouteParams {
|
||||
storyID?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
data: ModerateContainerQueryResponse;
|
||||
router: Router;
|
||||
match: Match & { params: RouteParams };
|
||||
}
|
||||
|
||||
class ModerateContainer extends React.Component<Props> {
|
||||
public static routeConfig: RouteProps;
|
||||
|
||||
public render() {
|
||||
const allStories = !this.props.match.params.storyID;
|
||||
if (!this.props.data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!this.props.data.moderationQueues) {
|
||||
return <Moderate />;
|
||||
return (
|
||||
<Moderate moderationQueues={null} story={null} allStories={allStories}>
|
||||
<Spinner />
|
||||
</Moderate>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Moderate
|
||||
unmoderatedCount={this.props.data.moderationQueues.unmoderated.count}
|
||||
reportedCount={this.props.data.moderationQueues.reported.count}
|
||||
pendingCount={this.props.data.moderationQueues.pending.count}
|
||||
moderationQueues={this.props.data.moderationQueues}
|
||||
story={this.props.data.story || null}
|
||||
allStories={allStories}
|
||||
>
|
||||
{this.props.children}
|
||||
</Moderate>
|
||||
@@ -36,21 +44,23 @@ class ModerateContainer extends React.Component<Props> {
|
||||
|
||||
const enhanced = withRouteConfig<ModerateContainerQueryResponse>({
|
||||
query: graphql`
|
||||
query ModerateContainerQuery {
|
||||
moderationQueues {
|
||||
unmoderated {
|
||||
count
|
||||
}
|
||||
reported {
|
||||
count
|
||||
}
|
||||
pending {
|
||||
count
|
||||
}
|
||||
query ModerateContainerQuery($storyID: ID, $includeStory: Boolean!) {
|
||||
story(id: $storyID) @include(if: $includeStory) {
|
||||
...ModerateNavigationContainer_story
|
||||
...ModerateSearchBarContainer_story
|
||||
}
|
||||
moderationQueues(storyID: $storyID) {
|
||||
...ModerateNavigationContainer_moderationQueues
|
||||
}
|
||||
}
|
||||
`,
|
||||
cacheConfig: { force: true },
|
||||
})(ModerateContainer);
|
||||
prepareVariables: (params, match) => {
|
||||
return {
|
||||
storyID: match.params.storyID,
|
||||
includeStory: Boolean(match.params.storyID),
|
||||
};
|
||||
},
|
||||
})(withRouter(ModerateContainer));
|
||||
|
||||
export default enhanced;
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import React from "react";
|
||||
import { graphql } from "react-relay";
|
||||
|
||||
import { ModerateNavigationContainer_moderationQueues as ModerationQueuesData } from "talk-admin/__generated__/ModerateNavigationContainer_moderationQueues.graphql";
|
||||
import { ModerateNavigationContainer_story as StoryData } from "talk-admin/__generated__/ModerateNavigationContainer_story.graphql";
|
||||
import { withFragmentContainer } from "talk-framework/lib/relay";
|
||||
|
||||
import Navigation from "../components/Navigation";
|
||||
|
||||
interface Props {
|
||||
moderationQueues: ModerationQueuesData | null;
|
||||
story: StoryData | null;
|
||||
}
|
||||
|
||||
const ModerateNavigationContainer: React.FunctionComponent<Props> = props => {
|
||||
if (!props.moderationQueues) {
|
||||
return <Navigation />;
|
||||
}
|
||||
return (
|
||||
<Navigation
|
||||
unmoderatedCount={props.moderationQueues.unmoderated.count}
|
||||
reportedCount={props.moderationQueues.reported.count}
|
||||
pendingCount={props.moderationQueues.pending.count}
|
||||
storyID={props.story && props.story.id}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const enhanced = withFragmentContainer<Props>({
|
||||
story: graphql`
|
||||
fragment ModerateNavigationContainer_story on Story {
|
||||
id
|
||||
}
|
||||
`,
|
||||
moderationQueues: graphql`
|
||||
fragment ModerateNavigationContainer_moderationQueues on ModerationQueues {
|
||||
unmoderated {
|
||||
count
|
||||
}
|
||||
reported {
|
||||
count
|
||||
}
|
||||
pending {
|
||||
count
|
||||
}
|
||||
}
|
||||
`,
|
||||
})(ModerateNavigationContainer);
|
||||
|
||||
export default enhanced;
|
||||
@@ -0,0 +1,264 @@
|
||||
import { Localized } from "fluent-react/compat";
|
||||
import { Match, Router, withRouter } from "found";
|
||||
import React, {
|
||||
KeyboardEvent,
|
||||
MouseEvent,
|
||||
useCallback,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { graphql } from "react-relay";
|
||||
|
||||
import { ModerateSearchBarContainer_story as ModerationQueuesData } from "talk-admin/__generated__/ModerateSearchBarContainer_story.graphql";
|
||||
import { SearchStoryFetch } from "talk-admin/fetches";
|
||||
import { useEffectWhenChanged } from "talk-framework/hooks";
|
||||
import { useFetch, withFragmentContainer } from "talk-framework/lib/relay";
|
||||
import { PropTypesOf } from "talk-framework/types";
|
||||
import { Spinner } from "talk-ui/components";
|
||||
import { blur } from "talk-ui/helpers";
|
||||
import {
|
||||
ListBoxOptionClickOrEnterHandler,
|
||||
ListBoxOptionElement,
|
||||
} from "talk-ui/hooks/useComboBox";
|
||||
|
||||
import * as Search from "../components/Search";
|
||||
import GoToAriaInfo from "../components/Search/GoToAriaInfo";
|
||||
|
||||
interface Props {
|
||||
router: Router;
|
||||
match: Match;
|
||||
story: ModerationQueuesData | null;
|
||||
allStories: boolean;
|
||||
}
|
||||
|
||||
type SearchBarOptions = PropTypesOf<typeof Search.Bar>["options"];
|
||||
|
||||
/**
|
||||
* useLinkNavHandler returns a handler that navigates to `href` prop and blurs
|
||||
* the TextField.
|
||||
* @param router Router from the _found_ library
|
||||
* @returns A handler for ListBoxOption
|
||||
*/
|
||||
function useLinkNavHandler(router: Router): ListBoxOptionClickOrEnterHandler {
|
||||
return useCallback(
|
||||
(evt: MouseEvent | KeyboardEvent, element: ListBoxOptionElement) => {
|
||||
if (element.props.href) {
|
||||
router.push(element.props.href);
|
||||
if (evt.preventDefault) {
|
||||
// We prevent default behavior because we handled navigation ourselves
|
||||
// and the browser don't need to follow anchor hrefs natively.
|
||||
evt.preventDefault();
|
||||
}
|
||||
// Blur will inactivate the textfield and close the popover/listbox.
|
||||
blur();
|
||||
}
|
||||
},
|
||||
[router]
|
||||
);
|
||||
}
|
||||
|
||||
function getContextOptionsWhenModeratingAll(
|
||||
onClickOrEnter: ListBoxOptionClickOrEnterHandler
|
||||
): SearchBarOptions {
|
||||
return [
|
||||
{
|
||||
element: (
|
||||
<Search.Option href="/admin/moderate">
|
||||
<GoToAriaInfo />
|
||||
<Localized id="moderate-searchBar-allStories">
|
||||
<span>All stories</span>
|
||||
</Localized>
|
||||
</Search.Option>
|
||||
),
|
||||
onClickOrEnter,
|
||||
group: "CONTEXT",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function getContextOptionsWhenModeratingStory(
|
||||
onClickOrEnter: ListBoxOptionClickOrEnterHandler,
|
||||
story: ModerationQueuesData | null
|
||||
): SearchBarOptions {
|
||||
if (story === null) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
element: (
|
||||
<Search.Option
|
||||
href={`/admin/moderate/${story.id}`}
|
||||
details={story.metadata && story.metadata.author}
|
||||
>
|
||||
<GoToAriaInfo /> {story.metadata && story.metadata.title}
|
||||
</Search.Option>
|
||||
),
|
||||
onClickOrEnter,
|
||||
group: "CONTEXT",
|
||||
},
|
||||
{
|
||||
element: <Search.ModerateAllOption href="/admin/moderate" />,
|
||||
onClickOrEnter,
|
||||
group: "CONTEXT",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
type OnSearchCallback = (search: string) => void;
|
||||
|
||||
/**
|
||||
* useSearchOptions
|
||||
* @param onClickOrEnter A handler that reacts to click or enter for the search options
|
||||
* @param story Current active story
|
||||
*/
|
||||
function useSearchOptions(
|
||||
onClickOrEnter: ListBoxOptionClickOrEnterHandler,
|
||||
story: ModerationQueuesData | null
|
||||
): [SearchBarOptions, OnSearchCallback] {
|
||||
const searchStory = useFetch(SearchStoryFetch);
|
||||
|
||||
const [searchOptions, setSearchOptions] = useState<SearchBarOptions>([]);
|
||||
|
||||
useEffectWhenChanged(() => {
|
||||
setSearchOptions([]);
|
||||
}, [story]);
|
||||
|
||||
const searchCountRef = useRef(0);
|
||||
|
||||
const onSearch = useCallback(
|
||||
async (search: string) => {
|
||||
const nextSearchOptions: SearchBarOptions = [];
|
||||
|
||||
const searchCount = ++searchCountRef.current;
|
||||
|
||||
setSearchOptions([
|
||||
{
|
||||
element: (
|
||||
<Search.Option>
|
||||
<Spinner size="xs" />
|
||||
</Search.Option>
|
||||
),
|
||||
group: "SEARCH",
|
||||
},
|
||||
]);
|
||||
|
||||
const stories = await searchStory({ query: search, limit: 5 });
|
||||
if (searchCount !== searchCountRef.current) {
|
||||
// This result is old, so we can discard it.
|
||||
return;
|
||||
}
|
||||
|
||||
if (stories.edges.length > 0) {
|
||||
stories.edges.forEach(e => {
|
||||
// Don't show current story in search results.
|
||||
if (story && story.id === e.node.id) {
|
||||
return;
|
||||
}
|
||||
nextSearchOptions.push({
|
||||
element: (
|
||||
<Search.Option
|
||||
href={`/admin/moderate/${e.node.id}`}
|
||||
details={e.node.metadata && e.node.metadata.author}
|
||||
>
|
||||
<GoToAriaInfo /> {e.node.metadata && e.node.metadata.title}
|
||||
</Search.Option>
|
||||
),
|
||||
onClickOrEnter,
|
||||
group: "SEARCH",
|
||||
});
|
||||
});
|
||||
} else {
|
||||
nextSearchOptions.push({
|
||||
element: (
|
||||
<Search.Option>
|
||||
<Localized id="moderate-searchBar-noResults">
|
||||
<span>No results</span>
|
||||
</Localized>
|
||||
</Search.Option>
|
||||
),
|
||||
group: "SEARCH",
|
||||
});
|
||||
}
|
||||
if (stories.pageInfo.hasNextPage) {
|
||||
nextSearchOptions.push({
|
||||
element: (
|
||||
<Search.SeeAllOption
|
||||
href={`/admin/stories?q=${encodeURIComponent(search)}`}
|
||||
/>
|
||||
),
|
||||
onClickOrEnter,
|
||||
group: "SEARCH",
|
||||
});
|
||||
}
|
||||
setSearchOptions(nextSearchOptions);
|
||||
},
|
||||
[story, searchStory, setSearchOptions]
|
||||
);
|
||||
|
||||
return [searchOptions, onSearch];
|
||||
}
|
||||
|
||||
const ModerateSearchBarContainer: React.FunctionComponent<Props> = props => {
|
||||
const linkNavHandler = useLinkNavHandler(props.router);
|
||||
const contextOptions: PropTypesOf<
|
||||
typeof Search.Bar
|
||||
>["options"] = props.allStories
|
||||
? getContextOptionsWhenModeratingAll(linkNavHandler)
|
||||
: getContextOptionsWhenModeratingStory(linkNavHandler, props.story);
|
||||
|
||||
const [searchOptions, onSearch] = useSearchOptions(
|
||||
linkNavHandler,
|
||||
props.story
|
||||
);
|
||||
|
||||
const options = [...contextOptions, ...searchOptions];
|
||||
|
||||
const childProps = {
|
||||
options,
|
||||
onSearch,
|
||||
};
|
||||
|
||||
// Still loading the story..
|
||||
if (props.allStories) {
|
||||
return (
|
||||
<Localized id="moderate-searchBar-allStories" attrs={{ title: true }}>
|
||||
<Search.Bar title="All stories" {...childProps} />
|
||||
</Localized>
|
||||
);
|
||||
}
|
||||
if (!props.story) {
|
||||
return <Search.Bar title={""} {...childProps} />;
|
||||
}
|
||||
const t = props.story!.metadata && props.story!.metadata.title;
|
||||
if (t) {
|
||||
return <Search.Bar title={t} {...childProps} />;
|
||||
}
|
||||
return (
|
||||
<Localized
|
||||
id="moderate-searchBar-titleNotAvailable"
|
||||
attrs={{ title: true }}
|
||||
>
|
||||
<Search.Bar
|
||||
title={"Title not available"}
|
||||
options={options}
|
||||
onSearch={onSearch}
|
||||
/>
|
||||
</Localized>
|
||||
);
|
||||
};
|
||||
|
||||
const enhanced = withRouter(
|
||||
withFragmentContainer<Props>({
|
||||
story: graphql`
|
||||
fragment ModerateSearchBarContainer_story on Story {
|
||||
id
|
||||
metadata {
|
||||
title
|
||||
author
|
||||
}
|
||||
}
|
||||
`,
|
||||
})(ModerateSearchBarContainer)
|
||||
);
|
||||
|
||||
export default enhanced;
|
||||
@@ -113,6 +113,7 @@ const createQueueContainer = (
|
||||
},
|
||||
getVariables(props, { count, cursor }, fragmentVariables) {
|
||||
return {
|
||||
...fragmentVariables,
|
||||
count,
|
||||
cursor,
|
||||
};
|
||||
@@ -141,8 +142,8 @@ const createQueueContainer = (
|
||||
|
||||
export const PendingQueueContainer = createQueueContainer(
|
||||
graphql`
|
||||
query QueueContainerPendingQuery {
|
||||
moderationQueues {
|
||||
query QueueContainerPendingQuery($storyID: ID) {
|
||||
moderationQueues(storyID: $storyID) {
|
||||
pending {
|
||||
...QueueContainer_queue
|
||||
}
|
||||
@@ -155,8 +156,12 @@ export const PendingQueueContainer = createQueueContainer(
|
||||
graphql`
|
||||
# Pagination query to be fetched upon calling 'loadMore'.
|
||||
# Notice that we re-use our fragment, and the shape of this query matches our fragment spec.
|
||||
query QueueContainerPaginationPendingQuery($count: Int!, $cursor: Cursor) {
|
||||
moderationQueues {
|
||||
query QueueContainerPaginationPendingQuery(
|
||||
$storyID: ID
|
||||
$count: Int!
|
||||
$cursor: Cursor
|
||||
) {
|
||||
moderationQueues(storyID: $storyID) {
|
||||
pending {
|
||||
...QueueContainer_queue @arguments(count: $count, cursor: $cursor)
|
||||
}
|
||||
@@ -167,8 +172,8 @@ export const PendingQueueContainer = createQueueContainer(
|
||||
|
||||
export const ReportedQueueContainer = createQueueContainer(
|
||||
graphql`
|
||||
query QueueContainerReportedQuery {
|
||||
moderationQueues {
|
||||
query QueueContainerReportedQuery($storyID: ID) {
|
||||
moderationQueues(storyID: $storyID) {
|
||||
reported {
|
||||
...QueueContainer_queue
|
||||
}
|
||||
@@ -181,8 +186,12 @@ export const ReportedQueueContainer = createQueueContainer(
|
||||
graphql`
|
||||
# Pagination query to be fetched upon calling 'loadMore'.
|
||||
# Notice that we re-use our fragment, and the shape of this query matches our fragment spec.
|
||||
query QueueContainerPaginationReportedQuery($count: Int!, $cursor: Cursor) {
|
||||
moderationQueues {
|
||||
query QueueContainerPaginationReportedQuery(
|
||||
$storyID: ID
|
||||
$count: Int!
|
||||
$cursor: Cursor
|
||||
) {
|
||||
moderationQueues(storyID: $storyID) {
|
||||
reported {
|
||||
...QueueContainer_queue @arguments(count: $count, cursor: $cursor)
|
||||
}
|
||||
@@ -193,8 +202,8 @@ export const ReportedQueueContainer = createQueueContainer(
|
||||
|
||||
export const UnmoderatedQueueContainer = createQueueContainer(
|
||||
graphql`
|
||||
query QueueContainerUnmoderatedQuery {
|
||||
moderationQueues {
|
||||
query QueueContainerUnmoderatedQuery($storyID: ID) {
|
||||
moderationQueues(storyID: $storyID) {
|
||||
unmoderated {
|
||||
...QueueContainer_queue
|
||||
}
|
||||
@@ -208,10 +217,11 @@ export const UnmoderatedQueueContainer = createQueueContainer(
|
||||
# Pagination query to be fetched upon calling 'loadMore'.
|
||||
# Notice that we re-use our fragment, and the shape of this query matches our fragment spec.
|
||||
query QueueContainerPaginationUnmoderatedQuery(
|
||||
$storyID: ID
|
||||
$count: Int!
|
||||
$cursor: Cursor
|
||||
) {
|
||||
moderationQueues {
|
||||
moderationQueues(storyID: $storyID) {
|
||||
unmoderated {
|
||||
...QueueContainer_queue @arguments(count: $count, cursor: $cursor)
|
||||
}
|
||||
|
||||
@@ -75,9 +75,14 @@ const enhanced = (withPaginationContainer<
|
||||
@argumentDefinitions(
|
||||
count: { type: "Int!", defaultValue: 5 }
|
||||
cursor: { type: "Cursor" }
|
||||
storyID: { type: "ID" }
|
||||
) {
|
||||
comments(status: REJECTED, first: $count, after: $cursor)
|
||||
@connection(key: "RejectedQueue_comments") {
|
||||
comments(
|
||||
status: REJECTED
|
||||
storyID: $storyID
|
||||
first: $count
|
||||
after: $cursor
|
||||
) @connection(key: "RejectedQueue_comments") {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
@@ -105,6 +110,7 @@ const enhanced = (withPaginationContainer<
|
||||
},
|
||||
getVariables(props, { count, cursor }, fragmentVariables) {
|
||||
return {
|
||||
...fragmentVariables,
|
||||
count,
|
||||
cursor,
|
||||
};
|
||||
@@ -113,11 +119,12 @@ const enhanced = (withPaginationContainer<
|
||||
# Pagination query to be fetched upon calling 'loadMore'.
|
||||
# Notice that we re-use our fragment, and the shape of this query matches our fragment spec.
|
||||
query RejectedQueueContainerPaginationQuery(
|
||||
$storyID: ID
|
||||
$count: Int!
|
||||
$cursor: Cursor
|
||||
) {
|
||||
...RejectedQueueContainer_query
|
||||
@arguments(count: $count, cursor: $cursor)
|
||||
@arguments(storyID: $storyID, count: $count, cursor: $cursor)
|
||||
}
|
||||
`,
|
||||
}
|
||||
@@ -126,8 +133,8 @@ const enhanced = (withPaginationContainer<
|
||||
enhanced.routeConfig = {
|
||||
Component: enhanced,
|
||||
query: graphql`
|
||||
query RejectedQueueContainerQuery {
|
||||
...RejectedQueueContainer_query
|
||||
query RejectedQueueContainerQuery($storyID: ID) {
|
||||
...RejectedQueueContainer_query @arguments(storyID: $storyID)
|
||||
}
|
||||
`,
|
||||
cacheConfig: { force: true },
|
||||
|
||||
@@ -9,11 +9,15 @@ import styles from "./Stories.css";
|
||||
|
||||
interface Props {
|
||||
query: PropTypesOf<typeof StoryTableContainer>["query"];
|
||||
initialSearchFilter?: string;
|
||||
}
|
||||
|
||||
const Stories: StatelessComponent<Props> = props => (
|
||||
<MainLayout className={styles.root} data-testid="stories-container">
|
||||
<StoryTableContainer query={props.query} />
|
||||
<StoryTableContainer
|
||||
query={props.query}
|
||||
initialSearchFilter={props.initialSearchFilter}
|
||||
/>
|
||||
</MainLayout>
|
||||
);
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Link } from "found";
|
||||
import React, { StatelessComponent } from "react";
|
||||
|
||||
import NotAvailable from "talk-admin/components/NotAvailable";
|
||||
import { getModerationLink } from "talk-admin/helpers";
|
||||
import { PropTypesOf } from "talk-framework/types";
|
||||
import { TableCell, TableRow } from "talk-ui/components";
|
||||
import { TableCell, TableRow, TextLink } from "talk-ui/components";
|
||||
|
||||
import StatusChangeContainer from "../containers/StatusChangeContainer";
|
||||
import StatusText from "./StatusText";
|
||||
@@ -21,7 +23,12 @@ interface Props {
|
||||
const UserRow: StatelessComponent<Props> = props => (
|
||||
<TableRow>
|
||||
<TableCell className={styles.titleColumn}>
|
||||
{props.title || <NotAvailable />}
|
||||
<Link
|
||||
to={getModerationLink("default", props.storyID)}
|
||||
Component={TextLink}
|
||||
>
|
||||
{props.title || <NotAvailable />}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className={styles.authorColumn}>
|
||||
{props.author || <NotAvailable />}
|
||||
|
||||
@@ -10,3 +10,6 @@
|
||||
.statusColumn {
|
||||
width: 15%;
|
||||
}
|
||||
.clickToModerate {
|
||||
font-size: calc(12rem / var(--rem-base));
|
||||
}
|
||||
|
||||
@@ -38,9 +38,19 @@ const StoryTable: StatelessComponent<Props> = props => (
|
||||
<Table fullWidth>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<Localized id="stories-column-title">
|
||||
<TableCell className={styles.titleColumn}>Title</TableCell>
|
||||
</Localized>
|
||||
<TableCell className={styles.titleColumn}>
|
||||
<Localized id="stories-column-title">
|
||||
<span>Title</span>
|
||||
</Localized>{" "}
|
||||
<span className={styles.clickToModerate}>
|
||||
(
|
||||
<Localized id="stories-column-clickToModerate">
|
||||
<span>Click title to moderate story</span>
|
||||
</Localized>
|
||||
)
|
||||
</span>
|
||||
</TableCell>
|
||||
|
||||
<Localized id="stories-column-author">
|
||||
<TableCell className={styles.authorColumn}>Author</TableCell>
|
||||
</Localized>
|
||||
|
||||
@@ -36,6 +36,7 @@ const StoryTableFilter: StatelessComponent<Props> = props => (
|
||||
</Typography>
|
||||
</Localized>
|
||||
<Form
|
||||
initialValues={{ search: props.searchFilter }}
|
||||
onSubmit={({ search }: { search: string }) =>
|
||||
props.onSetSearchFilter(search)
|
||||
}
|
||||
|
||||
@@ -10,19 +10,33 @@ import Stories from "../components/Stories";
|
||||
interface Props {
|
||||
data: StoriesContainerQueryResponse | null;
|
||||
form: FormApi;
|
||||
initialSearchFilter?: string;
|
||||
}
|
||||
|
||||
const StoriesContainer: StatelessComponent<Props> = props => {
|
||||
return <Stories query={props.data} />;
|
||||
return (
|
||||
<Stories
|
||||
query={props.data}
|
||||
initialSearchFilter={props.initialSearchFilter}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const enhanced = withRouteConfig({
|
||||
query: graphql`
|
||||
query StoriesContainerQuery {
|
||||
...StoryTableContainer_query
|
||||
query StoriesContainerQuery($searchFilter: String) {
|
||||
...StoryTableContainer_query @arguments(searchFilter: $searchFilter)
|
||||
}
|
||||
`,
|
||||
cacheConfig: { force: true },
|
||||
prepareVariables: (params, match) => {
|
||||
return {
|
||||
searchFilter: match.location.query.q,
|
||||
};
|
||||
},
|
||||
render: ({ match, Component, ...rest }) => (
|
||||
<Component initialSearchFilter={match.location.query.q} {...rest} />
|
||||
),
|
||||
})(StoriesContainer);
|
||||
|
||||
export default enhanced;
|
||||
|
||||
@@ -16,6 +16,7 @@ import StoryTable from "../components/StoryTable";
|
||||
import StoryTableFilter from "../components/StoryTableFilter";
|
||||
|
||||
interface Props {
|
||||
initialSearchFilter?: string;
|
||||
query: QueryData | null;
|
||||
relay: RelayPaginationProp;
|
||||
}
|
||||
@@ -26,7 +27,9 @@ const StoryTableContainer: StatelessComponent<Props> = props => {
|
||||
: [];
|
||||
|
||||
const [loadMore, isLoadingMore] = useLoadMore(props.relay, 10);
|
||||
const [searchFilter, setSearchFilter] = useState<string>("");
|
||||
const [searchFilter, setSearchFilter] = useState<string>(
|
||||
props.initialSearchFilter || ""
|
||||
);
|
||||
const [statusFilter, setStatusFilter] = useState<GQLSTORY_STATUS_RL | null>(
|
||||
null
|
||||
);
|
||||
|
||||
@@ -45,8 +45,18 @@ async function createTestRenderer(
|
||||
Query: {
|
||||
settings: () => settings,
|
||||
viewer: () => viewer,
|
||||
moderationQueues: () => emptyModerationQueues,
|
||||
comments: () => emptyRejectedComments,
|
||||
moderationQueues: ({ variables }) => {
|
||||
expectAndFail(variables).toEqual({
|
||||
storyID: null,
|
||||
});
|
||||
return emptyModerationQueues;
|
||||
},
|
||||
comments: ({ variables }) => {
|
||||
expectAndFail(variables).toEqual({
|
||||
storyID: null,
|
||||
});
|
||||
return emptyRejectedComments;
|
||||
},
|
||||
},
|
||||
}),
|
||||
params.resolvers
|
||||
@@ -382,6 +392,7 @@ describe("rejected queue", () => {
|
||||
expectAndFail(variables).toEqual({
|
||||
first: 5,
|
||||
status: "REJECTED",
|
||||
storyID: null,
|
||||
});
|
||||
return {
|
||||
edges: [
|
||||
@@ -418,6 +429,7 @@ describe("rejected queue", () => {
|
||||
expectAndFail(variables).toEqual({
|
||||
first: 5,
|
||||
status: GQLCOMMENT_STATUS.REJECTED,
|
||||
storyID: null,
|
||||
});
|
||||
return {
|
||||
edges: [
|
||||
@@ -440,6 +452,7 @@ describe("rejected queue", () => {
|
||||
first: 10,
|
||||
after: rejectedComments[1].createdAt,
|
||||
status: GQLCOMMENT_STATUS.REJECTED,
|
||||
storyID: null,
|
||||
});
|
||||
return {
|
||||
edges: [
|
||||
@@ -519,6 +532,7 @@ describe("rejected queue", () => {
|
||||
expectAndFail(variables).toEqual({
|
||||
first: 5,
|
||||
status: "REJECTED",
|
||||
storyID: null,
|
||||
});
|
||||
return {
|
||||
edges: [
|
||||
|
||||
@@ -124,7 +124,19 @@ exports[`renders empty stories 1`] = `
|
||||
<th
|
||||
className="TableCell-root StoryTable-titleColumn TableCell-header"
|
||||
>
|
||||
Title
|
||||
<span>
|
||||
Title
|
||||
</span>
|
||||
|
||||
<span
|
||||
className="StoryTable-clickToModerate"
|
||||
>
|
||||
(
|
||||
<span>
|
||||
Click title to moderate story
|
||||
</span>
|
||||
)
|
||||
</span>
|
||||
</th>
|
||||
<th
|
||||
className="TableCell-root StoryTable-authorColumn TableCell-header"
|
||||
@@ -152,7 +164,13 @@ exports[`renders empty stories 1`] = `
|
||||
<td
|
||||
className="TableCell-root StoryRow-titleColumn TableCell-body"
|
||||
>
|
||||
Finally a Cure for Cancer
|
||||
<a
|
||||
className="TextLink-root"
|
||||
href="/admin/moderate/story-1"
|
||||
onClick={[Function]}
|
||||
>
|
||||
Finally a Cure for Cancer
|
||||
</a>
|
||||
</td>
|
||||
<td
|
||||
className="TableCell-root StoryRow-authorColumn TableCell-body"
|
||||
@@ -215,7 +233,13 @@ exports[`renders empty stories 1`] = `
|
||||
<td
|
||||
className="TableCell-root StoryRow-titleColumn TableCell-body"
|
||||
>
|
||||
First Colony on Mars
|
||||
<a
|
||||
className="TextLink-root"
|
||||
href="/admin/moderate/story-2"
|
||||
onClick={[Function]}
|
||||
>
|
||||
First Colony on Mars
|
||||
</a>
|
||||
</td>
|
||||
<td
|
||||
className="TableCell-root StoryRow-authorColumn TableCell-body"
|
||||
@@ -403,7 +427,19 @@ exports[`renders stories 1`] = `
|
||||
<th
|
||||
className="TableCell-root StoryTable-titleColumn TableCell-header"
|
||||
>
|
||||
Title
|
||||
<span>
|
||||
Title
|
||||
</span>
|
||||
|
||||
<span
|
||||
className="StoryTable-clickToModerate"
|
||||
>
|
||||
(
|
||||
<span>
|
||||
Click title to moderate story
|
||||
</span>
|
||||
)
|
||||
</span>
|
||||
</th>
|
||||
<th
|
||||
className="TableCell-root StoryTable-authorColumn TableCell-header"
|
||||
@@ -431,7 +467,13 @@ exports[`renders stories 1`] = `
|
||||
<td
|
||||
className="TableCell-root StoryRow-titleColumn TableCell-body"
|
||||
>
|
||||
Finally a Cure for Cancer
|
||||
<a
|
||||
className="TextLink-root"
|
||||
href="/admin/moderate/story-1"
|
||||
onClick={[Function]}
|
||||
>
|
||||
Finally a Cure for Cancer
|
||||
</a>
|
||||
</td>
|
||||
<td
|
||||
className="TableCell-root StoryRow-authorColumn TableCell-body"
|
||||
@@ -494,7 +536,13 @@ exports[`renders stories 1`] = `
|
||||
<td
|
||||
className="TableCell-root StoryRow-titleColumn TableCell-body"
|
||||
>
|
||||
First Colony on Mars
|
||||
<a
|
||||
className="TextLink-root"
|
||||
href="/admin/moderate/story-2"
|
||||
onClick={[Function]}
|
||||
>
|
||||
First Colony on Mars
|
||||
</a>
|
||||
</td>
|
||||
<td
|
||||
className="TableCell-root StoryRow-authorColumn TableCell-body"
|
||||
|
||||
Reference in New Issue
Block a user