From ba08447d5e5b5eb08d1fd222976179a4149ad338 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Thu, 4 Jun 2020 11:32:04 +0000 Subject: [PATCH] [CORL-1090] Sections Alpha (#2973) * feat: added section filtering support * fix: addressed issues around filtering sections * fix: fixed issue with section filter --- .../ModerateCard/FeatureCommentMutation.ts | 39 ++++++++-- .../ModerateCard/ModerateCardContainer.tsx | 19 ++++- .../PaginatedSelect/PaginatedSelect.tsx | 17 +++-- .../admin/helpers/getQueueConnection.ts | 7 +- .../admin/mutations/ApproveCommentMutation.ts | 49 ++++++++++-- .../admin/mutations/RejectCommentMutation.ts | 17 ++++- .../sections/Auth/OIDCConfigContainer.tsx | 4 +- .../client/admin/routes/Moderate/Moderate.tsx | 15 +++- .../routes/Moderate/ModerateContainer.tsx | 20 +++-- ...derateCountsCommentEnteredSubscription.tsx | 7 +- .../ModerateCountsCommentLeftSubscription.tsx | 10 +-- .../ModerateNavigationContainer.tsx | 12 ++- .../ModerateNavigation/Navigation.tsx | 13 ++-- .../routes/Moderate/ModerateSearchBar/Bar.tsx | 4 +- .../ModerateSearchBarContainer.tsx | 71 ++++++++++++++---- .../Moderate/ModerateSearchBar/Option.css | 3 +- .../ModerateSearchBar/OptionDetail.css | 8 ++ .../ModerateSearchBar/OptionDetail.tsx | 21 ++++++ .../ModerateSearchBar/SearchStoryFetch.ts | 5 ++ .../Moderate/Queue/ApprovedQueueRoute.tsx | 22 +++++- .../Queue/QueueCommentEnteredSubscription.tsx | 20 ++++- .../Queue/QueueCommentLeftSubscription.tsx | 20 ++++- .../routes/Moderate/Queue/QueueRoute.tsx | 58 +++++++++++---- .../Moderate/Queue/QueueViewNewMutation.tsx | 5 +- .../Moderate/Queue/RejectedQueueRoute.tsx | 22 +++++- .../SectionSelector/SectionSelector.css | 8 ++ .../SectionSelector/SectionSelector.tsx | 74 +++++++++++++++++++ .../SectionSelectorContainer.tsx | 54 ++++++++++++++ .../SectionSelectorSection.css | 18 +++++ .../SectionSelectorSection.tsx | 43 +++++++++++ .../routes/Moderate/SectionSelector/index.ts | 1 + .../SiteSelector/SiteSelectorSite.css | 4 + .../__snapshots__/Moderate.spec.tsx.snap | 6 ++ .../test/moderate/rejectedQueue.spec.tsx | 5 ++ .../framework/helpers/getModerationLink.ts | 19 ++++- .../helpers/parseModerationOptions.ts | 30 ++++++++ .../createManagedSubscriptionClient.ts | 31 ++++++++ .../framework/lib/network/createNetwork.ts | 6 +- src/core/client/framework/lib/relay/fetch.tsx | 11 +-- .../AllCommentsTabContainer.tsx | 24 +++--- src/core/common/section.ts | 8 ++ src/core/server/graph/loaders/Comments.ts | 51 ++++++++++--- src/core/server/graph/loaders/Stories.ts | 2 + .../graph/resolvers/ModerationQueues.ts | 28 ++++++- src/core/server/graph/resolvers/Query.ts | 1 + .../commentEnteredModerationQueue.ts | 26 ++++++- .../commentLeftModerationQueue.ts | 26 ++++++- src/core/server/graph/schema/schema.graphql | 50 ++++++++++++- src/core/server/models/comment/comment.ts | 7 ++ src/core/server/models/story/index.ts | 20 +++++ src/core/server/services/events/comments.ts | 14 +++- src/core/server/services/stories/index.ts | 16 +++- src/core/server/stacks/createComment.ts | 2 + src/locales/en-US/admin.ftl | 6 ++ 54 files changed, 944 insertions(+), 135 deletions(-) create mode 100644 src/core/client/admin/routes/Moderate/ModerateSearchBar/OptionDetail.css create mode 100644 src/core/client/admin/routes/Moderate/ModerateSearchBar/OptionDetail.tsx create mode 100644 src/core/client/admin/routes/Moderate/SectionSelector/SectionSelector.css create mode 100644 src/core/client/admin/routes/Moderate/SectionSelector/SectionSelector.tsx create mode 100644 src/core/client/admin/routes/Moderate/SectionSelector/SectionSelectorContainer.tsx create mode 100644 src/core/client/admin/routes/Moderate/SectionSelector/SectionSelectorSection.css create mode 100644 src/core/client/admin/routes/Moderate/SectionSelector/SectionSelectorSection.tsx create mode 100644 src/core/client/admin/routes/Moderate/SectionSelector/index.ts create mode 100644 src/core/client/framework/helpers/parseModerationOptions.ts create mode 100644 src/core/common/section.ts diff --git a/src/core/client/admin/components/ModerateCard/FeatureCommentMutation.ts b/src/core/client/admin/components/ModerateCard/FeatureCommentMutation.ts index 11d772e23..35eb93549 100644 --- a/src/core/client/admin/components/ModerateCard/FeatureCommentMutation.ts +++ b/src/core/client/admin/components/ModerateCard/FeatureCommentMutation.ts @@ -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 & { storyID: string }, + input: MutationInput & { + storyID?: string | null; + siteID?: string | null; + section?: SectionFilter | null; + }, { uuidGenerator }: CoralContext ) => commitMutationPromiseNormalized(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) diff --git a/src/core/client/admin/components/ModerateCard/ModerateCardContainer.tsx b/src/core/client/admin/components/ModerateCard/ModerateCardContainer.tsx index 92dca23b4..7f9015ff8 100644 --- a/src/core/client/admin/components/ModerateCard/ModerateCardContainer.tsx +++ b/src/core/client/admin/components/ModerateCard/ModerateCardContainer.tsx @@ -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 = ({ 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 = ({ 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 = ({ 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]); diff --git a/src/core/client/admin/components/PaginatedSelect/PaginatedSelect.tsx b/src/core/client/admin/components/PaginatedSelect/PaginatedSelect.tsx index 7f33dc885..76ae5967a 100644 --- a/src/core/client/admin/components/PaginatedSelect/PaginatedSelect.tsx +++ b/src/core/client/admin/components/PaginatedSelect/PaginatedSelect.tsx @@ -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 = ({ - loading, - onLoadMore, - disableLoadMore, - hasMore, + onLoadMore = noop, + disableLoadMore = false, + hasMore = false, + loading = false, children, icon, selected, diff --git a/src/core/client/admin/helpers/getQueueConnection.ts b/src/core/client/admin/helpers/getQueueConnection.ts index 6c06de126..4a0f73bbe 100644 --- a/src/core/client/admin/helpers/getQueueConnection.ts +++ b/src/core/client/admin/helpers/getQueueConnection.ts @@ -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; diff --git a/src/core/client/admin/mutations/ApproveCommentMutation.ts b/src/core/client/admin/mutations/ApproveCommentMutation.ts index 1755a6ddf..a4f7b6be3 100644 --- a/src/core/client/admin/mutations/ApproveCommentMutation.ts +++ b/src/core/client/admin/mutations/ApproveCommentMutation.ts @@ -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 & { storyID?: string } + input: MutationInput & { + storyID?: string | null; + siteID?: string | null; + section?: SectionFilter | null; + } ) => commitMutationPromiseNormalized(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) diff --git a/src/core/client/admin/mutations/RejectCommentMutation.ts b/src/core/client/admin/mutations/RejectCommentMutation.ts index fc87b3a0a..04e3858a0 100644 --- a/src/core/client/admin/mutations/RejectCommentMutation.ts +++ b/src/core/client/admin/mutations/RejectCommentMutation.ts @@ -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 & { storyID?: string } + input: MutationInput & { + storyID?: string | null; + siteID?: string | null; + section?: SectionFilter | null; + } ) => commitMutationPromiseNormalized(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)!; diff --git a/src/core/client/admin/routes/Configure/sections/Auth/OIDCConfigContainer.tsx b/src/core/client/admin/routes/Configure/sections/Auth/OIDCConfigContainer.tsx index a9996d537..7c1c140df 100644 --- a/src/core/client/admin/routes/Configure/sections/Auth/OIDCConfigContainer.tsx +++ b/src/core/client/admin/routes/Configure/sections/Auth/OIDCConfigContainer.tsx @@ -39,7 +39,9 @@ class OIDCConfigContainer extends React.Component { } this.setState({ awaitingResponse: true }); try { - const config = await this.props.discoverOIDCConfiguration({ + const { + discoverOIDCConfiguration: config, + } = await this.props.discoverOIDCConfiguration({ issuer, }); if (config) { diff --git a/src/core/client/admin/routes/Moderate/Moderate.tsx b/src/core/client/admin/routes/Moderate/Moderate.tsx index 23bb05de1..1bf671044 100644 --- a/src/core/client/admin/routes/Moderate/Moderate.tsx +++ b/src/core/client/admin/routes/Moderate/Moderate.tsx @@ -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["story"] & PropTypesOf["story"]; - query: PropTypesOf["query"]; + query: PropTypesOf["query"] & + PropTypesOf["query"]; moderationQueues: PropTypesOf< typeof ModerateNavigationContainer >["moderationQueues"]; allStories: boolean; siteID: string | null; + section?: SectionFilter | null; settings: PropTypesOf["settings"] | null; children?: React.ReactNode; queueName: string; @@ -48,6 +52,7 @@ const Moderate: FunctionComponent = ({ routeParams, settings, siteID, + section, }) => { const [showHotkeysModal, setShowHotkeysModal] = useState(false); const closeModal = useCallback(() => { @@ -80,12 +85,20 @@ const Moderate: FunctionComponent = ({ siteID={routeParams.siteID || siteID || null} /> } + sectionSelector={ + + } />
diff --git a/src/core/client/admin/routes/Moderate/ModerateContainer.tsx b/src/core/client/admin/routes/Moderate/ModerateContainer.tsx index a2ac0cf06..c228f79bf 100644 --- a/src/core/client/admin/routes/Moderate/ModerateContainer.tsx +++ b/src/core/client/admin/routes/Moderate/ModerateContainer.tsx @@ -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 { ].find((name) => { return this.props.match.location.pathname.includes(name); }); + const { section } = parseModerationOptions(this.props.match); + if (!this.props.data) { return ( { 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 { 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({ $storyID: ID $includeStory: Boolean! $siteID: ID + $section: SectionFilter ) { settings { ...ModerateSearchBarContainer_settings @@ -87,19 +93,23 @@ const enhanced = withRouteConfig({ 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)); diff --git a/src/core/client/admin/routes/Moderate/ModerateNavigation/ModerateCountsCommentEnteredSubscription.tsx b/src/core/client/admin/routes/Moderate/ModerateNavigation/ModerateCountsCommentEnteredSubscription.tsx index 5ca712519..5fdee88b9 100644 --- a/src/core/client/admin/routes/Moderate/ModerateNavigation/ModerateCountsCommentEnteredSubscription.tsx +++ b/src/core/client/admin/routes/Moderate/ModerateNavigation/ModerateCountsCommentEnteredSubscription.tsx @@ -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 } } diff --git a/src/core/client/admin/routes/Moderate/ModerateNavigation/ModerateCountsCommentLeftSubscription.tsx b/src/core/client/admin/routes/Moderate/ModerateNavigation/ModerateCountsCommentLeftSubscription.tsx index 8a71c8f38..cf79f3d79 100644 --- a/src/core/client/admin/routes/Moderate/ModerateNavigation/ModerateCountsCommentLeftSubscription.tsx +++ b/src/core/client/admin/routes/Moderate/ModerateNavigation/ModerateCountsCommentLeftSubscription.tsx @@ -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 } } diff --git a/src/core/client/admin/routes/Moderate/ModerateNavigation/ModerateNavigationContainer.tsx b/src/core/client/admin/routes/Moderate/ModerateNavigation/ModerateNavigationContainer.tsx index c5bf15516..3340535a3 100644 --- a/src/core/client/admin/routes/Moderate/ModerateNavigation/ModerateNavigationContainer.tsx +++ b/src/core/client/admin/routes/Moderate/ModerateNavigation/ModerateNavigationContainer.tsx @@ -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) => { @@ -29,7 +31,7 @@ const ModerateNavigationContainer: React.FunctionComponent = (props) => { ); useEffect(() => { - if (!props.moderationQueues) { + if (!props.moderationQueues || props.section) { return; } const vars = { @@ -43,7 +45,12 @@ const ModerateNavigationContainer: React.FunctionComponent = (props) => { return () => { disposable.dispose(); }; - }, [Boolean(props.moderationQueues), props.story, props.siteID]); + }, [ + Boolean(props.moderationQueues), + props.story, + props.siteID, + props.section, + ]); if (!props.moderationQueues) { return ; @@ -55,6 +62,7 @@ const ModerateNavigationContainer: React.FunctionComponent = (props) => { pendingCount={props.moderationQueues.pending.count} storyID={props.story && props.story.id} siteID={props.siteID} + section={props.section} /> ); }; diff --git a/src/core/client/admin/routes/Moderate/ModerateNavigation/Navigation.tsx b/src/core/client/admin/routes/Moderate/ModerateNavigation/Navigation.tsx index 21797eee9..8ad116a30 100644 --- a/src/core/client/admin/routes/Moderate/ModerateNavigation/Navigation.tsx +++ b/src/core/client/admin/routes/Moderate/ModerateNavigation/Navigation.tsx @@ -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 = ({ 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]); diff --git a/src/core/client/admin/routes/Moderate/ModerateSearchBar/Bar.tsx b/src/core/client/admin/routes/Moderate/ModerateSearchBar/Bar.tsx index 58362ddd9..1d1028420 100644 --- a/src/core/client/admin/routes/Moderate/ModerateSearchBar/Bar.tsx +++ b/src/core/client/admin/routes/Moderate/ModerateSearchBar/Bar.tsx @@ -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 = ({ options, onSearch, siteSelector, + sectionSelector, multisite, }) => { const [focused, focusHandlers] = useFocus(); @@ -90,7 +92,7 @@ const Bar: FunctionComponent = ({ aria-expanded={focused} > - {multisite ? siteSelector : null} + {multisite ? siteSelector : sectionSelector}
{({ handleSubmit }) => ( + {/* If we're multisite, show the site name. */} + {multisite && {site.name}} + {/* If the section is available, show it on the story result */} + {!multisite && + featureFlags.includes(GQLFEATURE_FLAG.SECTIONS) && + (metadata?.section ? ( + {metadata.section} + ) : ( + + Uncategorized + + ))} + {/* If the author is available, show it on the story result */} + {metadata?.author && {metadata.author}} + + ); +} + 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: ( @@ -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={ - - {e.node.site.name} - {e.node.metadata && e.node.metadata.author} - - } + details={getStoryDetails(settings, e.node)} > {e.node.metadata && e.node.metadata.title} @@ -237,7 +270,11 @@ const ModerateSearchBarContainer: React.FunctionComponent = (props) => { const linkNavHandler = useLinkNavHandler(props.router); const contextOptions: PropTypesOf["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) => { ); } - const t = props.story.metadata && props.story.metadata.title; - if (t) { + + const title = props.story.metadata && props.story.metadata.title; + if (title) { return ( ); } + return ( = (props) => { > = ({ children, variant }) => ( + + {children} + +); + +export default OptionDetail; diff --git a/src/core/client/admin/routes/Moderate/ModerateSearchBar/SearchStoryFetch.ts b/src/core/client/admin/routes/Moderate/ModerateSearchBar/SearchStoryFetch.ts index 6ef76dab9..de8695e0b 100644 --- a/src/core/client/admin/routes/Moderate/ModerateSearchBar/SearchStoryFetch.ts +++ b/src/core/client/admin/routes/Moderate/ModerateSearchBar/SearchStoryFetch.ts @@ -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 } } diff --git a/src/core/client/admin/routes/Moderate/Queue/ApprovedQueueRoute.tsx b/src/core/client/admin/routes/Moderate/Queue/ApprovedQueueRoute.tsx index baade3218..4d51c3ecb 100644 --- a/src/core/client/admin/routes/Moderate/Queue/ApprovedQueueRoute.tsx +++ b/src/core/client/admin/routes/Moderate/Queue/ApprovedQueueRoute.tsx @@ -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 ( ); } diff --git a/src/core/client/admin/routes/Moderate/Queue/QueueCommentEnteredSubscription.tsx b/src/core/client/admin/routes/Moderate/Queue/QueueCommentEnteredSubscription.tsx index 98b875acb..0d2a22905 100644 --- a/src/core/client/admin/routes/Moderate/Queue/QueueCommentEnteredSubscription.tsx +++ b/src/core/client/admin/routes/Moderate/Queue/QueueCommentEnteredSubscription.tsx @@ -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, 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 ); }, }) diff --git a/src/core/client/admin/routes/Moderate/Queue/QueueCommentLeftSubscription.tsx b/src/core/client/admin/routes/Moderate/Queue/QueueCommentLeftSubscription.tsx index 249adb050..8b9f83958 100644 --- a/src/core/client/admin/routes/Moderate/Queue/QueueCommentLeftSubscription.tsx +++ b/src/core/client/admin/routes/Moderate/Queue/QueueCommentLeftSubscription.tsx @@ -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, 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 ); }, }) diff --git a/src/core/client/admin/routes/Moderate/Queue/QueueRoute.tsx b/src/core/client/admin/routes/Moderate/Queue/QueueRoute.tsx index cd54c0571..a5ad2397e 100644 --- a/src/core/client/admin/routes/Moderate/Queue/QueueRoute.tsx +++ b/src/core/client/admin/routes/Moderate/Queue/QueueRoute.tsx @@ -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) => { 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) => { 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.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 ( ); } const queue = data.moderationQueues[Object.keys(data.moderationQueues)[0]]; + return ( ); }, @@ -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) } diff --git a/src/core/client/admin/routes/Moderate/Queue/QueueViewNewMutation.tsx b/src/core/client/admin/routes/Moderate/Queue/QueueViewNewMutation.tsx index 3a8f2492c..09da23b5d 100644 --- a/src/core/client/admin/routes/Moderate/Queue/QueueViewNewMutation.tsx +++ b/src/core/client/admin/routes/Moderate/Queue/QueueViewNewMutation.tsx @@ -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; diff --git a/src/core/client/admin/routes/Moderate/Queue/RejectedQueueRoute.tsx b/src/core/client/admin/routes/Moderate/Queue/RejectedQueueRoute.tsx index 675b9067e..a7328b654 100644 --- a/src/core/client/admin/routes/Moderate/Queue/RejectedQueueRoute.tsx +++ b/src/core/client/admin/routes/Moderate/Queue/RejectedQueueRoute.tsx @@ -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 ( ); } diff --git a/src/core/client/admin/routes/Moderate/SectionSelector/SectionSelector.css b/src/core/client/admin/routes/Moderate/SectionSelector/SectionSelector.css new file mode 100644 index 000000000..9756af271 --- /dev/null +++ b/src/core/client/admin/routes/Moderate/SectionSelector/SectionSelector.css @@ -0,0 +1,8 @@ +.button { + height: calc(4 * var(--mini-unit)); +} + +.buttonText { + overflow-x: hidden; + text-overflow: ellipsis; +} diff --git a/src/core/client/admin/routes/Moderate/SectionSelector/SectionSelector.tsx b/src/core/client/admin/routes/Moderate/SectionSelector/SectionSelector.tsx new file mode 100644 index 000000000..403b73b43 --- /dev/null +++ b/src/core/client/admin/routes/Moderate/SectionSelector/SectionSelector.tsx @@ -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; + section?: SectionFilter | null; + queueName: QUEUE_NAME; +} + +const SelectedSection: FunctionComponent<{ + section?: SectionFilter | null; +}> = ({ section }) => { + if (!section) { + return ( + + All Sections + + ); + } + + if (!section.name) { + return ( + + Uncategorized + + ); + } + + return {section.name}; +}; + +const SectionSelector: FunctionComponent = ({ + sections, + section, + queueName: queue, +}) => { + return ( + } + > + + + {sections.length > 0 && } + {sections.map((name) => ( + + ))} + + ); +}; + +export default SectionSelector; diff --git a/src/core/client/admin/routes/Moderate/SectionSelector/SectionSelectorContainer.tsx b/src/core/client/admin/routes/Moderate/SectionSelector/SectionSelectorContainer.tsx new file mode 100644 index 000000000..865c8b192 --- /dev/null +++ b/src/core/client/admin/routes/Moderate/SectionSelector/SectionSelectorContainer.tsx @@ -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 = ({ + query, + section, + queueName, +}) => { + // FEATURE_FLAG:SECTIONS + if ( + !query || + !query.settings.featureFlags.includes(GQLFEATURE_FLAG.SECTIONS) || + !query.sections + ) { + return null; + } + + return ( + + ); +}; + +const enhanced = withFragmentContainer({ + query: graphql` + fragment SectionSelectorContainer_query on Query { + sections + settings { + # FEATURE_FLAG:SECTIONS + featureFlags + } + } + `, +})(SectionSelectorContainer); + +export default enhanced; diff --git a/src/core/client/admin/routes/Moderate/SectionSelector/SectionSelectorSection.css b/src/core/client/admin/routes/Moderate/SectionSelector/SectionSelectorSection.css new file mode 100644 index 000000000..301ca8e60 --- /dev/null +++ b/src/core/client/admin/routes/Moderate/SectionSelector/SectionSelectorSection.css @@ -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); +} diff --git a/src/core/client/admin/routes/Moderate/SectionSelector/SectionSelectorSection.tsx b/src/core/client/admin/routes/Moderate/SectionSelector/SectionSelectorSection.tsx new file mode 100644 index 000000000..fafbece25 --- /dev/null +++ b/src/core/client/admin/routes/Moderate/SectionSelector/SectionSelectorSection.tsx @@ -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 = ({ + section, + link, + active, +}) => { + return ( + + {!section && ( + + All Sections + + )} + {section && !section.name && ( + + Uncategorized + + )} + {section && section.name && {section.name}} + + ); +}; + +export default SectionSelectorSection; diff --git a/src/core/client/admin/routes/Moderate/SectionSelector/index.ts b/src/core/client/admin/routes/Moderate/SectionSelector/index.ts new file mode 100644 index 000000000..7de5f8156 --- /dev/null +++ b/src/core/client/admin/routes/Moderate/SectionSelector/index.ts @@ -0,0 +1 @@ +export { default as SectionSelectorContainer } from "./SectionSelectorContainer"; diff --git a/src/core/client/admin/routes/Moderate/SiteSelector/SiteSelectorSite.css b/src/core/client/admin/routes/Moderate/SiteSelector/SiteSelectorSite.css index 761acd473..301ca8e60 100644 --- a/src/core/client/admin/routes/Moderate/SiteSelector/SiteSelectorSite.css +++ b/src/core/client/admin/routes/Moderate/SiteSelector/SiteSelectorSite.css @@ -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); } diff --git a/src/core/client/admin/routes/Moderate/__snapshots__/Moderate.spec.tsx.snap b/src/core/client/admin/routes/Moderate/__snapshots__/Moderate.spec.tsx.snap index 8aa999245..6783bdad0 100644 --- a/src/core/client/admin/routes/Moderate/__snapshots__/Moderate.spec.tsx.snap +++ b/src/core/client/admin/routes/Moderate/__snapshots__/Moderate.spec.tsx.snap @@ -6,6 +6,12 @@ exports[`renders correctly 1`] = ` > + } settings={ Object { "multisite": false, diff --git a/src/core/client/admin/test/moderate/rejectedQueue.spec.tsx b/src/core/client/admin/test/moderate/rejectedQueue.spec.tsx index 7c5415a55..a845aa9c0 100644 --- a/src/core/client/admin/test/moderate/rejectedQueue.spec.tsx +++ b/src/core/client/admin/test/moderate/rejectedQueue.spec.tsx @@ -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: [ diff --git a/src/core/client/framework/helpers/getModerationLink.ts b/src/core/client/framework/helpers/getModerationLink.ts index c283195a4..ec53944ee 100644 --- a/src/core/client/framework/helpers/getModerationLink.ts +++ b/src/core/client/framework/helpers/getModerationLink.ts @@ -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; } diff --git a/src/core/client/framework/helpers/parseModerationOptions.ts b/src/core/client/framework/helpers/parseModerationOptions.ts new file mode 100644 index 000000000..b5b67e0ce --- /dev/null +++ b/src/core/client/framework/helpers/parseModerationOptions.ts @@ -0,0 +1,30 @@ +import { Match } from "found"; + +import { Options } from "./getModerationLink"; + +export default function parseModerationOptions( + match: Match +): Omit { + 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; +} diff --git a/src/core/client/framework/lib/network/createManagedSubscriptionClient.ts b/src/core/client/framework/lib/network/createManagedSubscriptionClient.ts index 06d57ba78..93a89c9ee 100644 --- a/src/core/client/framework/lib/network/createManagedSubscriptionClient.ts +++ b/src/core/client/framework/lib/network/createManagedSubscriptionClient.ts @@ -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) + ); + } }, }; }; diff --git a/src/core/client/framework/lib/network/createNetwork.ts b/src/core/client/framework/lib/network/createNetwork.ts index f85f82fa9..9f1c60f7c 100644 --- a/src/core/client/framework/lib/network/createNetwork.ts +++ b/src/core/client/framework/lib/network/createNetwork.ts @@ -23,7 +23,7 @@ function createSubscriptionFunction( ): SubscribeFunction { const fn: SubscribeFunction = (operation, variables, cacheConfig) => { return Observable.create((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; diff --git a/src/core/client/framework/lib/relay/fetch.tsx b/src/core/client/framework/lib/relay/fetch.tsx index d19fd0fae..de2356e47 100644 --- a/src/core/client/framework/lib/relay/fetch.tsx +++ b/src/core/client/framework/lib/relay/fetch.tsx @@ -14,7 +14,6 @@ import { } from "relay-runtime"; import { CoralContext, useCoralContext, withContext } from "../bootstrap"; -import extractPayload from "./extractPayload"; export interface Fetch { name: N; @@ -49,14 +48,8 @@ export async function fetchQuery( taggedNode: GraphQLTaggedNode, variables: Variables, cacheConfig?: CacheConfig -): Promise { - const result = await relayFetchQuery( - environment, - taggedNode, - variables, - cacheConfig - ); - return extractPayload(result); +): Promise { + return relayFetchQuery(environment, taggedNode, variables, cacheConfig); } /** diff --git a/src/core/client/stream/tabs/Comments/Stream/AllCommentsTab/AllCommentsTabContainer.tsx b/src/core/client/stream/tabs/Comments/Stream/AllCommentsTab/AllCommentsTabContainer.tsx index e67ce5021..42d42b395 100644 --- a/src/core/client/stream/tabs/Comments/Stream/AllCommentsTab/AllCommentsTabContainer.tsx +++ b/src/core/client/stream/tabs/Comments/Stream/AllCommentsTab/AllCommentsTabContainer.tsx @@ -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) => { // 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, diff --git a/src/core/common/section.ts b/src/core/common/section.ts new file mode 100644 index 000000000..d17f82aa1 --- /dev/null +++ b/src/core/common/section.ts @@ -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; +} diff --git a/src/core/server/graph/loaders/Comments.ts b/src/core/server/graph/loaders/Comments.ts index 8a9f3e728..03d579d1b 100644 --- a/src/core/server/graph/loaders/Comments.ts +++ b/src/core/server/graph/loaders/Comments.ts @@ -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, + 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( (commentIDs: string[]) => { diff --git a/src/core/server/graph/loaders/Stories.ts b/src/core/server/graph/loaders/Stories.ts index 28d1a62f1..24e5cc7fb 100644 --- a/src/core/server/graph/loaders/Stories.ts +++ b/src/core/server/graph/loaders/Stories.ts @@ -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( (ids) => retrieveManyStories(ctx.mongo, ctx.tenant.id, ids), { diff --git a/src/core/server/graph/resolvers/ModerationQueues.ts b/src/core/server/graph/resolvers/ModerationQueues.ts index 79eb4bcf9..9771fe0c5 100644 --- a/src/core/server/graph/resolvers/ModerationQueues.ts +++ b/src/core/server/graph/resolvers/ModerationQueues.ts @@ -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; - 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 => ({ + 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) { diff --git a/src/core/server/graph/resolvers/Query.ts b/src/core/server/graph/resolvers/Query.ts index 8bc6ef986..96cd160b8 100644 --- a/src/core/server/graph/resolvers/Query.ts +++ b/src/core/server/graph/resolvers/Query.ts @@ -24,6 +24,7 @@ export const Query: Required> = { 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), diff --git a/src/core/server/graph/resolvers/Subscription/commentEnteredModerationQueue.ts b/src/core/server/graph/resolvers/Subscription/commentEnteredModerationQueue.ts index 2d83646a1..37f58d5cb 100644 --- a/src/core/server/graph/resolvers/Subscription/commentEnteredModerationQueue.ts +++ b/src/core/server/graph/resolvers/Subscription/commentEnteredModerationQueue.ts @@ -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 = 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; }, } diff --git a/src/core/server/graph/resolvers/Subscription/commentLeftModerationQueue.ts b/src/core/server/graph/resolvers/Subscription/commentLeftModerationQueue.ts index f56654de1..323b77446 100644 --- a/src/core/server/graph/resolvers/Subscription/commentLeftModerationQueue.ts +++ b/src/core/server/graph/resolvers/Subscription/commentLeftModerationQueue.ts @@ -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 = 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; }, } diff --git a/src/core/server/graph/schema/schema.graphql b/src/core/server/graph/schema/schema.graphql index 3377cac14..062f22dc8 100644 --- a/src/core/server/graph/schema/schema.graphql +++ b/src/core/server/graph/schema/schema.graphql @@ -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]) diff --git a/src/core/server/models/comment/comment.ts b/src/core/server/models/comment/comment.ts index b64d93764..81c28d80b 100644 --- a/src/core/server/models/comment/comment.ts +++ b/src/core/server/models/comment/comment.ts @@ -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. diff --git a/src/core/server/models/story/index.ts b/src/core/server/models/story/index.ts index 24779814d..d7b73e557 100644 --- a/src/core/server/models/story/index.ts +++ b/src/core/server/models/story/index.ts @@ -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 { + const results: Array = 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[]; +} diff --git a/src/core/server/services/events/comments.ts b/src/core/server/services/events/comments.ts index d547d1b81..192e9ed7b 100644 --- a/src/core/server/services/events/comments.ts +++ b/src/core/server/services/events/comments.ts @@ -90,19 +90,23 @@ export async function publishCommentFeatured( export async function publishModerationQueueChanges( broker: CoralEventPublisherBroker, moderationQueue: Pick, - comment: Pick + comment: Pick ) { 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, }); } } diff --git a/src/core/server/services/stories/index.ts b/src/core/server/services/stories/index.ts index 8e0109366..3ed6ca62f 100644 --- a/src/core/server/services/stories/index.ts +++ b/src/core/server/services/stories/index.ts @@ -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); +} diff --git a/src/core/server/stacks/createComment.ts b/src/core/server/stacks/createComment.ts index a7b82a626..048cb3e6e 100644 --- a/src/core/server/stacks/createComment.ts +++ b/src/core/server/stacks/createComment.ts @@ -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, diff --git a/src/locales/en-US/admin.ftl b/src/locales/en-US/admin.ftl index 6606a0545..17796209d 100644 --- a/src/locales/en-US/admin.ftl +++ b/src/locales/en-US/admin.ftl @@ -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