[CORL-1090] Sections Alpha (#2973)

* feat: added section filtering support

* fix: addressed issues around filtering sections

* fix: fixed issue with section filter
This commit is contained in:
Wyatt Johnson
2020-06-04 13:32:04 +02:00
committed by GitHub
parent 92c72f7041
commit ba08447d5e
54 changed files with 944 additions and 135 deletions
@@ -2,6 +2,7 @@ import { graphql } from "react-relay";
import { ConnectionHandler, Environment } from "relay-runtime";
import { getQueueConnection } from "coral-admin/helpers";
import { SectionFilter } from "coral-common/section";
import { CoralContext } from "coral-framework/lib/bootstrap";
import {
commitMutationPromiseNormalized,
@@ -18,7 +19,11 @@ const FeatureCommentMutation = createMutation(
"featureComment",
(
environment: Environment,
input: MutationInput<FeatureCommentMutation> & { storyID: string },
input: MutationInput<FeatureCommentMutation> & {
storyID?: string | null;
siteID?: string | null;
section?: SectionFilter | null;
},
{ uuidGenerator }: CoralContext
) =>
commitMutationPromiseNormalized<FeatureCommentMutation>(environment, {
@@ -69,10 +74,34 @@ const FeatureCommentMutation = createMutation(
},
updater: (store) => {
const connections = [
getQueueConnection(store, "PENDING", input.storyID),
getQueueConnection(store, "REPORTED", input.storyID),
getQueueConnection(store, "UNMODERATED", input.storyID),
getQueueConnection(store, "REJECTED", input.storyID),
getQueueConnection(
store,
"PENDING",
input.storyID,
input.siteID,
input.section
),
getQueueConnection(
store,
"REPORTED",
input.storyID,
input.siteID,
input.section
),
getQueueConnection(
store,
"UNMODERATED",
input.storyID,
input.siteID,
input.section
),
getQueueConnection(
store,
"REJECTED",
input.storyID,
input.siteID,
input.section
),
].filter((c) => c);
connections.forEach((con) =>
ConnectionHandler.deleteNode(con!, input.commentID)
@@ -10,6 +10,7 @@ import {
} from "coral-admin/mutations";
import FadeInTransition from "coral-framework/components/FadeInTransition";
import { getModerationLink } from "coral-framework/helpers";
import parseModerationOptions from "coral-framework/helpers/parseModerationOptions";
import {
MutationProp,
withFragmentContainer,
@@ -95,10 +96,14 @@ const ModerateCardContainer: FunctionComponent<Props> = ({
return;
}
const { storyID, siteID, section } = parseModerationOptions(match);
await approveComment({
commentID: comment.id,
commentRevisionID: comment.revision.id,
storyID: match.params.storyID,
storyID,
siteID,
section,
});
if (loadNext) {
loadNext();
@@ -110,10 +115,14 @@ const ModerateCardContainer: FunctionComponent<Props> = ({
return;
}
const { storyID, siteID, section } = parseModerationOptions(match);
await rejectComment({
commentID: comment.id,
commentRevisionID: comment.revision.id,
storyID: match.params.storyID,
storyID,
siteID,
section,
});
if (loadNext) {
loadNext();
@@ -125,10 +134,14 @@ const ModerateCardContainer: FunctionComponent<Props> = ({
return;
}
const { storyID, siteID, section } = parseModerationOptions(match);
featureComment({
commentID: comment.id,
commentRevisionID: comment.revision.id,
storyID: match.params.storyID,
storyID,
siteID,
section,
});
}, [featureComment, comment, match]);
@@ -1,4 +1,5 @@
import cn from "classnames";
import { noop } from "lodash";
import React, { FunctionComponent } from "react";
import AutoLoadMore from "coral-admin/components/AutoLoadMore";
@@ -16,20 +17,20 @@ import {
import styles from "./PaginatedSelect.css";
interface Props {
onLoadMore: () => void;
onLoadMore?: () => void;
icon?: string;
hasMore: boolean;
disableLoadMore: boolean;
loading: boolean;
hasMore?: boolean;
disableLoadMore?: boolean;
loading?: boolean;
selected: React.ReactNode;
className?: string;
}
const PaginatedSelect: FunctionComponent<Props> = ({
loading,
onLoadMore,
disableLoadMore,
hasMore,
onLoadMore = noop,
disableLoadMore = false,
hasMore = false,
loading = false,
children,
icon,
selected,
@@ -5,6 +5,7 @@ import {
RecordSourceSelectorProxy,
} from "relay-runtime";
import { SectionFilter } from "coral-common/section";
import {
GQLCOMMENT_STATUS,
GQLMODERATION_QUEUE_RL,
@@ -14,7 +15,8 @@ export default function getQueueConnection(
store: RecordSourceSelectorProxy | RecordSourceProxy,
queue: GQLMODERATION_QUEUE_RL | "REJECTED" | "APPROVED",
storyID?: string | null,
siteID?: string | null
siteID?: string | null,
section?: SectionFilter | null
): RecordProxy | null | undefined {
const root = store.getRoot();
if (queue === "REJECTED") {
@@ -22,6 +24,7 @@ export default function getQueueConnection(
status: GQLCOMMENT_STATUS.REJECTED,
storyID,
siteID,
section,
});
}
if (queue === "APPROVED") {
@@ -29,11 +32,13 @@ export default function getQueueConnection(
status: GQLCOMMENT_STATUS.APPROVED,
storyID,
siteID,
section,
});
}
const queuesRecord = root.getLinkedRecord("moderationQueues", {
storyID,
siteID,
section,
})!;
if (!queuesRecord) {
return undefined;
@@ -2,6 +2,7 @@ import { graphql } from "react-relay";
import { ConnectionHandler, Environment } from "relay-runtime";
import { getQueueConnection } from "coral-admin/helpers";
import { SectionFilter } from "coral-common/section";
import {
commitMutationPromiseNormalized,
createMutation,
@@ -16,13 +17,19 @@ const ApproveCommentMutation = createMutation(
"approveComment",
(
environment: Environment,
input: MutationInput<MutationTypes> & { storyID?: string }
input: MutationInput<MutationTypes> & {
storyID?: string | null;
siteID?: string | null;
section?: SectionFilter | null;
}
) =>
commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation: graphql`
mutation ApproveCommentMutation(
$input: ApproveCommentInput!
$storyID: ID
$siteID: ID
$section: SectionFilter
) {
approveComment(input: $input) {
comment {
@@ -42,7 +49,11 @@ const ApproveCommentMutation = createMutation(
}
...ModeratedByContainer_comment
}
moderationQueues(storyID: $storyID) {
moderationQueues(
storyID: $storyID
siteID: $siteID
section: $section
) {
unmoderated {
count
}
@@ -64,6 +75,8 @@ const ApproveCommentMutation = createMutation(
clientMutationId: (clientMutationId++).toString(),
},
storyID: input.storyID,
siteID: input.siteID,
section: input.section,
},
optimisticUpdater: (store) => {
const proxy = store.get(input.commentID)!;
@@ -72,10 +85,34 @@ const ApproveCommentMutation = createMutation(
},
updater: (store) => {
const connections = [
getQueueConnection(store, "REPORTED", input.storyID),
getQueueConnection(store, "PENDING", input.storyID),
getQueueConnection(store, "UNMODERATED", input.storyID),
getQueueConnection(store, "REJECTED", input.storyID),
getQueueConnection(
store,
"REPORTED",
input.storyID,
input.siteID,
input.section
),
getQueueConnection(
store,
"PENDING",
input.storyID,
input.siteID,
input.section
),
getQueueConnection(
store,
"UNMODERATED",
input.storyID,
input.siteID,
input.section
),
getQueueConnection(
store,
"REJECTED",
input.storyID,
input.siteID,
input.section
),
].filter((c) => c);
connections.forEach((con) =>
ConnectionHandler.deleteNode(con!, input.commentID)
@@ -2,6 +2,7 @@ import { graphql } from "react-relay";
import { ConnectionHandler, Environment } from "relay-runtime";
import { getQueueConnection } from "coral-admin/helpers";
import { SectionFilter } from "coral-common/section";
import {
commitMutationPromiseNormalized,
createMutation,
@@ -16,13 +17,19 @@ const RejectCommentMutation = createMutation(
"rejectComment",
(
environment: Environment,
input: MutationInput<MutationTypes> & { storyID?: string }
input: MutationInput<MutationTypes> & {
storyID?: string | null;
siteID?: string | null;
section?: SectionFilter | null;
}
) =>
commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation: graphql`
mutation RejectCommentMutation(
$input: RejectCommentInput!
$storyID: ID
$siteID: ID
$section: SectionFilter
) {
rejectComment(input: $input) {
comment {
@@ -42,7 +49,11 @@ const RejectCommentMutation = createMutation(
}
...ModeratedByContainer_comment
}
moderationQueues(storyID: $storyID) {
moderationQueues(
storyID: $storyID
siteID: $siteID
section: $section
) {
unmoderated {
count
}
@@ -64,6 +75,8 @@ const RejectCommentMutation = createMutation(
clientMutationId: (clientMutationId++).toString(),
},
storyID: input.storyID,
siteID: input.siteID,
section: input.section,
},
optimisticUpdater: (store) => {
const proxy = store.get(input.commentID)!;
@@ -39,7 +39,9 @@ class OIDCConfigContainer extends React.Component<Props, State> {
}
this.setState({ awaitingResponse: true });
try {
const config = await this.props.discoverOIDCConfiguration({
const {
discoverOIDCConfiguration: config,
} = await this.props.discoverOIDCConfiguration({
issuer,
});
if (config) {
@@ -8,12 +8,14 @@ import React, {
import MainLayout from "coral-admin/components/MainLayout";
import { HOTKEYS } from "coral-admin/constants";
import { SectionFilter } from "coral-common/section";
import { PropTypesOf } from "coral-framework/types";
import { SubBar } from "coral-ui/components/v2/SubBar";
import HotkeysModal from "./HotkeysModal";
import ModerateNavigationContainer from "./ModerateNavigation";
import ModerateSearchBarContainer from "./ModerateSearchBar";
import { SectionSelectorContainer } from "./SectionSelector";
import { SiteSelectorContainer } from "./SiteSelector";
import styles from "./Moderate.css";
@@ -26,12 +28,14 @@ interface RouteParams {
interface Props {
story: PropTypesOf<typeof ModerateNavigationContainer>["story"] &
PropTypesOf<typeof ModerateSearchBarContainer>["story"];
query: PropTypesOf<typeof SiteSelectorContainer>["query"];
query: PropTypesOf<typeof SiteSelectorContainer>["query"] &
PropTypesOf<typeof SectionSelectorContainer>["query"];
moderationQueues: PropTypesOf<
typeof ModerateNavigationContainer
>["moderationQueues"];
allStories: boolean;
siteID: string | null;
section?: SectionFilter | null;
settings: PropTypesOf<typeof ModerateSearchBarContainer>["settings"] | null;
children?: React.ReactNode;
queueName: string;
@@ -48,6 +52,7 @@ const Moderate: FunctionComponent<Props> = ({
routeParams,
settings,
siteID,
section,
}) => {
const [showHotkeysModal, setShowHotkeysModal] = useState(false);
const closeModal = useCallback(() => {
@@ -80,12 +85,20 @@ const Moderate: FunctionComponent<Props> = ({
siteID={routeParams.siteID || siteID || null}
/>
}
sectionSelector={
<SectionSelectorContainer
queueName={queueName}
query={query}
section={section}
/>
}
/>
<SubBar data-testid="moderate-tabBar-container">
<ModerateNavigationContainer
moderationQueues={moderationQueues}
story={story}
siteID={routeParams.siteID || siteID || null}
section={section}
/>
</SubBar>
<div className={styles.background} />
@@ -2,6 +2,7 @@ import { Match, RouteProps, Router, withRouter } from "found";
import React from "react";
import { graphql } from "react-relay";
import parseModerationOptions from "coral-framework/helpers/parseModerationOptions";
import { withRouteConfig } from "coral-framework/lib/router";
import { Spinner } from "coral-ui/components/v2";
@@ -36,6 +37,8 @@ class ModerateContainer extends React.Component<Props> {
].find((name) => {
return this.props.match.location.pathname.includes(name);
});
const { section } = parseModerationOptions(this.props.match);
if (!this.props.data) {
return (
<Moderate
@@ -43,6 +46,7 @@ class ModerateContainer extends React.Component<Props> {
story={null}
settings={null}
siteID={null}
section={section}
query={this.props.data}
routeParams={this.props.match.params}
queueName={queueName || "default"}
@@ -58,6 +62,7 @@ class ModerateContainer extends React.Component<Props> {
moderationQueues={this.props.data.moderationQueues}
story={this.props.data.story || null}
siteID={this.props.data.story ? this.props.data.story.site.id : null}
section={section}
routeParams={this.props.match.params}
query={this.props.data}
allStories={allStories}
@@ -76,6 +81,7 @@ const enhanced = withRouteConfig<Props>({
$storyID: ID
$includeStory: Boolean!
$siteID: ID
$section: SectionFilter
) {
settings {
...ModerateSearchBarContainer_settings
@@ -87,19 +93,23 @@ const enhanced = withRouteConfig<Props>({
id
}
}
moderationQueues(storyID: $storyID, siteID: $siteID) {
moderationQueues(storyID: $storyID, siteID: $siteID, section: $section) {
...ModerateNavigationContainer_moderationQueues
}
...SiteSelectorContainer_query
...SectionSelectorContainer_query
}
`,
cacheConfig: { force: true },
prepareVariables: (params, match) => {
const { storyID, siteID, section } = parseModerationOptions(match);
return {
storyID: match.params.storyID,
siteID: match.params.siteID,
includeStory: Boolean(match.params.storyID),
includeSite: Boolean(match.params.siteID),
storyID,
siteID,
section,
includeStory: Boolean(storyID),
includeSite: Boolean(siteID),
};
},
})(withRouter(ModerateContainer));
@@ -20,8 +20,11 @@ const ModerateCountsCommentEnteredSubscription = createSubscription(
) =>
requestSubscription(environment, {
subscription: graphql`
subscription ModerateCountsCommentEnteredSubscription($storyID: ID) {
commentEnteredModerationQueue(storyID: $storyID) {
subscription ModerateCountsCommentEnteredSubscription(
$storyID: ID
$siteID: ID
) {
commentEnteredModerationQueue(storyID: $storyID, siteID: $siteID) {
queue
}
}
@@ -20,11 +20,11 @@ const ModerateCountsCommentLeftSubscription = createSubscription(
) =>
requestSubscription(environment, {
subscription: graphql`
subscription ModerateCountsCommentLeftSubscription($storyID: ID) {
commentLeftModerationQueue(storyID: $storyID) {
queue
}
commentLeftModerationQueue(storyID: $storyID) {
subscription ModerateCountsCommentLeftSubscription(
$storyID: ID
$siteID: ID
) {
commentLeftModerationQueue(storyID: $storyID, siteID: $siteID) {
queue
}
}
@@ -1,6 +1,7 @@
import React, { useEffect } from "react";
import { graphql } from "react-relay";
import { SectionFilter } from "coral-common/section";
import {
combineDisposables,
useSubscription,
@@ -18,6 +19,7 @@ interface Props {
moderationQueues: ModerationQueuesData | null;
story: StoryData | null;
siteID: string | null;
section?: SectionFilter | null;
}
const ModerateNavigationContainer: React.FunctionComponent<Props> = (props) => {
@@ -29,7 +31,7 @@ const ModerateNavigationContainer: React.FunctionComponent<Props> = (props) => {
);
useEffect(() => {
if (!props.moderationQueues) {
if (!props.moderationQueues || props.section) {
return;
}
const vars = {
@@ -43,7 +45,12 @@ const ModerateNavigationContainer: React.FunctionComponent<Props> = (props) => {
return () => {
disposable.dispose();
};
}, [Boolean(props.moderationQueues), props.story, props.siteID]);
}, [
Boolean(props.moderationQueues),
props.story,
props.siteID,
props.section,
]);
if (!props.moderationQueues) {
return <Navigation />;
@@ -55,6 +62,7 @@ const ModerateNavigationContainer: React.FunctionComponent<Props> = (props) => {
pendingCount={props.moderationQueues.pending.count}
storyID={props.story && props.story.id}
siteID={props.siteID}
section={props.section}
/>
);
};
@@ -5,6 +5,7 @@ import { isNumber } from "lodash";
import React, { FunctionComponent, useEffect, useMemo } from "react";
import { HOTKEYS } from "coral-admin/constants";
import { SectionFilter } from "coral-common/section";
import { getModerationLink } from "coral-framework/helpers";
import { Counter, Icon, SubBarNavigation } from "coral-ui/components/v2";
@@ -16,6 +17,7 @@ interface Props {
pendingCount?: number | null;
storyID?: string | null;
siteID?: string | null;
section?: SectionFilter | null;
router: Router;
match: Match;
}
@@ -26,16 +28,17 @@ const Navigation: FunctionComponent<Props> = ({
pendingCount,
storyID,
siteID,
section,
router,
match,
}) => {
const moderationLinks = useMemo(() => {
return [
getModerationLink({ queue: "reported", storyID, siteID }),
getModerationLink({ queue: "pending", storyID, siteID }),
getModerationLink({ queue: "unmoderated", storyID, siteID }),
getModerationLink({ queue: "approved", storyID, siteID }),
getModerationLink({ queue: "rejected", storyID, siteID }),
getModerationLink({ queue: "reported", storyID, siteID, section }),
getModerationLink({ queue: "pending", storyID, siteID, section }),
getModerationLink({ queue: "unmoderated", storyID, siteID, section }),
getModerationLink({ queue: "approved", storyID, siteID, section }),
getModerationLink({ queue: "rejected", storyID, siteID, section }),
];
}, [storyID, siteID]);
@@ -36,6 +36,7 @@ interface Props {
onSearch?: (value: string) => void;
siteSelector: React.ReactNode;
sectionSelector?: React.ReactNode;
multisite: boolean;
}
@@ -48,6 +49,7 @@ const Bar: FunctionComponent<Props> = ({
options,
onSearch,
siteSelector,
sectionSelector,
multisite,
}) => {
const [focused, focusHandlers] = useFocus();
@@ -90,7 +92,7 @@ const Bar: FunctionComponent<Props> = ({
aria-expanded={focused}
>
<Backdrop className={styles.bumpZIndex} active={focused} />
{multisite ? siteSelector : null}
{multisite ? siteSelector : sectionSelector}
<Form onSubmit={submitHandler}>
{({ handleSubmit }) => (
<Localized
@@ -12,6 +12,7 @@ import { graphql } from "react-relay";
import { getModerationLink } from "coral-framework/helpers";
import { useEffectWhenChanged } from "coral-framework/hooks";
import { useFetch, withFragmentContainer } from "coral-framework/lib/relay";
import { GQLFEATURE_FLAG } from "coral-framework/schema";
import { PropTypesOf } from "coral-framework/types";
import { Flex, Spinner } from "coral-ui/components/v2";
import { blur } from "coral-ui/helpers";
@@ -22,11 +23,13 @@ import {
import { ModerateSearchBarContainer_settings as SettingsData } from "coral-admin/__generated__/ModerateSearchBarContainer_settings.graphql";
import { ModerateSearchBarContainer_story as ModerationQueuesData } from "coral-admin/__generated__/ModerateSearchBarContainer_story.graphql";
import { SearchStoryFetchQueryResponse } from "coral-admin/__generated__/SearchStoryFetchQuery.graphql";
import Bar from "./Bar";
import GoToAriaInfo from "./GoToAriaInfo";
import ModerateAllOption from "./ModerateAllOption";
import Option from "./Option";
import OptionDetail from "./OptionDetail";
import SearchStoryFetch from "./SearchStoryFetch";
import SeeAllOption from "./SeeAllOption";
@@ -37,6 +40,7 @@ interface Props {
settings: SettingsData | null;
allStories: boolean;
siteSelector: React.ReactNode;
sectionSelector?: React.ReactNode;
siteID: string | null;
}
@@ -67,6 +71,38 @@ function useLinkNavHandler(router: Router): ListBoxOptionClickOrEnterHandler {
);
}
function getStoryDetails(
{
multisite,
featureFlags,
}: SettingsData | SearchStoryFetchQueryResponse["settings"],
{
site,
metadata,
}:
| ModerationQueuesData
| SearchStoryFetchQueryResponse["stories"]["edges"][0]["node"]
) {
return (
<Flex itemGutter>
{/* If we're multisite, show the site name. */}
{multisite && <OptionDetail variant="bold">{site.name}</OptionDetail>}
{/* If the section is available, show it on the story result */}
{!multisite &&
featureFlags.includes(GQLFEATURE_FLAG.SECTIONS) &&
(metadata?.section ? (
<OptionDetail variant="bold">{metadata.section}</OptionDetail>
) : (
<Localized id="moderate-section-uncategorized">
<OptionDetail variant="muted">Uncategorized</OptionDetail>
</Localized>
))}
{/* If the author is available, show it on the story result */}
{metadata?.author && <OptionDetail>{metadata.author}</OptionDetail>}
</Flex>
);
}
function getContextOptionsWhenModeratingAll(
onClickOrEnter: ListBoxOptionClickOrEnterHandler
): SearchBarOptions {
@@ -88,9 +124,10 @@ function getContextOptionsWhenModeratingAll(
function getContextOptionsWhenModeratingStory(
onClickOrEnter: ListBoxOptionClickOrEnterHandler,
settings: SettingsData | null,
story: ModerationQueuesData | null
): SearchBarOptions {
if (story === null) {
if (story === null || settings === null) {
return [];
}
return [
@@ -98,7 +135,7 @@ function getContextOptionsWhenModeratingStory(
element: (
<Option
href={getModerationLink({ storyID: story.id })}
details={story.metadata && story.metadata.author}
details={getStoryDetails(settings, story)}
>
<GoToAriaInfo /> {story.metadata && story.metadata.title}
</Option>
@@ -115,6 +152,7 @@ function getContextOptionsWhenModeratingStory(
}
type OnSearchCallback = (search: string) => void;
interface SearchParams {
query: string;
limit: number;
@@ -165,7 +203,7 @@ function useSearchOptions(
if (siteID) {
searchParams.siteID = siteID;
}
const stories = await searchStory(searchParams);
const { settings, stories } = await searchStory(searchParams);
if (searchCount !== searchCountRef.current) {
// This result is old, so we can discard it.
return;
@@ -184,12 +222,7 @@ function useSearchOptions(
storyID: e.node.id,
siteID: e.node.site.id,
})}
details={
<Flex itemGutter>
<strong>{e.node.site.name}</strong>
{e.node.metadata && e.node.metadata.author}
</Flex>
}
details={getStoryDetails(settings, e.node)}
>
<GoToAriaInfo /> {e.node.metadata && e.node.metadata.title}
</Option>
@@ -237,7 +270,11 @@ const ModerateSearchBarContainer: React.FunctionComponent<Props> = (props) => {
const linkNavHandler = useLinkNavHandler(props.router);
const contextOptions: PropTypesOf<typeof Bar>["options"] = props.allStories
? getContextOptionsWhenModeratingAll(linkNavHandler)
: getContextOptionsWhenModeratingStory(linkNavHandler, props.story);
: getContextOptionsWhenModeratingStory(
linkNavHandler,
props.settings,
props.story
);
const [searchOptions, onSearch] = useSearchOptions(
linkNavHandler,
@@ -258,6 +295,7 @@ const ModerateSearchBarContainer: React.FunctionComponent<Props> = (props) => {
<Localized id="moderate-searchBar-allStories" attrs={{ title: true }}>
<Bar
siteSelector={props.siteSelector}
sectionSelector={props.sectionSelector}
multisite={props.settings ? props.settings.multisite : false}
title="All stories"
{...childProps}
@@ -270,22 +308,26 @@ const ModerateSearchBarContainer: React.FunctionComponent<Props> = (props) => {
<Bar
multisite={props.settings ? props.settings.multisite : false}
siteSelector={props.siteSelector}
sectionSelector={props.sectionSelector}
title={""}
{...childProps}
/>
);
}
const t = props.story.metadata && props.story.metadata.title;
if (t) {
const title = props.story.metadata && props.story.metadata.title;
if (title) {
return (
<Bar
multisite={props.settings ? props.settings.multisite : false}
siteSelector={props.siteSelector}
title={t}
sectionSelector={props.sectionSelector}
title={title}
{...childProps}
/>
);
}
return (
<Localized
id="moderate-searchBar-titleNotAvailable"
@@ -293,6 +335,7 @@ const ModerateSearchBarContainer: React.FunctionComponent<Props> = (props) => {
>
<Bar
siteSelector={props.siteSelector}
sectionSelector={props.sectionSelector}
multisite={props.settings ? props.settings.multisite : false}
title={"Title not available"}
options={options}
@@ -307,6 +350,7 @@ const enhanced = withRouter(
settings: graphql`
fragment ModerateSearchBarContainer_settings on Settings {
multisite
featureFlags
}
`,
story: graphql`
@@ -319,6 +363,7 @@ const enhanced = withRouter(
metadata {
title
author
section
}
}
`,
@@ -8,6 +8,7 @@
@mixin outline;
}
}
.container {
min-height: calc(4 * var(--mini-unit));
padding: var(--mini-unit) calc(2.5 * var(--mini-unit));
@@ -30,7 +31,7 @@
}
.details {
padding-top: 3px;
padding-top: 8px;
font-family: var(--v2-font-family-primary);
font-weight: var(--v2-font-weight-primary-regular);
font-size: var(--v2-font-size-2);
@@ -0,0 +1,8 @@
.bold {
font-weight: var(--v2-font-weight-primary-bold);
}
.muted {
font-weight: var(--v2-font-weight-primary-bold);
color: var(--v2-colors-grey-400);
}
@@ -0,0 +1,21 @@
import cn from "classnames";
import React, { FunctionComponent } from "react";
import styles from "./OptionDetail.css";
interface Props {
variant?: "bold" | "muted";
}
const OptionDetail: FunctionComponent<Props> = ({ children, variant }) => (
<span
className={cn({
[styles.muted]: variant === "muted",
[styles.bold]: variant === "bold",
})}
>
{children}
</span>
);
export default OptionDetail;
@@ -20,6 +20,10 @@ const SearchStoryFetch = createFetch(
$limit: Int!
$siteID: ID
) {
settings {
multisite
featureFlags
}
stories(query: $query, first: $limit, siteID: $siteID) {
edges {
node {
@@ -30,6 +34,7 @@ const SearchStoryFetch = createFetch(
}
metadata {
title
section
author
}
}
@@ -3,6 +3,7 @@ import { RouteProps } from "found";
import React from "react";
import { graphql, RelayPaginationProp } from "react-relay";
import parseModerationOptions from "coral-framework/helpers/parseModerationOptions";
import { IntersectionProvider } from "coral-framework/lib/intersection";
import { withPaginationContainer } from "coral-framework/lib/relay";
import { resolveModule } from "coral-framework/lib/relay/helpers";
@@ -19,6 +20,7 @@ interface ApprovedQueueRouteProps {
relay: RelayPaginationProp;
storyID?: string;
siteID?: string;
section?: string | null;
}
// TODO: use generated types
@@ -89,11 +91,13 @@ const enhanced = (withPaginationContainer<
cursor: { type: "Cursor" }
storyID: { type: "ID" }
siteID: { type: "ID" }
section: { type: "SectionFilter" }
) {
comments(
status: APPROVED
storyID: $storyID
siteID: $siteID
section: $section
first: $count
after: $cursor
) @connection(key: "ApprovedQueue_comments") {
@@ -135,6 +139,7 @@ const enhanced = (withPaginationContainer<
query ApprovedQueueRoutePaginationQuery(
$storyID: ID
$siteID: ID
$section: SectionFilter
$count: Int!
$cursor: Cursor
) {
@@ -142,6 +147,7 @@ const enhanced = (withPaginationContainer<
@arguments(
storyID: $storyID
siteID: $siteID
section: $section
count: $count
cursor: $cursor
)
@@ -153,18 +159,26 @@ const enhanced = (withPaginationContainer<
enhanced.routeConfig = {
Component: enhanced,
query: resolveModule(graphql`
query ApprovedQueueRouteQuery($storyID: ID, $siteID: ID) {
...ApprovedQueueRoute_query @arguments(storyID: $storyID, siteID: $siteID)
query ApprovedQueueRouteQuery(
$storyID: ID
$siteID: ID
$section: SectionFilter
) {
...ApprovedQueueRoute_query
@arguments(storyID: $storyID, siteID: $siteID, section: $section)
}
`),
cacheConfig: { force: true },
render: function RejectedRouteRender({ Component, props, match }) {
if (Component && props) {
const { storyID, siteID, section } = parseModerationOptions(match);
return (
<Component
query={props}
storyID={match.params.storyID}
siteID={match.params.siteID}
storyID={storyID}
siteID={siteID}
section={section}
/>
);
}
@@ -2,6 +2,7 @@ import { graphql } from "react-relay";
import { Environment, RecordSourceSelectorProxy } from "relay-runtime";
import { getQueueConnection } from "coral-admin/helpers";
import { SectionFilter } from "coral-common/section";
import {
createSubscription,
requestSubscription,
@@ -14,7 +15,9 @@ import { QueueCommentEnteredSubscription } from "coral-admin/__generated__/Queue
function handleCommentEnteredModerationQueue(
store: RecordSourceSelectorProxy<unknown>,
queue: GQLMODERATION_QUEUE_RL,
storyID: string | null
storyID: string | null,
siteID: string | null,
section?: SectionFilter | null
) {
const rootField = store.getRootField("commentEnteredModerationQueue");
if (!rootField) {
@@ -28,7 +31,7 @@ function handleCommentEnteredModerationQueue(
);
commentsEdge.setValue(comment.getValue("createdAt"), "cursor");
commentsEdge.setLinkedRecord(comment, "node");
const connection = getQueueConnection(store, queue, storyID);
const connection = getQueueConnection(store, queue, storyID, siteID, section);
if (connection) {
const linked = connection.getLinkedRecords("viewNewEdges") || [];
connection.setLinkedRecords(linked.concat(commentsEdge), "viewNewEdges");
@@ -45,9 +48,16 @@ const QueueSubscription = createSubscription(
subscription: graphql`
subscription QueueCommentEnteredSubscription(
$storyID: ID
$siteID: ID
$section: SectionFilter
$queue: MODERATION_QUEUE!
) {
commentEnteredModerationQueue(storyID: $storyID, queue: $queue) {
commentEnteredModerationQueue(
storyID: $storyID
siteID: $siteID
section: $section
queue: $queue
) {
comment {
id
createdAt
@@ -61,7 +71,9 @@ const QueueSubscription = createSubscription(
handleCommentEnteredModerationQueue(
store,
variables.queue,
variables.storyID || null
variables.storyID || null,
variables.siteID || null,
variables.section
);
},
})
@@ -2,6 +2,7 @@ import { graphql } from "react-relay";
import { Environment, RecordSourceSelectorProxy } from "relay-runtime";
import { getQueueConnection } from "coral-admin/helpers";
import { SectionFilter } from "coral-common/section";
import {
createSubscription,
requestSubscription,
@@ -14,7 +15,9 @@ import { QueueCommentLeftSubscription } from "coral-admin/__generated__/QueueCom
function handleCommentLeftModerationQueue(
store: RecordSourceSelectorProxy<unknown>,
queue: GQLMODERATION_QUEUE_RL,
storyID: string | null
storyID: string | null,
siteID: string | null,
section?: SectionFilter | null
) {
const rootField = store.getRootField("commentLeftModerationQueue");
if (!rootField) {
@@ -28,7 +31,7 @@ function handleCommentLeftModerationQueue(
// Mark that the status of the comment was live updated.
commentInStore.setValue(true, "statusLiveUpdated");
}
const connection = getQueueConnection(store, queue, storyID);
const connection = getQueueConnection(store, queue, storyID, siteID, section);
if (connection) {
const linked = connection.getLinkedRecords("viewNewEdges") || [];
connection.setLinkedRecords(
@@ -50,9 +53,16 @@ const QueueSubscription = createSubscription(
subscription: graphql`
subscription QueueCommentLeftSubscription(
$storyID: ID
$siteID: ID
$section: SectionFilter
$queue: MODERATION_QUEUE!
) {
commentLeftModerationQueue(storyID: $storyID, queue: $queue) {
commentLeftModerationQueue(
storyID: $storyID
siteID: $siteID
section: $section
queue: $queue
) {
comment {
id
status
@@ -67,7 +77,9 @@ const QueueSubscription = createSubscription(
handleCommentLeftModerationQueue(
store,
variables.queue,
variables.storyID || null
variables.storyID || null,
variables.siteID || null,
variables.section
);
},
})
@@ -7,6 +7,8 @@ import React, {
} from "react";
import { graphql, GraphQLTaggedNode, RelayPaginationProp } from "react-relay";
import { SectionFilter } from "coral-common/section";
import parseModerationOptions from "coral-framework/helpers/parseModerationOptions";
import { IntersectionProvider } from "coral-framework/lib/intersection";
import {
combineDisposables,
@@ -36,8 +38,9 @@ interface Props {
settings: QueueRoute_settings | null;
relay: RelayPaginationProp;
emptyElement: React.ReactElement;
storyID?: string;
siteID?: string;
storyID?: string | null;
siteID?: string | null;
section?: SectionFilter | null;
count?: string;
}
@@ -59,6 +62,7 @@ export const QueueRoute: FunctionComponent<Props> = (props) => {
queue: props.queueName,
storyID: props.storyID || null,
siteID: props.siteID || null,
section: props.section,
});
}, [props.queueName, props.storyID, props.siteID, viewNew]);
useEffect(() => {
@@ -66,6 +70,7 @@ export const QueueRoute: FunctionComponent<Props> = (props) => {
queue: props.queueName,
storyID: props.storyID || null,
siteID: props.siteID || null,
section: props.section,
};
const disposable = combineDisposables(
subscribeToQueueCommentEntered(vars),
@@ -77,6 +82,7 @@ export const QueueRoute: FunctionComponent<Props> = (props) => {
}, [
props.storyID,
props.siteID,
props.section,
props.queueName,
subscribeToQueueCommentEntered,
subscribeToQueueCommentLeft,
@@ -126,6 +132,9 @@ const createQueueRoute = (
if (!Component) {
throw new Error("Missing component");
}
const { storyID, siteID, section } = parseModerationOptions(match);
if (!data || !data.moderationQueues) {
return (
<Component
@@ -134,13 +143,15 @@ const createQueueRoute = (
queue={null}
settings={null}
emptyElement={emptyElement}
storyID={match.params.storyID}
siteID={match.params.siteID}
storyID={storyID}
siteID={siteID}
section={section}
/>
);
}
const queue =
data.moderationQueues[Object.keys(data.moderationQueues)[0]];
return (
<Component
isLoading={false}
@@ -148,8 +159,9 @@ const createQueueRoute = (
queue={queue}
settings={data.settings}
emptyElement={emptyElement}
storyID={match.params.storyID}
siteID={match.params.siteID}
storyID={storyID}
siteID={siteID}
section={section}
/>
);
},
@@ -217,8 +229,13 @@ const createQueueRoute = (
export const PendingQueueRoute = createQueueRoute(
GQLMODERATION_QUEUE.PENDING,
graphql`
query QueueRoutePendingQuery($storyID: ID, $siteID: ID, $count: Int) {
moderationQueues(storyID: $storyID, siteID: $siteID) {
query QueueRoutePendingQuery(
$storyID: ID
$siteID: ID
$count: Int
$section: SectionFilter
) {
moderationQueues(storyID: $storyID, siteID: $siteID, section: $section) {
pending {
...QueueRoute_queue @arguments(count: $count)
}
@@ -234,10 +251,11 @@ export const PendingQueueRoute = createQueueRoute(
query QueueRoutePaginationPendingQuery(
$storyID: ID
$siteID: ID
$section: SectionFilter
$count: Int!
$cursor: Cursor
) {
moderationQueues(storyID: $storyID, siteID: $siteID) {
moderationQueues(storyID: $storyID, siteID: $siteID, section: $section) {
pending {
...QueueRoute_queue @arguments(count: $count, cursor: $cursor)
}
@@ -255,8 +273,12 @@ export const PendingQueueRoute = createQueueRoute(
export const ReportedQueueRoute = createQueueRoute(
GQLMODERATION_QUEUE.REPORTED,
graphql`
query QueueRouteReportedQuery($storyID: ID, $siteID: ID) {
moderationQueues(storyID: $storyID, siteID: $siteID) {
query QueueRouteReportedQuery(
$storyID: ID
$siteID: ID
$section: SectionFilter
) {
moderationQueues(storyID: $storyID, siteID: $siteID, section: $section) {
reported {
...QueueRoute_queue
}
@@ -272,10 +294,11 @@ export const ReportedQueueRoute = createQueueRoute(
query QueueRoutePaginationReportedQuery(
$storyID: ID
$siteID: ID
$section: SectionFilter
$count: Int!
$cursor: Cursor
) {
moderationQueues(storyID: $storyID, siteID: $siteID) {
moderationQueues(storyID: $storyID, siteID: $siteID, section: $section) {
reported {
...QueueRoute_queue @arguments(count: $count, cursor: $cursor)
}
@@ -293,8 +316,12 @@ export const ReportedQueueRoute = createQueueRoute(
export const UnmoderatedQueueRoute = createQueueRoute(
GQLMODERATION_QUEUE.UNMODERATED,
graphql`
query QueueRouteUnmoderatedQuery($storyID: ID, $siteID: ID) {
moderationQueues(storyID: $storyID, siteID: $siteID) {
query QueueRouteUnmoderatedQuery(
$storyID: ID
$siteID: ID
$section: SectionFilter
) {
moderationQueues(storyID: $storyID, siteID: $siteID, section: $section) {
unmoderated {
...QueueRoute_queue
}
@@ -310,10 +337,11 @@ export const UnmoderatedQueueRoute = createQueueRoute(
query QueueRoutePaginationUnmoderatedQuery(
$storyID: ID
$siteID: ID
$section: SectionFilter
$count: Int!
$cursor: Cursor
) {
moderationQueues(storyID: $storyID, siteID: $siteID) {
moderationQueues(storyID: $storyID, siteID: $siteID, section: $section) {
unmoderated {
...QueueRoute_queue @arguments(count: $count, cursor: $cursor)
}
@@ -1,6 +1,7 @@
import { ConnectionHandler, Environment } from "relay-runtime";
import { getQueueConnection } from "coral-admin/helpers";
import { SectionFilter } from "coral-common/section";
import {
commitLocalUpdatePromisified,
createMutation,
@@ -10,6 +11,7 @@ import { GQLMODERATION_QUEUE } from "coral-framework/schema";
interface QueueViewNewInput {
storyID: string | null;
siteID: string | null;
section?: SectionFilter | null;
queue: GQLMODERATION_QUEUE;
}
@@ -21,7 +23,8 @@ const QueueViewNewMutation = createMutation(
store,
input.queue,
input.storyID,
input.siteID
input.siteID,
input.section
);
if (!connection) {
return;
@@ -3,6 +3,7 @@ import { RouteProps } from "found";
import React from "react";
import { graphql, RelayPaginationProp } from "react-relay";
import parseModerationOptions from "coral-framework/helpers/parseModerationOptions";
import { IntersectionProvider } from "coral-framework/lib/intersection";
import { withPaginationContainer } from "coral-framework/lib/relay";
import { resolveModule } from "coral-framework/lib/relay/helpers";
@@ -19,6 +20,7 @@ interface RejectedQueueRouteProps {
relay: RelayPaginationProp;
storyID?: string;
siteID?: string;
section?: string | null;
}
// TODO: use generated types
@@ -89,11 +91,13 @@ const enhanced = (withPaginationContainer<
cursor: { type: "Cursor" }
storyID: { type: "ID" }
siteID: { type: "ID" }
section: { type: "SectionFilter" }
) {
comments(
status: REJECTED
storyID: $storyID
siteID: $siteID
section: $section
first: $count
after: $cursor
) @connection(key: "RejectedQueue_comments") {
@@ -135,6 +139,7 @@ const enhanced = (withPaginationContainer<
query RejectedQueueRoutePaginationQuery(
$storyID: ID
$siteID: ID
$section: SectionFilter
$count: Int!
$cursor: Cursor
) {
@@ -142,6 +147,7 @@ const enhanced = (withPaginationContainer<
@arguments(
storyID: $storyID
siteID: $siteID
section: $section
count: $count
cursor: $cursor
)
@@ -153,18 +159,26 @@ const enhanced = (withPaginationContainer<
enhanced.routeConfig = {
Component: enhanced,
query: resolveModule(graphql`
query RejectedQueueRouteQuery($storyID: ID, $siteID: ID) {
...RejectedQueueRoute_query @arguments(storyID: $storyID, siteID: $siteID)
query RejectedQueueRouteQuery(
$storyID: ID
$siteID: ID
$section: SectionFilter
) {
...RejectedQueueRoute_query
@arguments(storyID: $storyID, siteID: $siteID, section: $section)
}
`),
cacheConfig: { force: true },
render: function RejectedRouteRender({ Component, props, match }) {
if (Component && props) {
const { storyID, siteID, section } = parseModerationOptions(match);
return (
<Component
query={props}
storyID={match.params.storyID}
siteID={match.params.siteID}
storyID={storyID}
siteID={siteID}
section={section}
/>
);
}
@@ -0,0 +1,8 @@
.button {
height: calc(4 * var(--mini-unit));
}
.buttonText {
overflow-x: hidden;
text-overflow: ellipsis;
}
@@ -0,0 +1,74 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent } from "react";
import PaginatedSelect from "coral-admin/components/PaginatedSelect";
import { SectionFilter } from "coral-common/section";
import { getModerationLink, QUEUE_NAME } from "coral-framework/helpers";
import { Divider } from "coral-ui/components/v2/Dropdown";
import SectionSelectorSection from "./SectionSelectorSection";
import styles from "./SectionSelector.css";
interface Props {
sections: ReadonlyArray<string>;
section?: SectionFilter | null;
queueName: QUEUE_NAME;
}
const SelectedSection: FunctionComponent<{
section?: SectionFilter | null;
}> = ({ section }) => {
if (!section) {
return (
<Localized id="moderate-section-selector-allSections">
<span className={styles.buttonText}>All Sections</span>
</Localized>
);
}
if (!section.name) {
return (
<Localized id="moderate-section-selector-uncategorized">
<span className={styles.buttonText}>Uncategorized</span>
</Localized>
);
}
return <span className={styles.buttonText}>{section.name}</span>;
};
const SectionSelector: FunctionComponent<Props> = ({
sections,
section,
queueName: queue,
}) => {
return (
<PaginatedSelect
disableLoadMore
className={styles.button}
selected={<SelectedSection section={section} />}
>
<SectionSelectorSection
active={!section}
link={getModerationLink({ queue })}
/>
<SectionSelectorSection
section={{ name: null }}
active={!!section && !section.name}
link={getModerationLink({ queue, section: { name: null } })}
/>
{sections.length > 0 && <Divider />}
{sections.map((name) => (
<SectionSelectorSection
key={name}
section={{ name }}
active={!!section && section.name === name}
link={getModerationLink({ queue, section: { name } })}
/>
))}
</PaginatedSelect>
);
};
export default SectionSelector;
@@ -0,0 +1,54 @@
import React, { FunctionComponent } from "react";
import { graphql } from "react-relay";
import { SectionFilter } from "coral-common/section";
import { QUEUE_NAME } from "coral-framework/helpers";
import { withFragmentContainer } from "coral-framework/lib/relay";
import { GQLFEATURE_FLAG } from "coral-framework/schema";
import { SectionSelectorContainer_query } from "coral-admin/__generated__/SectionSelectorContainer_query.graphql";
import SectionSelector from "./SectionSelector";
interface Props {
query: SectionSelectorContainer_query | null;
section?: SectionFilter | null;
queueName: string;
}
const SectionSelectorContainer: FunctionComponent<Props> = ({
query,
section,
queueName,
}) => {
// FEATURE_FLAG:SECTIONS
if (
!query ||
!query.settings.featureFlags.includes(GQLFEATURE_FLAG.SECTIONS) ||
!query.sections
) {
return null;
}
return (
<SectionSelector
sections={query.sections}
section={section}
queueName={queueName as QUEUE_NAME}
/>
);
};
const enhanced = withFragmentContainer<Props>({
query: graphql`
fragment SectionSelectorContainer_query on Query {
sections
settings {
# FEATURE_FLAG:SECTIONS
featureFlags
}
}
`,
})(SectionSelectorContainer);
export default enhanced;
@@ -0,0 +1,18 @@
.root {
font-family: var(--v2-font-family-primary);
font-weight: var(--v2-font-weight-primary-regular);
font-size: var(--v2-font-size-1);
color: var(--v2-colors-mono-500);
line-height: var(--v2-line-height-reset);
display: block;
text-decoration: none;
padding: var(--v2-spacing-2) var(--v2-spacing-4);
}
.root:hover {
background: var(--v2-colors-teal-100);
}
.active {
font-weight: var(--v2-font-weight-primary-bold);
}
@@ -0,0 +1,43 @@
import { Localized } from "@fluent/react/compat";
import cn from "classnames";
import { Link } from "found";
import React, { FunctionComponent } from "react";
import { SectionFilter } from "coral-common/section";
import styles from "./SectionSelectorSection.css";
interface Props {
section?: SectionFilter;
active?: boolean;
link?: string;
}
const SectionSelectorSection: FunctionComponent<Props> = ({
section,
link,
active,
}) => {
return (
<Link
className={cn(styles.root, {
[styles.active]: active,
})}
to={link || ""}
>
{!section && (
<Localized id="moderate-section-selector-allSections">
<span>All Sections</span>
</Localized>
)}
{section && !section.name && (
<Localized id="moderate-section-selector-uncategorized">
<span>Uncategorized</span>
</Localized>
)}
{section && section.name && <span>{section.name}</span>}
</Link>
);
};
export default SectionSelectorSection;
@@ -0,0 +1 @@
export { default as SectionSelectorContainer } from "./SectionSelectorContainer";
@@ -9,6 +9,10 @@
padding: var(--v2-spacing-2) var(--v2-spacing-4);
}
.root:hover {
background: var(--v2-colors-teal-100);
}
.active {
font-weight: var(--v2-font-weight-primary-bold);
}
@@ -6,6 +6,12 @@ exports[`renders correctly 1`] = `
>
<withRouter(Relay(ModerateSearchBarContainer))
allStories={true}
sectionSelector={
<Relay(SectionSelectorContainer)
query=""
queueName=""
/>
}
settings={
Object {
"multisite": false,
@@ -75,6 +75,7 @@ it("renders rejected queue with comments", async () => {
status: "REJECTED",
storyID: null,
siteID: null,
section: null,
});
return {
edges: [
@@ -114,6 +115,7 @@ it("shows a moderate story", async () => {
status: "REJECTED",
storyID: null,
siteID: null,
section: null,
});
return {
edges: [
@@ -158,6 +160,7 @@ it("renders rejected queue with comments and load more", async () => {
status: GQLCOMMENT_STATUS.REJECTED,
storyID: null,
siteID: null,
section: null,
});
return {
edges: [
@@ -182,6 +185,7 @@ it("renders rejected queue with comments and load more", async () => {
status: GQLCOMMENT_STATUS.REJECTED,
storyID: null,
siteID: null,
section: null,
});
return {
edges: [
@@ -275,6 +279,7 @@ it("approves comment in rejected queue", async () => {
status: "REJECTED",
storyID: null,
siteID: null,
section: null,
});
return {
edges: [
@@ -1,5 +1,7 @@
import urls from "./urls";
import { SectionFilter } from "coral-common/section";
export type QUEUE_NAME =
| "reported"
| "pending"
@@ -7,16 +9,18 @@ export type QUEUE_NAME =
| "rejected"
| "approved";
interface Options {
export interface Options {
queue?: QUEUE_NAME;
storyID?: string | null;
siteID?: string | null;
section?: SectionFilter | null;
}
export default function getModerationLink({
queue,
storyID,
siteID,
section,
}: Options = {}) {
const parts = [urls.admin.moderate];
@@ -32,5 +36,16 @@ export default function getModerationLink({
parts.push("sites", encodeURIComponent(siteID));
}
return parts.join("/");
const path = parts.join("/");
if (section) {
const { name } = section;
if (name) {
return path + "?section=" + encodeURIComponent(name);
}
return path + "?section=";
}
return path;
}
@@ -0,0 +1,30 @@
import { Match } from "found";
import { Options } from "./getModerationLink";
export default function parseModerationOptions(
match: Match
): Omit<Options, "queue"> {
const options: Options = {};
if (match.params.storyID) {
options.storyID = match.params.storyID;
}
if (match.params.siteID) {
options.siteID = match.params.siteID;
}
if (typeof match.location.query.section === "string") {
const section = match.location.query.section;
options.section = section
? {
name: section,
}
: {
name: null,
};
}
return options;
}
@@ -42,10 +42,13 @@ export interface ManagedSubscriptionClient {
cacheConfig: CacheConfig,
observer: any
): Disposable;
/** Pauses all active subscriptions causing websocket connection to close. */
pause(): void;
/** Resume all subscriptions eventually causing websocket to start with new connection parameters */
resume(): void;
/** Sets access token and restarts the websocket connection */
setAccessToken(accessToken?: string): void;
}
@@ -140,13 +143,28 @@ export default function createManagedSubscriptionClient(
subscription.unsubscribe();
};
};
// Register the request.
requests.push(request as SubscriptionRequest);
// Start subscription if we are not paused.
if (!paused) {
request.subscribe();
// Debug subscriptions being logged. These should be kept here to help
// with debugging subscriptions.
if (process.env.NODE_ENV !== "production") {
window.console.debug(
`+1 [${requests.length - 1} + 1 = ${
requests.length
}] subscribe called for subscription:`,
request.operation?.name,
"with variables:",
JSON.stringify(request.variables)
);
}
}
return {
dispose: () => {
const i = requests.findIndex((r) => r === request);
@@ -166,6 +184,19 @@ export default function createManagedSubscriptionClient(
closeClient();
}
}
// Debug subscriptions being logged. These should be kept here to help
// with debugging subscriptions.
if (process.env.NODE_ENV !== "production") {
window.console.debug(
`-1 [${requests.length + 1} - 1 = ${
requests.length
}] dispose called for subscription:`,
request.operation?.name,
"with variables:",
JSON.stringify(request.variables)
);
}
},
};
};
@@ -23,7 +23,7 @@ function createSubscriptionFunction(
): SubscribeFunction {
const fn: SubscribeFunction = (operation, variables, cacheConfig) => {
return Observable.create<GraphQLResponse>((sink) => {
subscriptionClient.subscribe(
const subscription = subscriptionClient.subscribe(
operation as any,
variables,
{
@@ -36,6 +36,10 @@ function createSubscriptionFunction(
onCompleted: sink.complete,
}
);
return () => {
subscription.dispose();
};
});
};
return fn;
@@ -14,7 +14,6 @@ import {
} from "relay-runtime";
import { CoralContext, useCoralContext, withContext } from "../bootstrap";
import extractPayload from "./extractPayload";
export interface Fetch<N, V, R> {
name: N;
@@ -49,14 +48,8 @@ export async function fetchQuery<T extends { response: any }>(
taggedNode: GraphQLTaggedNode,
variables: Variables,
cacheConfig?: CacheConfig
): Promise<T["response"][keyof T["response"]]> {
const result = await relayFetchQuery(
environment,
taggedNode,
variables,
cacheConfig
);
return extractPayload(result);
): Promise<T["response"]> {
return relayFetchQuery(environment, taggedNode, variables, cacheConfig);
}
/**
@@ -5,6 +5,7 @@ import { graphql, RelayPaginationProp } from "react-relay";
import FadeInTransition from "coral-framework/components/FadeInTransition";
import { useViewerNetworkEvent } from "coral-framework/lib/events";
import {
combineDisposables,
useLoadMore,
useLocal,
useMutation,
@@ -86,17 +87,20 @@ export const AllCommentsTabContainer: FunctionComponent<Props> = (props) => {
// Only chronological sort supports top level live updates of incoming comments.
return;
}
const newCommentDisposable = subscribeToCommentCreated({
storyID: props.story.id,
orderBy: commentsOrderBy,
});
const releasedCommentDisposable = subscribeToCommentReleased({
storyID: props.story.id,
orderBy: commentsOrderBy,
});
const disposable = combineDisposables(
subscribeToCommentCreated({
storyID: props.story.id,
orderBy: commentsOrderBy,
}),
subscribeToCommentReleased({
storyID: props.story.id,
orderBy: commentsOrderBy,
})
);
return () => {
newCommentDisposable.dispose();
releasedCommentDisposable.dispose();
disposable.dispose();
};
}, [
commentsOrderBy,
+8
View File
@@ -0,0 +1,8 @@
export interface SectionFilter {
/**
* name when null or not provided indicates a filter for comments/stories
* without a section. When name is provided as a string, it indicates a filter
* for comments/stories with the specified section.
*/
name?: string | null;
}
+41 -10
View File
@@ -2,6 +2,7 @@ import DataLoader from "dataloader";
import { defaultTo, isNil, omitBy } from "lodash";
import { DateTime } from "luxon";
import { SectionFilter } from "coral-common/section";
import Context from "coral-server/graph/context";
import { retrieveManyUserActionPresence } from "coral-server/models/action/comment";
import {
@@ -21,6 +22,7 @@ import {
import { retrieveSharedModerationQueueQueuesCounts } from "coral-server/models/comment/counts/shared";
import { hasPublishedStatus } from "coral-server/models/comment/helpers";
import { Connection } from "coral-server/models/helpers";
import { hasFeatureFlag, Tenant } from "coral-server/models/tenant";
import { User } from "coral-server/models/user";
import {
@@ -28,6 +30,7 @@ import {
CommentToRepliesArgs,
GQLActionPresence,
GQLCOMMENT_SORT,
GQLFEATURE_FLAG,
GQLTAG,
GQLUSER_ROLE,
QueryToCommentsArgs,
@@ -39,6 +42,18 @@ import {
import { SingletonResolver } from "./util";
/**
* requiredPropertyFilter will remove those properties that are nil from the
* object as they are not nilable on the database model. If we didn't do this,
* then any time that the property is nil, we'd be querying for comments that
* can't possibly exist!
*
* @param props properties that if nil should be removed from the return object
*/
const requiredPropertyFilter = (
props: CommentConnectionInput["filter"]
): CommentConnectionInput["filter"] => omitBy(props, isNil);
const tagFilter = (tag?: GQLTAG): CommentConnectionInput["filter"] => {
if (tag) {
return {
@@ -57,6 +72,22 @@ const queryFilter = (query?: string): CommentConnectionInput["filter"] => {
return {};
};
const sectionFilter = (
tenant: Pick<Tenant, "featureFlags">,
section?: SectionFilter
): CommentConnectionInput["filter"] => {
// Don't filter by section if the feature flag is disabled.
if (!hasFeatureFlag(tenant, GQLFEATURE_FLAG.SECTIONS)) {
return {};
}
if (section) {
return { section: section.name || null };
}
return {};
};
/**
* primeCommentsFromConnection will prime a given context with the comments
* retrieved via a connection.
@@ -139,6 +170,7 @@ export default (ctx: Context) => ({
after,
storyID,
siteID,
section,
status,
tag,
query,
@@ -147,16 +179,15 @@ export default (ctx: Context) => ({
first: defaultTo(first, 10),
after,
orderBy: GQLCOMMENT_SORT.CREATED_AT_DESC,
filter: omitBy(
{
...queryFilter(query),
...tagFilter(tag),
storyID,
siteID,
status,
},
isNil
),
filter: {
...queryFilter(query),
...tagFilter(tag),
...sectionFilter(ctx.tenant, section),
// If these properties are not provided or are null, remove them from
// the filter because they do not exist in a nullable state on the
// database model.
...requiredPropertyFilter({ storyID, siteID, status }),
},
}).then(primeCommentsFromConnection(ctx)),
retrieveMyActionPresence: new DataLoader<string, GQLActionPresence>(
(commentIDs: string[]) => {
+2
View File
@@ -17,6 +17,7 @@ import {
findOrCreate,
FindOrCreateStory,
FindStory,
retrieveSections,
} from "coral-server/services/stories";
import { scraper } from "coral-server/services/stories/scraper";
@@ -185,6 +186,7 @@ export default (ctx: GraphContext) => ({
cache: !ctx.disableCaching,
}
),
sections: () => retrieveSections(ctx.mongo, ctx.tenant),
story: new DataLoader<string, Story | null>(
(ids) => retrieveManyStories(ctx.mongo, ctx.tenant.id, ids),
{
@@ -5,6 +5,7 @@ import {
import { FilterQuery } from "coral-server/models/helpers";
import { Site } from "coral-server/models/site";
import { Story } from "coral-server/models/story";
import { hasFeatureFlag } from "coral-server/models/tenant";
import {
PENDING_STATUS,
REPORTED_STATUS,
@@ -12,7 +13,9 @@ import {
} from "coral-server/services/comments/moderation/counts";
import {
GQLFEATURE_FLAG,
GQLModerationQueuesTypeResolver,
GQLSectionFilter,
QueryToModerationQueuesResolver,
} from "coral-server/graph/schema/__generated__/types";
@@ -21,7 +24,7 @@ import { ModerationQueueInput } from "./ModerationQueue";
interface ModerationQueuesInput {
connection: Partial<CommentConnectionInput>;
counts: CommentModerationCountsPerQueue;
counts?: CommentModerationCountsPerQueue;
}
const mergeModerationInputFilters = (
@@ -80,6 +83,25 @@ export const storyModerationInputResolver = (
counts: story.commentCounts.moderationQueue.queues,
});
/**
* sectionModerationInputResolver can be used to retrieve the moderationQueue for
* a specific Story.
*
* @param section the section that will be used to base the comment moderation
* queues on
*/
export const sectionModerationInputResolver = async (
section: GQLSectionFilter
): Promise<ModerationQueuesInput> => ({
connection: {
filter: {
// This moderationQueues is being sourced from the section, so require
// that all the comments for theses queues are also for this section.
section: section.name || null,
},
},
});
/**
* sharedModerationInputResolver implements the resolver function style which
* allows it to be used in a type resolver.
@@ -122,6 +144,10 @@ export const moderationQueuesResolver: QueryToModerationQueuesResolver = async (
return storyModerationInputResolver(story);
}
if (args.section && hasFeatureFlag(ctx.tenant, GQLFEATURE_FLAG.SECTIONS)) {
return sectionModerationInputResolver(args.section);
}
if (args.siteID) {
const site = await ctx.loaders.Sites.site.load(args.siteID);
if (!site) {
+1
View File
@@ -24,6 +24,7 @@ export const Query: Required<GQLQueryTypeResolver<void>> = {
debugScrapeStoryMetadata: (source, { url }, ctx) =>
ctx.loaders.Stories.debugScrapeMetadata.load(url),
moderationQueues: moderationQueuesResolver,
sections: (source, args, ctx) => ctx.loaders.Stories.sections(),
activeStories: (source, { limit = 10 }, ctx) =>
ctx.loaders.Stories.activeStories(limit),
sites: (source, args, ctx) => ctx.loaders.Sites.connection(args),
@@ -1,4 +1,7 @@
import { hasFeatureFlag } from "coral-server/models/tenant";
import {
GQLFEATURE_FLAG,
GQLMODERATION_QUEUE,
SubscriptionToCommentEnteredModerationQueueResolver,
} from "coral-server/graph/schema/__generated__/types";
@@ -15,6 +18,8 @@ export interface CommentEnteredModerationQueueInput
queue: GQLMODERATION_QUEUE;
commentID: string;
storyID: string;
siteID: string;
section?: string;
}
export type CommentEnteredModerationQueueSubscription = SubscriptionType<
@@ -25,7 +30,7 @@ export type CommentEnteredModerationQueueSubscription = SubscriptionType<
export const commentEnteredModerationQueue: SubscriptionToCommentEnteredModerationQueueResolver<CommentEnteredModerationQueueInput> = createIterator(
SUBSCRIPTION_CHANNELS.COMMENT_ENTERED_MODERATION_QUEUE,
{
filter: (source, { storyID, queue }) => {
filter: (source, { storyID, siteID, section, queue }, ctx) => {
// If we're filtering by storyID, then only send back comments with the
// specific storyID.
if (storyID && source.storyID !== storyID) {
@@ -38,6 +43,25 @@ export const commentEnteredModerationQueue: SubscriptionToCommentEnteredModerati
return false;
}
// If we're filtering by siteID, then only send back comments from the
// specific site.
if (siteID && source.siteID !== siteID) {
return false;
}
// If we're filtering by section, then only send back comments from the
// specific section. If the source has a section, if it's not equal to the
// filter then return false. If the source does not have a section, then
// the filter must also be null/undefined, otherwise return false.
if (
section &&
((source.section && section.name !== source.section) ||
(!source.section && section.name)) &&
hasFeatureFlag(ctx.tenant, GQLFEATURE_FLAG.SECTIONS)
) {
return false;
}
return true;
},
}
@@ -1,4 +1,7 @@
import { hasFeatureFlag } from "coral-server/models/tenant";
import {
GQLFEATURE_FLAG,
GQLMODERATION_QUEUE,
SubscriptionToCommentLeftModerationQueueResolver,
} from "coral-server/graph/schema/__generated__/types";
@@ -14,6 +17,8 @@ export interface CommentLeftModerationQueueInput extends SubscriptionPayload {
queue: GQLMODERATION_QUEUE;
commentID: string;
storyID: string;
siteID: string;
section?: string;
}
export type CommentLeftModerationQueueSubscription = SubscriptionType<
@@ -24,7 +29,7 @@ export type CommentLeftModerationQueueSubscription = SubscriptionType<
export const commentLeftModerationQueue: SubscriptionToCommentLeftModerationQueueResolver<CommentLeftModerationQueueInput> = createIterator(
SUBSCRIPTION_CHANNELS.COMMENT_LEFT_MODERATION_QUEUE,
{
filter: (source, { storyID, queue }) => {
filter: (source, { storyID, siteID, section, queue }, ctx) => {
// If we're filtering by storyID, then only send back comments with the
// specific storyID.
if (storyID && source.storyID !== storyID) {
@@ -37,6 +42,25 @@ export const commentLeftModerationQueue: SubscriptionToCommentLeftModerationQueu
return false;
}
// If we're filtering by siteID, then only send back comments from the
// specific site.
if (siteID && source.siteID !== siteID) {
return false;
}
// If we're filtering by section, then only send back comments from the
// specific section. If the source has a section, if it's not equal to the
// filter then return false. If the source does not have a section, then
// the filter must also be null/undefined, otherwise return false.
if (
section &&
((source.section && section.name !== source.section) ||
(!source.section && section.name)) &&
hasFeatureFlag(ctx.tenant, GQLFEATURE_FLAG.SECTIONS)
) {
return false;
}
return true;
},
}
+46 -4
View File
@@ -91,6 +91,21 @@ Locale represents a language code in the BCP 47 format.
"""
scalar Locale
################################################################################
## Custom Input Types
################################################################################
input SectionFilter {
"""
name when provided will filter only those comments/stories that have this
section. When name is not provided or is null, only comments/stories without
a section (uncategorized) will be returned. In order to return all
comments/stories regardless of section, specify null or undefined for this
filter option.
"""
name: String
}
################################################################################
## Actions
################################################################################
@@ -343,6 +358,12 @@ type ActionPresence {
################################################################################
enum FEATURE_FLAG {
"""
SECTIONS when enabled will allow filtering comments in the moderation queue
by section.
"""
SECTIONS
"""
DISABLE_WARN_USER_OF_TOXIC_COMMENT when enabled will turn off warnings for
toxic comments.
@@ -3150,6 +3171,7 @@ type Query {
storyID: ID
siteID: ID
status: COMMENT_STATUS
section: SectionFilter
tag: TAG
query: String
): CommentsConnection! @auth(roles: [ADMIN, MODERATOR])
@@ -3236,8 +3258,16 @@ type Query {
moderationQueues returns the set of ModerationQueues that are available for
all stories or if given the story identified by the `storyID`.
"""
moderationQueues(storyID: ID, siteID: ID): ModerationQueues!
@auth(roles: [ADMIN, MODERATOR])
moderationQueues(
storyID: ID
siteID: ID
section: SectionFilter
): ModerationQueues! @auth(roles: [ADMIN, MODERATOR])
"""
sections will return the unique sections used by this Tenant.
"""
sections: [String!] @auth(roles: [ADMIN, MODERATOR])
"""
activeStories is the list of most recently commented on stories identified
@@ -4888,7 +4918,11 @@ type ApproveCommentPayload {
is provided, it will filter the moderation queues for only comments in that
Story.
"""
moderationQueues(storyID: ID): ModerationQueues
moderationQueues(
storyID: ID
siteID: ID
section: SectionFilter
): ModerationQueues
"""
clientMutationId is required for Relay support.
@@ -4928,7 +4962,11 @@ type RejectCommentPayload {
is provided, it will filter the moderation queues for only comments in that
Story.
"""
moderationQueues(storyID: ID): ModerationQueues
moderationQueues(
storyID: ID
siteID: ID
section: SectionFilter
): ModerationQueues
"""
clientMutationId is required for Relay support.
@@ -7281,6 +7319,8 @@ type Subscription {
"""
commentEnteredModerationQueue(
storyID: ID
siteID: ID
section: SectionFilter
queue: MODERATION_QUEUE
): CommentEnteredModerationQueuePayload! @auth(roles: [MODERATOR, ADMIN])
@@ -7290,6 +7330,8 @@ type Subscription {
"""
commentLeftModerationQueue(
storyID: ID
siteID: ID
section: SectionFilter
queue: MODERATION_QUEUE
): CommentLeftModerationQueuePayload! @auth(roles: [MODERATOR, ADMIN])
@@ -80,6 +80,13 @@ export interface Comment extends TenantResource {
*/
siteID: string;
/**
* section is the section of the story that this comment was left on. If the
* section was not available when the comment was authored, the section will
* be null here.
*/
section?: string;
/**
* revisions stores all the revisions of the Comment body including the most
* recent revision, the last revision is the most recent.
+20
View File
@@ -661,3 +661,23 @@ export async function setStoryMode(
return result.value || null;
}
/**
* retrieveStorySections will return the sections used by stories in the
* database for a given Tenant sorted alphabetically.
*
* @param mongo the database connection to use to retrieve the data
* @param tenantID the ID of the Tenant that we're retrieving data
*/
export async function retrieveStorySections(
mongo: Db,
tenantID: string
): Promise<string[]> {
const results: Array<string | null> = await collection(
mongo
).distinct("metadata.section", { tenantID });
// We perform the type assertion here because we know that after filtering out
// the null entries, the resulting array can not contain null.
return results.filter((section) => section !== null).sort() as string[];
}
+13 -1
View File
@@ -90,19 +90,23 @@ export async function publishCommentFeatured(
export async function publishModerationQueueChanges(
broker: CoralEventPublisherBroker,
moderationQueue: Pick<CommentModerationQueueCounts, "queues">,
comment: Pick<Comment, "id" | "storyID">
comment: Pick<Comment, "id" | "storyID" | "siteID" | "section">
) {
if (moderationQueue.queues.pending === 1) {
await CommentEnteredModerationQueueCoralEvent.publish(broker, {
queue: GQLMODERATION_QUEUE.PENDING,
commentID: comment.id,
storyID: comment.storyID,
siteID: comment.siteID,
section: comment.section,
});
} else if (moderationQueue.queues.pending === -1) {
await CommentLeftModerationQueueCoralEvent.publish(broker, {
queue: GQLMODERATION_QUEUE.PENDING,
commentID: comment.id,
storyID: comment.storyID,
siteID: comment.siteID,
section: comment.section,
});
}
if (moderationQueue.queues.reported === 1) {
@@ -110,12 +114,16 @@ export async function publishModerationQueueChanges(
queue: GQLMODERATION_QUEUE.REPORTED,
commentID: comment.id,
storyID: comment.storyID,
siteID: comment.siteID,
section: comment.section,
});
} else if (moderationQueue.queues.reported === -1) {
await CommentLeftModerationQueueCoralEvent.publish(broker, {
queue: GQLMODERATION_QUEUE.REPORTED,
commentID: comment.id,
storyID: comment.storyID,
siteID: comment.siteID,
section: comment.section,
});
}
if (moderationQueue.queues.unmoderated === 1) {
@@ -123,12 +131,16 @@ export async function publishModerationQueueChanges(
queue: GQLMODERATION_QUEUE.UNMODERATED,
commentID: comment.id,
storyID: comment.storyID,
siteID: comment.siteID,
section: comment.section,
});
} else if (moderationQueue.queues.unmoderated === -1) {
await CommentLeftModerationQueueCoralEvent.publish(broker, {
queue: GQLMODERATION_QUEUE.UNMODERATED,
commentID: comment.id,
storyID: comment.storyID,
siteID: comment.siteID,
section: comment.section,
});
}
}
+14 -2
View File
@@ -34,6 +34,7 @@ import {
removeStory,
retrieveManyStories,
retrieveStory,
retrieveStorySections,
setStoryMode,
Story,
updateStory,
@@ -42,13 +43,16 @@ import {
updateStorySettings,
UpdateStorySettingsInput,
} from "coral-server/models/story";
import { Tenant } from "coral-server/models/tenant";
import { hasFeatureFlag, Tenant } from "coral-server/models/tenant";
import { retrieveUser } from "coral-server/models/user";
import { ScraperQueue } from "coral-server/queue/tasks/scraper";
import { findSiteByURL } from "coral-server/services/sites";
import { scrape } from "coral-server/services/stories/scraper";
import { GQLSTORY_MODE } from "coral-server/graph/schema/__generated__/types";
import {
GQLFEATURE_FLAG,
GQLSTORY_MODE,
} from "coral-server/graph/schema/__generated__/types";
export type FindStory = FindStoryInput;
@@ -412,3 +416,11 @@ export async function updateStoryMode(
) {
return setStoryMode(mongo, tenant.id, storyID, mode);
}
export async function retrieveSections(mongo: Db, tenant: Tenant) {
if (!hasFeatureFlag(tenant, GQLFEATURE_FLAG.SECTIONS)) {
return null;
}
return retrieveStorySections(mongo, tenant.id);
}
+2
View File
@@ -222,6 +222,8 @@ export default async function create(
{
...input,
siteID: story.siteID,
// Copy the current story section into the comment if it exists.
section: story.metadata?.section,
// Remap the tags to include the createdAt.
tags: tags.map((tag) => ({ type: tag, createdAt: now })),
body,
+6
View File
@@ -475,6 +475,12 @@ stories-column-site = Site
site-table-siteName = Site name
stories-filter-sites = Site
### Sections
moderate-section-selector-allSections = All Sections
moderate-section-selector-uncategorized = Uncategorized
moderate-section-uncategorized = Uncategorized
### Email
configure-email = Email settings