Support multisite (#2799)

* resovle import error by removing useContext from ui component

* update snaps

* create useUIContext hook

* add site and community models

* create sites and communities on install

* add site name to install wizard

* add site id to stories

* pass site id to stream query in embed

* fix spec

* add sites query

* list sites in organization config

* add route for new sites

* add create site mutation

* view and update sites

* show embed codes for sites

* add site id to comments

* allow filtering moderation queues by site id

* add site selector to queue

* move sites config routes

* Revert "move sites config routes"

This reverts commit 4ed5345d3e1df6263f8390b64214956c43c4d8cd.

* update sites routes

* show site name in moderate card

* remove site selector from queue selector

* style create site form

* edit site form

* clean up ts

* move :storyID paths to /storeis/:storyID

* make queues respect site id

* add site switcher

* styles for site selector

* add global notifications

* style app notifications

* clear notifications after x miliseconds

* use notification component in add site form

* fix types

* make notifications dismissable

* dismiss site created notification

* remove button letter spacing if lowercase

* filter stories by site in search

* add site name to story search results

* add site column to stories table

* filter stories table by site

* make sure notification displays after site creation

* paginate sites table

* paginage site selector

* add paginated site filter to stories table

* fix merge conflicts

* sort by createdAt

* default to 20 sites

* delete comments

* add translation tags

* make site ID not mandatory

* Fix tests and specs

* only include site id in embed code for multisite

* update tenant cache when adding first site

* only show site selector if multiple sites

* use story url instead of site id for story upsert

* update snaps

* make ui conditional on multisite

* update snaps and remove unnecessary site ID

* sloppily calculate counts for filtered queues

* get origins of allowed domains

* add migration

* enable migration

* only show permitted domains if mulltisite is false

* remove site id from embed code

* update snaps

* undo updates to singletonresolver

* remove refernces to communities

* fix mints

* remove community reference

* update copy in installation

* use sites services in installer

* remove unused loader

* correct error text for useNotification

* order sites by name

* make multisite a computed property

* use map/filter instead of for/of for url origins

* add missing/incorrect translations

* remove references to siteID

* remove references to tenant isURLpermitted

* add comments to schema updates

* simplify filtering stories by site

* remove domains config from advanced

* fix: adjusted CSP header generation

* add migration to create indexes on site

* clear notifications on navigate

* remove count for filtering by site

* throw duplicate error for allowed domains

* handle errors for create/update sites

* remove contacturl and contactemail from sites

* fix types for counts

* sort imports

* ensure props get passed down to link version of button component

* add url and email fields back into organization config

* sort imports

* fix moderation queues resolver types

* fix appearance of sites dropdown

* add status role to notificaiton

* remove duplicate layout file

* fix: rename allowedDomains -> allowdOrigins

* move Link conditional from button to basebutton component

* fix merge conflict

* fix mutation optimistic response

* make sure to prop gets passed to link

* change labels on install steps

* show story's site in site selector when moderating by story

* feat: support site counting

* update snap

* remove multisite from settings

* move paginated select to admin/components

* fix circular import errors

* remove uicontext component from v2 timestamp

Co-authored-by: Wyatt Johnson <accounts+github@wyattjoh.ca>
This commit is contained in:
Tessa Thornton
2020-02-12 09:22:07 -05:00
committed by GitHub
co-authored by Wyatt Johnson
parent 014aa2d86a
commit 707d65a119
210 changed files with 4695 additions and 1632 deletions
-5
View File
@@ -501,11 +501,6 @@ export default function createWebpackConfig(
exclude: [/\.(js|ts|tsx)$/, /\.html$/, /\.json$/],
loader: require.resolve("file-loader"),
options: {
// Because the resources loaded via CSS can sometimes be loaded
// directly from a CSS file, this will ensure that they are
// relative to those referencing files.
publicPath: (loaderPublicPath: string) =>
"../../" + loaderPublicPath,
name: isProduction
? "assets/media/[name].[hash:8].[ext]"
: "assets/media/[name].[ext]",
@@ -0,0 +1,45 @@
import React, { createContext, ReactNode, useMemo, useReducer } from "react";
interface State {
message: ReactNode | null;
visible: boolean;
}
type Action =
| {
type: "SET_MESSAGE";
} & State
| {
type: "CLEAR_MESSAGE";
};
// TODO (tessalt) can't figure out types for this
const NotificationContext = createContext<State | any>({
message: null,
visible: false,
});
function notificationReducer(state: State, action: Action): State {
switch (action.type) {
case "SET_MESSAGE": {
return { message: action.message, visible: true };
}
case "CLEAR_MESSAGE": {
return { message: null, visible: false };
}
default: {
throw new Error("unsupported action");
}
}
}
function NotificationProvider(props: any) {
const [state, dispatch] = useReducer(notificationReducer, {
message: null,
visible: false,
});
const value = useMemo(() => [state, dispatch], [state]);
return <NotificationContext.Provider value={value} {...props} />;
}
export { NotificationProvider, NotificationContext };
@@ -0,0 +1,12 @@
import React, { FunctionComponent } from "react";
import useNotification from "./useNotification";
const NotificationContainer: FunctionComponent<{}> = () => {
const { state } = useNotification();
if (!state.visible) {
return null;
}
return <div role="status">{state.message}</div>;
};
export default NotificationContainer;
@@ -0,0 +1,3 @@
export { NotificationProvider } from "./GlobalNotificationContext";
export { default as NotificationContainer } from "./NotificationContainer";
export { default as useNotification } from "./useNotification";
@@ -0,0 +1,39 @@
import { useRouter } from "found";
import { ReactNode, useContext } from "react";
import { NotificationContext } from "./GlobalNotificationContext";
function useNotification() {
const context = useContext(NotificationContext);
if (!context) {
throw new Error(
"useNotification must be used within a NotificationProvider"
);
}
const [state, dispatch] = context;
const { router } = useRouter();
const setMessage = (message: ReactNode, timeout?: number) => {
dispatch({ type: "SET_MESSAGE", message });
if (timeout) {
setTimeout(() => {
dispatch({ type: "CLEAR_MESSAGE" });
}, timeout);
}
router.addTransitionHook(() => {
dispatch({ type: "CLEAR_MESSAGE" });
return true;
});
};
const clearMessage = () => dispatch({ type: "CLEAR_MESSAGE" });
return {
state,
dispatch,
setMessage,
clearMessage,
};
}
export default useNotification;
+8 -1
View File
@@ -5,6 +5,10 @@ import { LogoHorizontal } from "coral-ui/components";
import { AppBar, Begin, Divider, End } from "coral-ui/components/v2/AppBar";
import { DecisionHistoryButton } from "./DecisionHistory";
import {
NotificationContainer,
NotificationProvider,
} from "./GlobalNotification";
import NavigationContainer from "./Navigation";
import UserMenuContainer from "./UserMenu";
import Version from "./Version";
@@ -32,7 +36,10 @@ const Main: FunctionComponent<Props> = ({ children, viewer }) => (
<UserMenuContainer viewer={viewer} />
</End>
</AppBar>
{children}
<NotificationProvider>
<NotificationContainer />
{children}
</NotificationProvider>
<Version />
</div>
);
@@ -28,7 +28,10 @@ exports[`renders correctly 1`] = `
/>
</withPropsOnChange(End)>
</withPropsOnChange(AppBar)>
child
<NotificationProvider>
<NotificationContainer />
child
</NotificationProvider>
<Version />
</div>
`;
@@ -142,13 +142,23 @@ $moderateCardLinkTextColor: var(--v2-colors-teal-700);
.storyTitle {
color: $moderateCardStoryTitleColor;
}
.commentOn {
font-size: var(--v2-font-size-2);
font-weight: var(--v2-font-weight-primary-regular);
font-family: var(--v2-font-family-primary);
line-height: var(--v2-line-height-reset);
margin-bottom: var(--v2-spacing-1);
}
.siteName {
font-weight: var(--v2-font-weight-primary-regular);
}
.storyTitle {
font-weight: var(--v2-font-weight-primary-semi-bold);
}
.borderless {
border-width: 0px;
box-shadow: none;
@@ -23,6 +23,7 @@ const baseProps: PropTypesOf<typeof ModerateCardN> = {
viewContextHref: "http://localhost/comment",
suspectWords: ["suspect"],
bannedWords: ["banned"],
siteName: null,
onApprove: noop,
onReject: noop,
onFeature: noop,
@@ -16,6 +16,7 @@ import {
Card,
Flex,
HorizontalGutter,
Icon,
TextLink,
Timestamp,
} from "coral-ui/components/v2";
@@ -52,6 +53,7 @@ interface Props {
showStory: boolean;
storyTitle?: React.ReactNode;
storyHref?: string;
siteName: string | null;
onModerateStory?: React.EventHandler<React.MouseEvent>;
onApprove: () => void;
onReject: () => void;
@@ -96,6 +98,7 @@ const ModerateCard: FunctionComponent<Props> = ({
storyTitle,
storyHref,
onModerateStory,
siteName,
moderatedBy,
selected,
onFocusOrClick,
@@ -250,7 +253,15 @@ const ModerateCard: FunctionComponent<Props> = ({
</Localized>
<span>:</span>
</div>
<div className={styles.storyTitle}>{storyTitle}</div>
<div className={styles.commentOn}>
{siteName && (
<span className={styles.siteName}>
{siteName}
<Icon>keyboard_arrow_right</Icon>
</span>
)}
<span className={styles.storyTitle}>{storyTitle}</span>
</div>
<div>
<Localized id="moderate-comment-moderateStory">
<TextLink
@@ -4,12 +4,12 @@ import { graphql } from "react-relay";
import NotAvailable from "coral-admin/components/NotAvailable";
import BanModal from "coral-admin/components/UserStatus/BanModal";
import { getModerationLink } from "coral-admin/helpers";
import {
ApproveCommentMutation,
RejectCommentMutation,
} from "coral-admin/mutations";
import FadeInTransition from "coral-framework/components/FadeInTransition";
import { getModerationLink } from "coral-framework/helpers";
import {
MutationProp,
withFragmentContainer,
@@ -162,7 +162,7 @@ const ModerateCardContainer: FunctionComponent<Props> = ({
const handleModerateStory = useCallback(
(e: React.MouseEvent) => {
router.push(getModerationLink("default", comment.story.id));
router.push(getModerationLink({ storyID: comment.story.id }));
if (e.preventDefault) {
e.preventDefault();
}
@@ -227,6 +227,7 @@ const ModerateCardContainer: FunctionComponent<Props> = ({
selected={selected}
selectPrev={selectPrev}
selectNext={selectNext}
siteName={settings.multisite ? comment.site.name : null}
onBan={openBanModal}
moderatedBy={
<ModeratedByContainer
@@ -242,7 +243,7 @@ const ModerateCardContainer: FunctionComponent<Props> = ({
<NotAvailable />
)
}
storyHref={getModerationLink("default", comment.story.id)}
storyHref={getModerationLink({ storyID: comment.story.id })}
onModerateStory={handleModerateStory}
mini={mini}
hideUsername={hideUsername}
@@ -300,6 +301,10 @@ const enhanced = withFragmentContainer<Props>({
title
}
}
site {
id
name
}
permalink
enteredLive
deleted
@@ -314,6 +319,7 @@ const enhanced = withFragmentContainer<Props>({
banned
suspect
}
multisite
...MarkersContainer_settings
}
`,
@@ -761,9 +761,13 @@ exports[`renders story info 1`] = `
</span>
</div>
<div
className="ModerateCard-storyTitle"
className="ModerateCard-commentOn"
>
Cancer cured!
<span
className="ModerateCard-storyTitle"
>
Cancer cured!
</span>
</div>
<div>
<Localized
@@ -0,0 +1,45 @@
.root {
margin-top: var(--v2-spacing-2);
}
.dropdown {
max-height: 15em;
width: calc(20 * var(--mini-unit));
overflow: auto;
}
.wrapper {
overflow-x: hidden;
/* adjust for button line-height being > 1 */
margin-top: -2px;
}
.button {
color: var(--v2-colors-mono-500) !important;
border-width: 0;
width: calc(20 * var(--mini-unit));
margin-right: calc(var(--v2-spacing-1) / 2);
font-size: var(--v2-font-size-3);
line-height: var(--v2-line-height-min);
justify-content: space-between;
}
.buttonIconLeft {
width: 20px;
margin-right: calc(var(--v2-spacing-1) / 2);
}
.buttonIconRight {
width: 16px;
}
.buttonIconLeft,
.buttonIconRight {
/* adjust for button line-height being > 1 */
margin-top: -2px;
}
.buttonText {
overflow-x: hidden;
text-overflow: ellipsis;
}
@@ -0,0 +1,98 @@
import cn from "classnames";
import React, { FunctionComponent } from "react";
import AutoLoadMore from "coral-admin/components/AutoLoadMore";
import { IntersectionProvider } from "coral-framework/lib/intersection";
import {
Button,
ButtonIcon,
ClickOutside,
Dropdown,
Flex,
Popover,
Spinner,
} from "coral-ui/components/v2";
import styles from "./PaginatedSelect.css";
interface Props {
onLoadMore: () => void;
icon?: string;
hasMore: boolean;
disableLoadMore: boolean;
loading: boolean;
selected: React.ReactNode;
className?: string;
}
const PaginatedSelect: FunctionComponent<Props> = ({
loading,
onLoadMore,
disableLoadMore,
hasMore,
children,
icon,
selected,
className,
}) => {
return (
<Popover
id=""
placement="bottom-end"
modifiers={{ arrow: { enabled: false }, offset: { offset: "0, 4" } }}
body={({ toggleVisibility }) => (
<ClickOutside onClickOutside={toggleVisibility}>
<IntersectionProvider>
<Dropdown className={styles.dropdown}>
{children}
{loading && (
<Flex justifyContent="center">
<Spinner />
</Flex>
)}
{hasMore && (
<Flex justifyContent="center">
<AutoLoadMore
disableLoadMore={disableLoadMore}
onLoadMore={onLoadMore}
/>
</Flex>
)}
</Dropdown>
</IntersectionProvider>
</ClickOutside>
)}
>
{({ toggleVisibility, ref, visible }) => (
<Button
className={cn(styles.button, className)}
variant="flat"
adornmentLeft
color="mono"
onClick={toggleVisibility}
ref={ref}
uppercase={false}
>
{icon && (
<ButtonIcon className={styles.buttonIconLeft}>{icon}</ButtonIcon>
)}
<Flex alignItems="center" className={styles.wrapper}>
{selected}
</Flex>
{!visible && (
<ButtonIcon className={styles.buttonIconRight}>
keyboard_arrow_down
</ButtonIcon>
)}
{visible && (
<ButtonIcon className={styles.buttonIconRight}>
keyboard_arrow_up
</ButtonIcon>
)}
</Button>
)}
</Popover>
);
};
export default PaginatedSelect;
@@ -0,0 +1 @@
export { default } from "./PaginatedSelect";
@@ -1,10 +0,0 @@
const basePath = "/admin/moderate";
export default function getModerationLink(
queue?: "default" | "reported" | "pending" | "unmoderated" | "rejected",
storyID?: string | null
) {
const queuePart = queue && queue !== "default" ? `/${queue}` : "";
const storyPart = storyID ? `/${encodeURIComponent(storyID)}` : "";
return `${basePath}${queuePart}${storyPart}`;
}
@@ -13,16 +13,21 @@ import {
export default function getQueueConnection(
store: RecordSourceSelectorProxy | RecordSourceProxy,
queue: GQLMODERATION_QUEUE_RL | "REJECTED",
storyID?: string | null
storyID?: string | null,
siteID?: string | null
): RecordProxy | null | undefined {
const root = store.getRoot();
if (queue === "REJECTED") {
return ConnectionHandler.getConnection(root, "RejectedQueue_comments", {
status: GQLCOMMENT_STATUS.REJECTED,
storyID,
siteID,
});
}
const queuesRecord = root.getLinkedRecord("moderationQueues", { storyID })!;
const queuesRecord = root.getLinkedRecord("moderationQueues", {
storyID,
siteID,
})!;
if (!queuesRecord) {
return undefined;
}
-1
View File
@@ -1,2 +1 @@
export { default as getQueueConnection } from "./getQueueConnection";
export { default as getModerationLink } from "./getModerationLink";
@@ -31,6 +31,7 @@ type Local {
redirectPath: String
authView: View
authError: String
siteID: String
}
extend type Query {
+45 -5
View File
@@ -18,6 +18,9 @@ import {
SlackConfigRoute,
WordListConfigRoute,
} from "./routes/Configure/sections";
import { Sites } from "./routes/Configure/sections/Sites";
import AddSiteRoute from "./routes/Configure/sections/Sites/AddSiteRoute";
import SiteRoute from "./routes/Configure/sections/Sites/SiteRoute";
import ForgotPasswordRoute from "./routes/ForgotPassword";
import InviteRoute from "./routes/Invite";
import LoginRoute from "./routes/Login";
@@ -45,17 +48,49 @@ export default makeRouteConfig(
<Route path="moderate" {...ModerateRoute.routeConfig}>
<Redirect from="/" to="/admin/moderate/reported" />
<Route path="reported" {...ReportedQueueRoute.routeConfig} />
<Route path="reported/:storyID" {...ReportedQueueRoute.routeConfig} />
<Route
path="reported/stories/:storyID"
{...ReportedQueueRoute.routeConfig}
/>
<Route
path="reported/sites/:siteID"
{...ReportedQueueRoute.routeConfig}
/>
<Route path="pending" {...PendingQueueRoute.routeConfig} />
<Route path="pending/:storyID" {...PendingQueueRoute.routeConfig} />
<Route
path="pending/stories/:storyID"
{...PendingQueueRoute.routeConfig}
/>
<Route
path="pending/sites/:siteID"
{...PendingQueueRoute.routeConfig}
/>
<Route path="unmoderated" {...UnmoderatedQueueRoute.routeConfig} />
<Route
path="unmoderated/:storyID"
path="unmoderated/stories/:storyID"
{...UnmoderatedQueueRoute.routeConfig}
/>
<Route
path="unmoderated/sites/:siteID"
{...UnmoderatedQueueRoute.routeConfig}
/>
<Route path="rejected" {...RejectedQueueRoute.routeConfig} />
<Route path="rejected/:storyID" {...RejectedQueueRoute.routeConfig} />
<Redirect from=":storyID" to="/admin/moderate/reported/:storyID" />
<Route
path="rejected/stories/:storyID"
{...RejectedQueueRoute.routeConfig}
/>
<Route
path="rejected/sites/:siteID"
{...RejectedQueueRoute.routeConfig}
/>
<Redirect
from="stories/:storyID"
to="/admin/moderate/reported/stories/:storyID"
/>
<Redirect
from="sites/:siteID"
to="/admin/moderate/reported/sites/:siteID"
/>
</Route>
<Route path="stories" {...StoriesRoute.routeConfig} />
<Route path="community" {...CommunityRoute.routeConfig} />
@@ -78,6 +113,11 @@ export default makeRouteConfig(
<Route path="email" {...EmailConfigRoute.routeConfig} />
<Route path="slack" {...SlackConfigRoute.routeConfig} />
</Route>
<Route path="configure/organization/sites" Component={Sites}>
<Redirect from="/" to="/admin/configure/organization/sites/new" />
<Route path="new" {...AddSiteRoute.routeConfig} />
<Route path=":siteID" {...SiteRoute.routeConfig} />
</Route>
</Route>
</Route>
</Route>
@@ -7,10 +7,9 @@ import { Form, FormSpy } from "react-final-form";
import MainLayout from "coral-admin/components/MainLayout";
import { Button, CallOut, HorizontalGutter } from "coral-ui/components/v2";
import ConfigureLinks from "./ConfigureLinks";
import Layout from "./Layout";
import Link from "./Link";
import Main from "./Main";
import Navigation from "./Navigation";
import SideBar from "./SideBar";
interface Props {
@@ -32,34 +31,7 @@ const Configure: FunctionComponent<Props> = ({
<Layout>
<SideBar>
<HorizontalGutter size="double">
<Navigation>
<Localized id="configure-sideBarNavigation-general">
<Link to="/admin/configure/general">General</Link>
</Localized>
<Localized id="configure-sideBarNavigation-organization">
<Link to="/admin/configure/organization">Organization</Link>
</Localized>
<Localized id="configure-sideBarNavigation-moderation">
<Link to="/admin/configure/moderation">Moderation</Link>
</Localized>
<Localized id="configure-sideBarNavigation-bannedAndSuspectWords">
<Link to="/admin/configure/wordList">
Banned and Suspect Words
</Link>
</Localized>
<Localized id="configure-sideBarNavigation-authentication">
<Link to="/admin/configure/auth">Authentication</Link>
</Localized>
<Localized id="configure-sideBarNavigation-email">
<Link to="/admin/configure/email">Email</Link>
</Localized>
<Localized id="configure-sideBarNavigation-slack">
<Link to="/admin/configure/slack">Slack</Link>
</Localized>
<Localized id="configure-sideBarNavigation-advanced">
<Link to="/admin/configure/advanced">Advanced</Link>
</Localized>
</Navigation>
<ConfigureLinks />
</HorizontalGutter>
<HorizontalGutter size="double">
<Localized id="configure-sideBar-saveChanges">
@@ -0,0 +1,38 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent } from "react";
import Link from "./Link";
import Navigation from "./Navigation";
const ConfigureLinks: FunctionComponent<{}> = () => {
return (
<Navigation>
<Localized id="configure-sideBarNavigation-general">
<Link to="/admin/configure/general">General</Link>
</Localized>
<Localized id="configure-sideBarNavigation-organization">
<Link to="/admin/configure/organization">Organization</Link>
</Localized>
<Localized id="configure-sideBarNavigation-moderation">
<Link to="/admin/configure/moderation">Moderation</Link>
</Localized>
<Localized id="configure-sideBarNavigation-bannedAndSuspectWords">
<Link to="/admin/configure/wordList">Banned and Suspect Words</Link>
</Localized>
<Localized id="configure-sideBarNavigation-authentication">
<Link to="/admin/configure/auth">Authentication</Link>
</Localized>
<Localized id="configure-sideBarNavigation-email">
<Link to="/admin/configure/email">Email</Link>
</Localized>
<Localized id="configure-sideBarNavigation-slack">
<Link to="/admin/configure/slack">Slack</Link>
</Localized>
<Localized id="configure-sideBarNavigation-advanced">
<Link to="/admin/configure/advanced">Advanced</Link>
</Localized>
</Navigation>
);
};
export default ConfigureLinks;
@@ -12,8 +12,6 @@ import { AdvancedConfigContainer_settings } from "coral-admin/__generated__/Adva
import CommentStreamLiveUpdatesContainer from "./CommentStreamLiveUpdatesContainer";
import CustomCSSConfig from "./CustomCSSConfig";
import EmbedCodeContainer from "./EmbedCodeContainer";
import PermittedDomainsConfig from "./PermittedDomainsConfig";
import StoryCreationConfig from "./StoryCreationConfig";
interface Props {
@@ -29,13 +27,11 @@ const AdvancedConfigContainer: React.FunctionComponent<Props> = ({
useMemo(() => form.initialize(purgeMetadata(settings)), []);
return (
<HorizontalGutter size="double" data-testid="configure-advancedContainer">
<EmbedCodeContainer settings={settings} />
<CustomCSSConfig disabled={submitting} />
<CommentStreamLiveUpdatesContainer
disabled={submitting}
settings={settings}
/>
<PermittedDomainsConfig disabled={submitting} />
<StoryCreationConfig disabled={submitting} />
</HorizontalGutter>
);
@@ -45,11 +41,8 @@ const enhanced = withFragmentContainer<Props>({
settings: graphql`
fragment AdvancedConfigContainer_settings on Settings {
...CustomCSSConfig_formValues @relay(mask: false)
...PermittedDomainsConfig_formValues @relay(mask: false)
...CommentStreamLiveUpdates_formValues @relay(mask: false)
...StoryCreationConfig_formValues @relay(mask: false)
...EmbedCodeContainer_settings
...CommentStreamLiveUpdatesContainer_settings
}
`,
@@ -1,26 +0,0 @@
import React, { FunctionComponent } from "react";
import { graphql } from "react-relay";
import { withFragmentContainer } from "coral-framework/lib/relay";
import { EmbedCodeContainer_settings } from "coral-admin/__generated__/EmbedCodeContainer_settings.graphql";
import EmbedCode from "./EmbedCode";
interface Props {
settings: EmbedCodeContainer_settings;
}
const EmbedCodeContainer: FunctionComponent<Props> = ({ settings }) => {
return <EmbedCode staticURI={settings.staticURI} />;
};
const enhanced = withFragmentContainer<Props>({
settings: graphql`
fragment EmbedCodeContainer_settings on Settings {
staticURI
}
`,
})(EmbedCodeContainer);
export default enhanced;
@@ -1,71 +0,0 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent } from "react";
import { Field } from "react-final-form";
import { graphql } from "react-relay";
import { formatStringList, parseStringList } from "coral-framework/lib/form";
import { validateStrictURLList } from "coral-framework/lib/validation";
import { FormField, FormFieldDescription } from "coral-ui/components/v2";
import ConfigBox from "../../ConfigBox";
import Header from "../../Header";
import TextFieldWithValidation from "../../TextFieldWithValidation";
// eslint-disable-next-line no-unused-expressions
graphql`
fragment PermittedDomainsConfig_formValues on Settings {
allowedDomains
}
`;
interface Props {
disabled: boolean;
}
const PermittedDomainsConfig: FunctionComponent<Props> = ({ disabled }) => (
<ConfigBox
title={
<Localized id="configure-advanced-permittedDomains">
<Header htmlFor="configure-advanced-allowedDomains">
Permitted domains
</Header>
</Localized>
}
>
<FormField>
<Localized
id="configure-advanced-permittedDomains-description"
strong={<strong />}
>
<FormFieldDescription>
The domains you would like to permit for Coral, e.g. your local,
staging and production environments including the scheme (ex.
http://localhost:3000, https://staging.domain.com,
https://domain.com).
</FormFieldDescription>
</Localized>
<Field
name="allowedDomains"
parse={parseStringList}
format={formatStringList}
validate={validateStrictURLList}
>
{({ input, meta }) => (
<TextFieldWithValidation
{...input}
id={`configure-advanced-${input.name}`}
disabled={disabled}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck={false}
meta={meta}
fullWidth
/>
)}
</Field>
</FormField>
</ConfigBox>
);
export default PermittedDomainsConfig;
@@ -0,0 +1,10 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent } from "react";
const EmptySitesMessage: FunctionComponent = props => (
<Localized id="sites-emptyMessage">
<div>We could not find any sites matching your criteria.</div>
</Localized>
);
export default EmptySitesMessage;
@@ -6,7 +6,6 @@ import {
purgeMetadata,
withFragmentContainer,
} from "coral-framework/lib/relay";
import { HorizontalGutter } from "coral-ui/components";
import { OrganizationConfigContainer_settings as SettingsData } from "coral-admin/__generated__/OrganizationConfigContainer_settings.graphql";
@@ -25,22 +24,19 @@ const OrganizationConfigContainer: React.FunctionComponent<Props> = ({
const form = useForm();
useMemo(() => form.initialize(purgeMetadata(settings)), []);
return (
<HorizontalGutter
size="double"
data-testid="configure-organizationContainer"
>
<>
<OrganizationNameConfig disabled={submitting} />
<OrganizationContactEmailConfig disabled={submitting} />
<OrganizationURLConfig disabled={submitting} />
</HorizontalGutter>
<OrganizationContactEmailConfig disabled={submitting} />
</>
);
};
const enhanced = withFragmentContainer<Props>({
settings: graphql`
fragment OrganizationConfigContainer_settings on Settings {
...OrganizationNameConfig_formValues @relay(mask: false)
...OrganizationContactEmailConfig_formValues @relay(mask: false)
...OrganizationURLConfig_formValues @relay(mask: false)
...OrganizationContactEmailConfig_formValues @relay(mask: false)
}
`,
})(OrganizationConfigContainer);
@@ -2,11 +2,12 @@ import React from "react";
import { graphql } from "react-relay";
import { withRouteConfig } from "coral-framework/lib/router";
import { Delay, Spinner } from "coral-ui/components/v2";
import { Delay, HorizontalGutter, Spinner } from "coral-ui/components/v2";
import { OrganizationConfigRouteQueryResponse } from "coral-admin/__generated__/OrganizationConfigRouteQuery.graphql";
import OrganizationConfigContainer from "./OrganizationConfigContainer";
import SitesConfigContainer from "./SitesConfigContainer";
interface Props {
data: OrganizationConfigRouteQueryResponse | null;
@@ -23,10 +24,16 @@ class OrganizationConfigRoute extends React.Component<Props> {
);
}
return (
<OrganizationConfigContainer
settings={this.props.data.settings}
submitting={this.props.submitting}
/>
<HorizontalGutter
spacing={4}
data-testid="configure-organizationContainer"
>
<OrganizationConfigContainer
settings={this.props.data.settings}
submitting={this.props.submitting}
/>
<SitesConfigContainer query={this.props.data} />
</HorizontalGutter>
);
}
}
@@ -37,6 +44,7 @@ const enhanced = withRouteConfig<Props>({
settings {
...OrganizationConfigContainer_settings
}
...SitesConfigContainer_query
}
`,
cacheConfig: { force: true },
@@ -0,0 +1,49 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent } from "react";
import { graphql, withFragmentContainer } from "coral-framework/lib/relay";
import { Button, Flex, Icon } from "coral-ui/components/v2";
import { TableCell, TableRow } from "coral-ui/components/v2/Table";
import { SiteRowContainer_site } from "coral-admin/__generated__/SiteRowContainer_site.graphql";
interface Props {
site: SiteRowContainer_site;
}
const SiteRowContainer: FunctionComponent<Props> = ({ site }) => {
return (
<TableRow>
<TableCell>{site.name}</TableCell>
<TableCell>
<Flex justifyContent="flex-end">
<Localized
id="configure-sites-site-details"
icon={<Icon>keyboard_arrow_right</Icon>}
>
<Button
variant="text"
to={`/admin/configure/organization/sites/${site.id}`}
iconRight
>
Details
<Icon>keyboard_arrow_right</Icon>
</Button>
</Localized>
</Flex>
</TableCell>
</TableRow>
);
};
const enhanced = withFragmentContainer<Props>({
site: graphql`
fragment SiteRowContainer_site on Site {
id
name
createdAt
}
`,
})(SiteRowContainer);
export default enhanced;
@@ -0,0 +1,67 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent } from "react";
import { PropTypesOf } from "coral-framework/types";
import { Button, FormFieldDescription, Icon } from "coral-ui/components/v2";
import ConfigBox from "../../ConfigBox";
import Header from "../../Header";
import SiteRowContainer from "./SiteRowContainer";
import SitesTable from "./SitesTable";
interface Props {
sites: Array<{ id: string } & PropTypesOf<typeof SiteRowContainer>["site"]>;
onLoadMore: () => void;
hasMore: boolean;
disableLoadMore: boolean;
loading: boolean;
}
const SitesConfig: FunctionComponent<Props> = ({
sites,
loading,
disableLoadMore,
onLoadMore,
hasMore,
}) => {
return (
<ConfigBox
title={
<Localized id="configure-organization-sites">
<Header htmlFor="configure-organization-organization.sites">
Sites
</Header>
</Localized>
}
>
<Localized id="configure-organization-sites-explanation">
<FormFieldDescription>
Add a new site to your organization or edit an existing site's
details.
</FormFieldDescription>
</Localized>
<Localized
id="configure-organization-sites-add-site"
icon={<Icon>add</Icon>}
>
<Button
to="/admin/configure/organization/sites/new"
iconLeft
size="large"
>
<Icon>add</Icon>
Add a site
</Button>
</Localized>
<SitesTable
sites={sites}
loading={loading}
onLoadMore={onLoadMore}
hasMore={hasMore}
disableLoadMore={disableLoadMore}
/>
</ConfigBox>
);
};
export default SitesConfig;
@@ -0,0 +1,94 @@
import React from "react";
import { graphql, RelayPaginationProp } from "react-relay";
import { IntersectionProvider } from "coral-framework/lib/intersection";
import {
useLoadMore,
useRefetch,
withPaginationContainer,
} from "coral-framework/lib/relay";
import { SitesConfigContainer_query as QueryData } from "coral-admin/__generated__/SitesConfigContainer_query.graphql";
import { SitesConfigContainerPaginationQueryVariables } from "coral-admin/__generated__/SitesConfigContainerPaginationQuery.graphql";
import SitesConfig from "./SitesConfig";
interface Props {
query: QueryData | null;
relay: RelayPaginationProp;
}
const SitesConfigContainer: React.FunctionComponent<Props> = props => {
const sites = props.query
? props.query.sites.edges.map(edge => edge.node)
: [];
const [loadMore, isLoadingMore] = useLoadMore(props.relay, 10);
const [, isRefetching] = useRefetch<
SitesConfigContainerPaginationQueryVariables
>(props.relay);
return (
<IntersectionProvider>
<SitesConfig
loading={!props.query || isRefetching}
sites={sites}
onLoadMore={loadMore}
hasMore={!isRefetching && props.relay.hasMore()}
disableLoadMore={isLoadingMore}
/>
</IntersectionProvider>
);
};
type FragmentVariables = SitesConfigContainerPaginationQueryVariables;
const enhanced = withPaginationContainer<
Props,
SitesConfigContainerPaginationQueryVariables,
FragmentVariables
>(
{
query: graphql`
fragment SitesConfigContainer_query on Query
@argumentDefinitions(
count: { type: "Int!", defaultValue: 20 }
cursor: { type: "Cursor" }
) {
sites(first: $count, after: $cursor)
@connection(key: "SitesConfig_sites") {
edges {
node {
id
...SiteRowContainer_site
}
}
}
}
`,
},
{
direction: "forward",
getConnectionFromProps(props) {
return props.query && props.query.sites;
},
// This is also the default implementation of `getFragmentVariables` if it isn't provided.
getFragmentVariables(prevVars, totalCount) {
return {
...prevVars,
count: totalCount,
};
},
getVariables(props, { count, cursor }, fragmentVariables) {
return {
count,
cursor,
};
},
query: graphql`
# Pagination query to be fetched upon calling 'loadMore'.
# Notice that we re-use our fragment, and the shape of this query matches our fragment spec.
query SitesConfigContainerPaginationQuery($count: Int!, $cursor: Cursor) {
...SitesConfigContainer_query @arguments(count: $count, cursor: $cursor)
}
`,
}
)(SitesConfigContainer);
export default enhanced;
@@ -0,0 +1,63 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent } from "react";
import AutoLoadMore from "coral-admin/components/AutoLoadMore";
import { PropTypesOf } from "coral-framework/types";
import {
Flex,
Spinner,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
} from "coral-ui/components/v2";
import EmptySitesMessage from "./EmptySitesMessage";
import SiteRowContainer from "./SiteRowContainer";
interface Props {
sites: Array<{ id: string } & PropTypesOf<typeof SiteRowContainer>["site"]>;
onLoadMore: () => void;
hasMore: boolean;
disableLoadMore: boolean;
loading: boolean;
}
const SitesTable: FunctionComponent<Props> = props => {
return (
<>
<Table fullWidth>
<TableHead>
<TableRow>
<Localized id="site-table-siteName">
<TableCell>Site name</TableCell>
</Localized>
<TableCell />
</TableRow>
</TableHead>
<TableBody>
{props.sites.map(site => (
<SiteRowContainer site={site} key={site.id} />
))}
</TableBody>
</Table>
{!props.loading && props.sites.length === 0 && <EmptySitesMessage />}
{props.loading && (
<Flex justifyContent="center">
<Spinner />
</Flex>
)}
{props.hasMore && (
<Flex justifyContent="center">
<AutoLoadMore
disableLoadMore={props.disableLoadMore}
onLoadMore={props.onLoadMore}
/>
</Flex>
)}
</>
);
};
export default SitesTable;
@@ -0,0 +1,75 @@
import { Localized } from "@fluent/react/compat";
import { useRouter } from "found";
import React, { FunctionComponent, useCallback } from "react";
import { useNotification } from "coral-admin/App/GlobalNotification";
import { graphql } from "coral-framework/lib/relay";
import { withRouteConfig } from "coral-framework/lib/router";
import { AppNotification } from "coral-ui/components/v2";
import { AddSiteRouteQueryResponse } from "coral-admin/__generated__/AddSiteRouteQuery.graphql";
import ConfigBox from "../../ConfigBox";
import Header from "../../Header";
import CreateSiteForm from "./CreateSiteForm";
interface Props {
data: AddSiteRouteQueryResponse | null;
}
const AddSiteRoute: FunctionComponent<Props> = props => {
const { router } = useRouter();
const { setMessage, clearMessage } = useNotification();
const onSiteCreate = useCallback(
(id: string, name: string) => {
router.replace(`/admin/configure/organization/sites/${id}`);
setMessage(
<Localized
id="configure-sites-add-success"
$site={name}
$org={props.data && props.data.settings.organization.name}
>
<AppNotification icon="check_circle_outline" onClose={clearMessage}>
{name} has been added to{" "}
{props.data && props.data.settings.organization.name}
</AppNotification>
</Localized>
);
},
[props.data]
);
if (!props.data) {
return null;
}
return (
<ConfigBox
title={
<Localized
id="configure-sites-add-new-site"
$site={props.data.settings.organization.name}
>
<Header>
Add a new site to {props.data.settings.organization.name}
</Header>
</Localized>
}
>
<CreateSiteForm onCreate={onSiteCreate} />
</ConfigBox>
);
};
const enhanced = withRouteConfig<Props>({
query: graphql`
query AddSiteRouteQuery {
settings {
organization {
name
}
}
}
`,
cacheConfig: { force: true },
})(AddSiteRoute);
export default enhanced;
@@ -0,0 +1,147 @@
import { Localized } from "@fluent/react/compat";
import { FormApi } from "final-form";
import React, { FunctionComponent, useCallback, useState } from "react";
import { Field, Form } from "react-final-form";
import { formatStringList, parseStringList } from "coral-framework/lib/form";
import { useMutation } from "coral-framework/lib/relay";
import {
required,
validateStrictURLList,
} from "coral-framework/lib/validation";
import {
Button,
ButtonIcon,
CallOut,
Flex,
FormField,
FormFieldHeader,
HorizontalGutter,
Label,
} from "coral-ui/components/v2";
import HelperText from "../../HelperText";
import TextFieldWithValidation from "../../TextFieldWithValidation";
import CreateSiteMutation from "./CreateSiteMutation";
interface Props {
onCreate: (id: string, name: string) => void;
}
const CreateSiteForm: FunctionComponent<Props> = ({ onCreate }) => {
const createSite = useMutation(CreateSiteMutation);
const [submitError, setSubmitError] = useState<null | string>(null);
const onSubmit = useCallback(
async (input, form: FormApi) => {
try {
const response = await createSite({ site: input });
if (response && response.site) {
onCreate(response.site.id, response.site.name);
}
} catch (error) {
setSubmitError(error.message);
}
},
[onCreate]
);
return (
<div>
<Form onSubmit={onSubmit}>
{({ handleSubmit, invalid, submitting, ...formProps }) => (
<form onSubmit={handleSubmit}>
<HorizontalGutter spacing={4}>
<FormField>
<FormFieldHeader>
<Localized id="configure-sites-site-form-name">
<Label>Site name</Label>
</Localized>
<Localized id="configure-sites-site-form-name-explanation">
<HelperText>
Site name will appear on emails sent by Coral to your
community and organization members.
</HelperText>
</Localized>
</FormFieldHeader>
<Field name="name" validate={required}>
{({ input, meta }) => (
<TextFieldWithValidation
{...input}
id={input.name}
fullWidth
meta={meta}
/>
)}
</Field>
</FormField>
<FormField>
<FormFieldHeader>
<Localized id="configure-sites-site-form-domains">
<Label>Site permitted domains</Label>
</Localized>
<Localized id="configure-sites-site-form-domains-explanation">
<HelperText>
Domains where your Coral comment streams are allowed to be
embedded (ex. http://localhost:3000,
https://staging.domain.com, https://domain.com).
</HelperText>
</Localized>
</FormFieldHeader>
<Field
name="allowedOrigins"
parse={parseStringList}
format={formatStringList}
validate={validateStrictURLList}
>
{({ input, meta }) => (
<TextFieldWithValidation
{...input}
id={`configure-advanced-${input.name}`}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck={false}
meta={meta}
fullWidth
/>
)}
</Field>
</FormField>
{submitError && (
<CallOut fullWidth color="error">
{submitError}
</CallOut>
)}
<Flex itemGutter justifyContent="flex-end">
<Localized id="configure-sites-site-form-cancel">
<Button
variant="outline"
size="large"
color="mono"
to="/admin/configure/organization"
>
Cancel
</Button>
</Localized>
<Localized
id="configure-sites-site-form-submit"
icon={<ButtonIcon>add</ButtonIcon>}
>
<Button
disabled={submitting}
iconLeft
type="submit"
size="large"
>
Add site
</Button>
</Localized>
</Flex>
</HorizontalGutter>
</form>
)}
</Form>
</div>
);
};
export default CreateSiteForm;
@@ -0,0 +1,57 @@
import { graphql } from "react-relay";
import { Environment } from "relay-runtime";
import { CoralContext } from "coral-framework/lib/bootstrap";
import {
commitMutationPromiseNormalized,
createMutation,
MutationInput,
} from "coral-framework/lib/relay";
import { CreateSiteMutation as MutationTypes } from "coral-admin/__generated__/CreateSiteMutation.graphql";
let clientMutationId = 0;
const CreateSiteMutation = createMutation(
"createSite",
(
environment: Environment,
input: MutationInput<MutationTypes>,
{ uuidGenerator }: CoralContext
) => {
const id = uuidGenerator();
const now = new Date();
return commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation: graphql`
mutation CreateSiteMutation($input: CreateSiteInput!) {
createSite(input: $input) {
site {
id
name
createdAt
}
clientMutationId
}
}
`,
variables: {
input: {
...input,
clientMutationId: clientMutationId.toString(),
},
},
optimisticResponse: {
createSite: {
site: {
id,
createdAt: now.toISOString(),
name: input.site.name,
},
clientMutationId: (clientMutationId++).toString(),
},
},
});
}
);
export default CreateSiteMutation;
@@ -0,0 +1,167 @@
import { Localized } from "@fluent/react/compat";
import { FormApi } from "final-form";
import React, { FunctionComponent, useCallback, useState } from "react";
import { Field, Form } from "react-final-form";
import { graphql } from "react-relay";
import { formatStringList, parseStringList } from "coral-framework/lib/form";
import { useMutation, withFragmentContainer } from "coral-framework/lib/relay";
import {
required,
validateStrictURLList,
} from "coral-framework/lib/validation";
import {
Button,
CallOut,
Flex,
FormField,
FormFieldHeader,
HorizontalGutter,
Label,
} from "coral-ui/components/v2";
import { EditSiteForm_settings as SettingsData } from "coral-admin/__generated__/EditSiteForm_settings.graphql";
import { EditSiteForm_site as SiteData } from "coral-admin/__generated__/EditSiteForm_site.graphql";
import HelperText from "../../HelperText";
import TextFieldWithValidation from "../../TextFieldWithValidation";
import EmbedCode from "./EmbedCode";
import UpdateSiteMutation from "./UpdateSiteMutation";
interface Props {
site: SiteData;
settings: SettingsData;
onEditSuccess: (name: string) => void;
}
const EditSiteForm: FunctionComponent<Props> = ({
site,
settings,
onEditSuccess,
}) => {
const updateSite = useMutation(UpdateSiteMutation);
const [submitError, setSubmitError] = useState<null | string>(null);
const onSubmit = useCallback(async (input, form: FormApi) => {
try {
const result = await updateSite({ site: input, id: site.id });
if (result) {
onEditSuccess(result.site.name);
}
} catch (error) {
setSubmitError(error.message);
}
}, []);
return (
<div>
<Form onSubmit={onSubmit}>
{({ handleSubmit, invalid, submitting, ...formProps }) => (
<form onSubmit={handleSubmit}>
<HorizontalGutter spacing={4}>
<FormField>
<FormFieldHeader>
<Localized id="configure-sites-site-form-name">
<Label>Site name</Label>
</Localized>
<Localized id="configure-sites-site-form-name-explanation">
<HelperText>
Site name will appear on emails sent by Coral to your
community and organization members.
</HelperText>
</Localized>
</FormFieldHeader>
<Field name="name" validate={required} defaultValue={site.name}>
{({ input, meta }) => (
<TextFieldWithValidation
{...input}
id={input.name}
fullWidth
meta={meta}
/>
)}
</Field>
</FormField>
<FormField>
<FormFieldHeader>
<Localized id="configure-sites-site-form-domains">
<Label>Site permitted domains</Label>
</Localized>
<Localized id="configure-sites-site-form-domains-explanation">
<HelperText>
Domains where your Coral comment streams are allowed to be
embedded (ex. http://localhost:3000,
https://staging.domain.com, https://domain.com).
</HelperText>
</Localized>
</FormFieldHeader>
<Field
name="allowedOrigins"
defaultValue={site.allowedOrigins}
parse={parseStringList}
format={formatStringList}
validate={validateStrictURLList}
>
{({ input, meta }) => (
<TextFieldWithValidation
{...input}
id={`configure-advanced-${input.name}`}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck={false}
meta={meta}
fullWidth
/>
)}
</Field>
</FormField>
<FormField>
<FormFieldHeader>
<Localized id="configure-sites-site-form-embed-code">
<Label>Embed code</Label>
</Localized>
</FormFieldHeader>
<EmbedCode staticURI={settings.staticURI} />
</FormField>
{submitError && (
<CallOut fullWidth color="error">
{submitError}
</CallOut>
)}
<Flex itemGutter justifyContent="flex-end">
<Localized id="configure-sites-site-form-save">
<Button
disabled={submitting}
iconLeft
type="submit"
size="large"
>
Save changes
</Button>
</Localized>
</Flex>
</HorizontalGutter>
</form>
)}
</Form>
</div>
);
};
const enhanced = withFragmentContainer<Props>({
site: graphql`
fragment EditSiteForm_site on Site {
name
createdAt
id
allowedOrigins
}
`,
settings: graphql`
fragment EditSiteForm_settings on Settings {
staticURI
}
`,
})(EditSiteForm);
export default enhanced;
@@ -1,19 +1,10 @@
import { Localized } from "@fluent/react/compat";
import { stripIndent } from "common-tags";
import React, { FunctionComponent, useMemo } from "react";
import { CopyButton } from "coral-framework/components";
import { GetMessage, withGetMessage } from "coral-framework/lib/i18n";
import { getLocationOrigin } from "coral-framework/utils";
import {
FieldSet,
FormFieldDescription,
HorizontalGutter,
Textarea,
} from "coral-ui/components/v2";
import ConfigBox from "../../ConfigBox";
import Header from "../../Header";
import { HorizontalGutter, Textarea } from "coral-ui/components/v2";
import styles from "./EmbedCode.css";
@@ -84,20 +75,7 @@ const EmbedCode: FunctionComponent<Props> = ({ staticURI, getMessage }) => {
}, [staticURI]);
return (
<ConfigBox
title={
<Localized id="configure-advanced-embedCode-title">
<Header container={<legend />}>Embed code</Header>
</Localized>
}
container={<FieldSet />}
>
<Localized id="configure-advanced-embedCode-explanation">
<FormFieldDescription>
Copy and paste the code below into your CMS to embed Coral comment
streams in each of your sites stories.
</FormFieldDescription>
</Localized>
<>
<Textarea
rows={embed.rows}
className={styles.textArea}
@@ -105,9 +83,9 @@ const EmbedCode: FunctionComponent<Props> = ({ staticURI, getMessage }) => {
value={embed.text}
/>
<HorizontalGutter className={styles.copyArea}>
<CopyButton text={embed.text} />
<CopyButton variant="regular" color="mono" text={embed.text} />
</HorizontalGutter>
</ConfigBox>
</>
);
};
@@ -0,0 +1,66 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent, useCallback } from "react";
import { useNotification } from "coral-admin/App/GlobalNotification";
import { graphql } from "coral-framework/lib/relay";
import { withRouteConfig } from "coral-framework/lib/router";
import { AppNotification } from "coral-ui/components/v2";
import { SiteRouteQueryResponse } from "coral-admin/__generated__/SiteRouteQuery.graphql";
import ConfigBox from "../../ConfigBox";
import Header from "../../Header";
import EditSiteForm from "./EditSiteForm";
interface Props {
data: SiteRouteQueryResponse;
}
const AddSiteRoute: FunctionComponent<Props> = ({ data }) => {
if (!data || !data.site) {
return null;
}
const { site } = data;
const { setMessage, clearMessage } = useNotification();
const onSiteEdit = useCallback((name: string) => {
setMessage(
<Localized id="configure-sites-edit-success" $site={name}>
<AppNotification icon="check_circle_outline" onClose={clearMessage}>
Changes to {name} have been saved
</AppNotification>
</Localized>
);
}, []);
return (
<ConfigBox
title={
<Localized id="configure-sites-site-edit" $site={site.name}>
<Header>Edit {site.name} details</Header>
</Localized>
}
>
<EditSiteForm
onEditSuccess={onSiteEdit}
site={site}
settings={data.settings}
/>
</ConfigBox>
);
};
const enhanced = withRouteConfig<Props>({
query: graphql`
query SiteRouteQuery($siteID: ID!) {
site(id: $siteID) {
name
...EditSiteForm_site
}
settings {
...EditSiteForm_settings
}
}
`,
cacheConfig: { force: true },
})(AddSiteRoute);
export default enhanced;
@@ -0,0 +1,27 @@
import React, { FunctionComponent } from "react";
import MainLayout from "coral-admin/components/MainLayout";
import ConfigureLinks from "../../ConfigureLinks";
import Layout from "../../Layout";
import Main from "../../Main";
import SideBar from "../../SideBar";
interface Props {
children: React.ReactElement;
}
const SitesRoute: FunctionComponent<Props> = props => {
return (
<MainLayout>
<Layout>
<SideBar>
<ConfigureLinks />
</SideBar>
<Main>{props.children}</Main>
</Layout>
</MainLayout>
);
};
export default SitesRoute;
@@ -0,0 +1,41 @@
import { graphql } from "react-relay";
import { Environment } from "relay-runtime";
import {
commitMutationPromiseNormalized,
createMutation,
MutationInput,
} from "coral-framework/lib/relay";
import { UpdateSiteMutation as MutationTypes } from "coral-admin/__generated__/UpdateSiteMutation.graphql";
const clientMutationId = 0;
const UpdateSiteMutation = createMutation(
"updateSite",
(environment: Environment, input: MutationInput<MutationTypes>) => {
return commitMutationPromiseNormalized<MutationTypes>(environment, {
mutation: graphql`
mutation UpdateSiteMutation($input: UpdateSiteInput!) {
updateSite(input: $input) {
site {
id
name
createdAt
allowedOrigins
}
clientMutationId
}
}
`,
variables: {
input: {
...input,
clientMutationId: clientMutationId.toString(),
},
},
});
}
);
export default UpdateSiteMutation;
@@ -0,0 +1 @@
export { default, default as Sites } from "./SitesRoute";
@@ -13,6 +13,13 @@ it("renders correctly", () => {
allStories: true,
moderationQueues: {},
story: {},
site: null,
query: "",
routeParams: {},
queueName: "",
settings: {
multisite: false,
},
};
const renderer = createRenderer();
renderer.render(<ModerateN {...props} />);
@@ -14,24 +14,43 @@ import { SubBar } from "coral-ui/components/v2/SubBar";
import HotkeysModal from "./HotkeysModal";
import ModerateNavigationContainer from "./ModerateNavigation";
import ModerateSearchBarContainer from "./ModerateSearchBar";
import { SiteSelectorContainer } from "./SiteSelector";
import styles from "./Moderate.css";
interface RouteParams {
storyID?: string;
siteID?: string;
}
interface Props {
story: PropTypesOf<typeof ModerateNavigationContainer>["story"] &
PropTypesOf<typeof ModerateSearchBarContainer>["story"];
site:
| { id: string } & PropTypesOf<typeof ModerateNavigationContainer>["site"] &
PropTypesOf<typeof SiteSelectorContainer>["site"]
| null;
query: PropTypesOf<typeof SiteSelectorContainer>["query"];
moderationQueues: PropTypesOf<
typeof ModerateNavigationContainer
>["moderationQueues"];
allStories: boolean;
settings: PropTypesOf<typeof ModerateSearchBarContainer>["settings"] | null;
children?: React.ReactNode;
queueName: string;
routeParams: RouteParams;
}
const Moderate: FunctionComponent<Props> = ({
moderationQueues,
story,
site,
query,
allStories,
children,
queueName,
routeParams,
settings,
}) => {
const [showHotkeysModal, setShowHotkeysModal] = useState(false);
const closeModal = useCallback(() => {
@@ -50,14 +69,26 @@ const Moderate: FunctionComponent<Props> = ({
key.unbind(HOTKEYS.GUIDE);
};
}, []);
return (
<div data-testid="moderate-container">
<ModerateSearchBarContainer story={story} allStories={allStories} />
<ModerateSearchBarContainer
story={story}
settings={settings}
allStories={allStories}
siteID={routeParams.siteID || null}
siteSelector={
<SiteSelectorContainer
queueName={queueName}
site={site}
query={query}
/>
}
/>
<SubBar data-testid="moderate-tabBar-container">
<ModerateNavigationContainer
moderationQueues={moderationQueues}
story={story}
site={story ? null : site}
/>
</SubBar>
<div className={styles.background} />
@@ -11,6 +11,7 @@ import Moderate from "./Moderate";
interface RouteParams {
storyID?: string;
siteID?: string;
}
interface Props {
@@ -24,18 +25,46 @@ class ModerateContainer extends React.Component<Props> {
public render() {
const allStories = !this.props.match.params.storyID;
// TODO: (tessalt) get active route in a better way
const queueName = [
"default",
"reported",
"pending",
"unmoderated",
"rejected",
].find(name => {
return this.props.match.location.pathname.includes(name);
});
if (!this.props.data) {
return (
<Moderate moderationQueues={null} story={null} allStories={allStories}>
<Moderate
moderationQueues={null}
story={null}
site={null}
settings={null}
query={this.props.data}
routeParams={this.props.match.params}
queueName={queueName || "default"}
allStories={allStories}
>
<Spinner />
</Moderate>
);
}
return (
<Moderate
moderationQueues={this.props.data.moderationQueues}
story={this.props.data.story || null}
site={
this.props.data.site ||
(this.props.data.story ? this.props.data.story.site : null)
}
routeParams={this.props.match.params}
query={this.props.data}
allStories={allStories}
settings={this.props.data.settings}
queueName={queueName || "default"}
>
{this.props.children}
</Moderate>
@@ -45,21 +74,42 @@ class ModerateContainer extends React.Component<Props> {
const enhanced = withRouteConfig<Props>({
query: graphql`
query ModerateContainerQuery($storyID: ID, $includeStory: Boolean!) {
query ModerateContainerQuery(
$storyID: ID
$includeStory: Boolean!
$siteID: ID
$includeSite: Boolean!
) {
settings {
...ModerateSearchBarContainer_settings
}
story(id: $storyID) @include(if: $includeStory) {
...ModerateNavigationContainer_story
...ModerateSearchBarContainer_story
site {
id
...ModerateNavigationContainer_site
...SiteSelectorSelected_site
}
}
moderationQueues(storyID: $storyID) {
site(id: $siteID) @include(if: $includeSite) {
id
...ModerateNavigationContainer_site
...SiteSelectorSelected_site
}
moderationQueues(storyID: $storyID, siteID: $siteID) {
...ModerateNavigationContainer_moderationQueues
}
...SiteSelectorContainer_query
}
`,
cacheConfig: { force: true },
prepareVariables: (params, match) => {
return {
storyID: match.params.storyID,
siteID: match.params.siteID,
includeStory: Boolean(match.params.storyID),
includeSite: Boolean(match.params.siteID),
};
},
})(withRouter(ModerateContainer));
@@ -8,6 +8,7 @@ import {
} from "coral-framework/lib/relay";
import { ModerateNavigationContainer_moderationQueues as ModerationQueuesData } from "coral-admin/__generated__/ModerateNavigationContainer_moderationQueues.graphql";
import { ModerateNavigationContainer_site as SiteData } from "coral-admin/__generated__/ModerateNavigationContainer_site.graphql";
import { ModerateNavigationContainer_story as StoryData } from "coral-admin/__generated__/ModerateNavigationContainer_story.graphql";
import ModerateCountsCommentEnteredSubscription from "./ModerateCountsCommentEnteredSubscription";
@@ -17,6 +18,7 @@ import Navigation from "./Navigation";
interface Props {
moderationQueues: ModerationQueuesData | null;
story: StoryData | null;
site: SiteData | null;
}
const ModerateNavigationContainer: React.FunctionComponent<Props> = props => {
@@ -33,6 +35,7 @@ const ModerateNavigationContainer: React.FunctionComponent<Props> = props => {
}
const vars = {
storyID: props.story && props.story.id,
siteID: props.site && props.site.id,
};
const disposable = combineDisposables(
subscribeToCommentEntered(vars),
@@ -41,7 +44,7 @@ const ModerateNavigationContainer: React.FunctionComponent<Props> = props => {
return () => {
disposable.dispose();
};
}, [Boolean(props.moderationQueues), props.story]);
}, [Boolean(props.moderationQueues), props.story, props.site]);
if (!props.moderationQueues) {
return <Navigation />;
@@ -52,6 +55,7 @@ const ModerateNavigationContainer: React.FunctionComponent<Props> = props => {
reportedCount={props.moderationQueues.reported.count}
pendingCount={props.moderationQueues.pending.count}
storyID={props.story && props.story.id}
siteID={props.site && props.site.id}
/>
);
};
@@ -62,6 +66,11 @@ const enhanced = withFragmentContainer<Props>({
id
}
`,
site: graphql`
fragment ModerateNavigationContainer_site on Site {
id
}
`,
moderationQueues: graphql`
fragment ModerateNavigationContainer_moderationQueues on ModerationQueues {
unmoderated {
@@ -1,19 +1,21 @@
import { Localized } from "@fluent/react/compat";
import { Match, Router, withRouter } from "found";
import key from "keymaster";
import { isNumber } from "lodash";
import React, { FunctionComponent, useEffect, useMemo } from "react";
import { HOTKEYS } from "coral-admin/constants";
import { getModerationLink } from "coral-admin/helpers";
import { getModerationLink } from "coral-framework/helpers";
import { Counter, Icon, SubBarNavigation } from "coral-ui/components/v2";
import NavigationLink from "./NavigationLink";
interface Props {
unmoderatedCount?: number;
reportedCount?: number;
pendingCount?: number;
unmoderatedCount?: number | null;
reportedCount?: number | null;
pendingCount?: number | null;
storyID?: string | null;
siteID?: string | null;
router: Router;
match: Match;
}
@@ -23,17 +25,18 @@ const Navigation: FunctionComponent<Props> = ({
reportedCount,
pendingCount,
storyID,
siteID,
router,
match,
}) => {
const moderationLinks = useMemo(() => {
return [
getModerationLink("reported", storyID),
getModerationLink("pending", storyID),
getModerationLink("unmoderated", storyID),
getModerationLink("rejected", storyID),
getModerationLink({ queue: "reported", storyID, siteID }),
getModerationLink({ queue: "pending", storyID, siteID }),
getModerationLink({ queue: "unmoderated", storyID, siteID }),
getModerationLink({ queue: "rejected", storyID, siteID }),
];
}, [storyID]);
}, [storyID, siteID]);
useEffect(() => {
key(HOTKEYS.SWITCH_QUEUE, () => {
@@ -67,7 +70,7 @@ const Navigation: FunctionComponent<Props> = ({
<Localized id="moderate-navigation-reported">
<span>Reported</span>
</Localized>
{reportedCount !== undefined && (
{isNumber(reportedCount) && (
<Counter data-testid="moderate-navigation-reported-count">
{reportedCount}
</Counter>
@@ -78,7 +81,7 @@ const Navigation: FunctionComponent<Props> = ({
<Localized id="moderate-navigation-pending">
<span>Pending</span>
</Localized>
{pendingCount !== undefined && (
{isNumber(pendingCount) && (
<Counter data-testid="moderate-navigation-pending-count">
{pendingCount}
</Counter>
@@ -89,7 +92,7 @@ const Navigation: FunctionComponent<Props> = ({
<Localized id="moderate-navigation-unmoderated">
<span>Unmoderated</span>
</Localized>
{unmoderatedCount !== undefined && (
{isNumber(unmoderatedCount) && (
<Counter data-testid="moderate-navigation-unmoderated-count">
{unmoderatedCount}
</Counter>
@@ -11,9 +11,17 @@
z-index: 100;
}
.popover,
.popoverNarrow {
border: 0;
}
.popover {
width: calc(94 * var(--mini-unit));
border: 0;
}
.popoverNarrow {
width: calc(84 * var(--mini-unit));
}
.listBox {
@@ -34,12 +34,22 @@ interface Props {
options: Array<ListBoxOption & { group: Group }>;
/** onSearch will be called whenenver the user submits the search */
onSearch?: (value: string) => void;
siteSelector: React.ReactNode;
multisite: boolean;
}
/**
* Bar is the container of the whole search bar.
*/
const Bar: FunctionComponent<Props> = ({ title, options, onSearch }) => {
const Bar: FunctionComponent<Props> = ({
title,
options,
onSearch,
siteSelector,
multisite,
}) => {
const [focused, focusHandlers] = useFocus();
const preventFocusLossHandlers = usePreventFocusLoss(focused);
const submitHandler = useCallback(
@@ -80,6 +90,7 @@ const Bar: FunctionComponent<Props> = ({ title, options, onSearch }) => {
aria-expanded={focused}
>
<Backdrop className={styles.bumpZIndex} active={focused} />
{multisite ? siteSelector : null}
<Form onSubmit={submitHandler}>
{({ handleSubmit }) => (
<Localized
@@ -96,7 +107,9 @@ const Bar: FunctionComponent<Props> = ({ title, options, onSearch }) => {
<Popover
id={"moderate-searchBar-popover"}
placement="bottom"
classes={{ popover: styles.popover }}
classes={{
popover: multisite ? styles.popoverNarrow : styles.popover,
}}
visible={focused}
eventsEnabled={false}
modifiers={{
@@ -166,6 +179,7 @@ const Bar: FunctionComponent<Props> = ({ title, options, onSearch }) => {
{({ ref }) => (
<div ref={ref}>
<Field
multisite={multisite}
title={title}
ref={searchInput}
{...combineEventHandlers(
@@ -5,15 +5,24 @@
height: calc(4 * var(--mini-unit));
}
.hasSiteSelector {
width: calc(84 * var(--mini-unit));
}
.begin {
background-color: $story-search-input-background;
min-width: calc(4 * var(--mini-unit));
border-top-left-radius: var(--v2-round-corners);
border-bottom-left-radius: var(--v2-round-corners);
min-width: calc(5 * var(--mini-unit));
flex-shrink: 0;
pointer-events: none;
}
.adornmentLeft {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
.beginStories {
font-size: var(--v2-font-size-2);
font-weight: var(--v2-font-weight-primary-semi-bold);
@@ -93,8 +102,9 @@
}
.inputWithTitle {
text-align: center;
&::placeholder {
font-weight: var(--v2-font-weight-primary-bold);
color: var(--v2-colors-mono-500);
opacity: 1;
}
}
@@ -14,6 +14,7 @@ interface Props extends HTMLAttributes<HTMLInputElement> {
className?: string;
focused?: boolean;
forwardRef?: Ref<HTMLInputElement>;
multisite: boolean;
}
/**
@@ -26,13 +27,24 @@ const Field: FunctionComponent<Props> = ({
onBlur,
onChange,
forwardRef,
multisite,
...rest
}) => {
return (
<FormField name="search">
{({ input }) => (
<Flex className={cn(className, styles.root)} alignItems="stretch">
<Flex className={styles.begin} alignItems="center">
<Flex
className={cn(className, styles.root, {
[styles.hasSiteSelector]: multisite,
})}
alignItems="stretch"
>
<Flex
className={cn(styles.begin, {
[styles.adornmentLeft]: multisite,
})}
alignItems="center"
>
<Icon className={styles.searchIcon} size="md">
search
</Icon>
@@ -22,3 +22,7 @@
font-weight: var(--font-weight-medium);
margin-top: -2px;
}
.button {
border-radius: 0;
}
@@ -26,7 +26,14 @@ const ModerateAllOption: FunctionComponent<Props> = ({
aria-selected={selected}
{...rest}
>
<Button href={href} color="dark" anchor fullWidth tabIndex={-1}>
<Button
className={styles.button}
href={href}
color="dark"
anchor
fullWidth
tabIndex={-1}
>
<Localized id="moderate-searchBar-moderateAllStories">
<span>Moderate all stories</span>
</Localized>
@@ -9,7 +9,7 @@ import React, {
} from "react";
import { graphql } from "react-relay";
import { getModerationLink } from "coral-admin/helpers";
import { getModerationLink } from "coral-framework/helpers";
import { useEffectWhenChanged } from "coral-framework/hooks";
import { useFetch, withFragmentContainer } from "coral-framework/lib/relay";
import { PropTypesOf } from "coral-framework/types";
@@ -20,6 +20,7 @@ import {
ListBoxOptionElement,
} from "coral-ui/hooks/useComboBox";
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 Bar from "./Bar";
@@ -33,7 +34,10 @@ interface Props {
router: Router;
match: Match;
story: ModerationQueuesData | null;
settings: SettingsData | null;
allStories: boolean;
siteSelector: React.ReactNode;
siteID: string | null;
}
type SearchBarOptions = PropTypesOf<typeof Bar>["options"];
@@ -111,7 +115,11 @@ function getContextOptionsWhenModeratingStory(
}
type OnSearchCallback = (search: string) => void;
interface SearchParams {
query: string;
limit: number;
siteID?: string;
}
/**
* useSearchOptions
*
@@ -120,7 +128,8 @@ type OnSearchCallback = (search: string) => void;
*/
function useSearchOptions(
onClickOrEnter: ListBoxOptionClickOrEnterHandler,
story: ModerationQueuesData | null
story: ModerationQueuesData | null,
siteID: string | null
): [SearchBarOptions, OnSearchCallback] {
const searchStory = useFetch(SearchStoryFetch);
@@ -149,7 +158,14 @@ function useSearchOptions(
},
]);
const stories = await searchStory({ query: search, limit: 5 });
const searchParams: SearchParams = {
query: search,
limit: 5,
};
if (siteID) {
searchParams.siteID = siteID;
}
const stories = await searchStory(searchParams);
if (searchCount !== searchCountRef.current) {
// This result is old, so we can discard it.
return;
@@ -164,8 +180,13 @@ function useSearchOptions(
nextSearchOptions.push({
element: (
<Option
href={getModerationLink("default", e.node.id)}
details={e.node.metadata && e.node.metadata.author}
href={getModerationLink({ storyID: e.node.id })}
details={
<Flex itemGutter>
<strong>{e.node.site.name}</strong>
{e.node.metadata && e.node.metadata.author}
</Flex>
}
>
<GoToAriaInfo /> {e.node.metadata && e.node.metadata.title}
</Option>
@@ -203,7 +224,7 @@ function useSearchOptions(
}
setSearchOptions(nextSearchOptions);
},
[story, searchStory, setSearchOptions]
[story, searchStory, setSearchOptions, siteID]
);
return [searchOptions, onSearch];
@@ -217,7 +238,8 @@ const ModerateSearchBarContainer: React.FunctionComponent<Props> = props => {
const [searchOptions, onSearch] = useSearchOptions(
linkNavHandler,
props.story
props.story,
props.siteID
);
const options = [...contextOptions, ...searchOptions];
@@ -231,16 +253,35 @@ const ModerateSearchBarContainer: React.FunctionComponent<Props> = props => {
if (props.allStories) {
return (
<Localized id="moderate-searchBar-allStories" attrs={{ title: true }}>
<Bar title="All stories" {...childProps} />
<Bar
siteSelector={props.siteSelector}
multisite={props.settings ? props.settings.multisite : false}
title="All stories"
{...childProps}
/>
</Localized>
);
}
if (!props.story) {
return <Bar title={""} {...childProps} />;
return (
<Bar
multisite={props.settings ? props.settings.multisite : false}
siteSelector={props.siteSelector}
title={""}
{...childProps}
/>
);
}
const t = props.story.metadata && props.story.metadata.title;
if (t) {
return <Bar title={t} {...childProps} />;
return (
<Bar
multisite={props.settings ? props.settings.multisite : false}
siteSelector={props.siteSelector}
title={t}
{...childProps}
/>
);
}
return (
<Localized
@@ -248,6 +289,8 @@ const ModerateSearchBarContainer: React.FunctionComponent<Props> = props => {
attrs={{ title: true }}
>
<Bar
siteSelector={props.siteSelector}
multisite={props.settings ? props.settings.multisite : false}
title={"Title not available"}
options={options}
onSearch={onSearch}
@@ -258,9 +301,17 @@ const ModerateSearchBarContainer: React.FunctionComponent<Props> = props => {
const enhanced = withRouter(
withFragmentContainer<Props>({
settings: graphql`
fragment ModerateSearchBarContainer_settings on Settings {
multisite
}
`,
story: graphql`
fragment ModerateSearchBarContainer_story on Story {
id
site {
name
}
metadata {
title
author
@@ -15,11 +15,18 @@ const SearchStoryFetch = createFetch(
return fetchQuery<QueryTypes>(
environment,
graphql`
query SearchStoryFetchQuery($query: String!, $limit: Int!) {
stories(query: $query, first: $limit) {
query SearchStoryFetchQuery(
$query: String!
$limit: Int!
$siteID: ID
) {
stories(query: $query, first: $limit, siteID: $siteID) {
edges {
node {
id
site {
name
}
metadata {
title
author
@@ -34,6 +34,7 @@ interface Props {
relay: RelayPaginationProp;
emptyElement: React.ReactElement;
storyID?: string;
siteID?: string;
count?: string;
}
@@ -51,12 +52,17 @@ export const QueueRoute: FunctionComponent<Props> = props => {
);
const viewNew = useMutation(QueueViewNewMutation);
const onViewNew = useCallback(() => {
viewNew({ queue: props.queueName, storyID: props.storyID || null });
}, [props.queueName, props.storyID, viewNew]);
viewNew({
queue: props.queueName,
storyID: props.storyID || null,
siteID: props.siteID || null,
});
}, [props.queueName, props.storyID, props.siteID, viewNew]);
useEffect(() => {
const vars = {
queue: props.queueName,
storyID: props.storyID || null,
siteID: props.siteID || null,
};
const disposable = combineDisposables(
subscribeToQueueCommentEntered(vars),
@@ -67,6 +73,7 @@ export const QueueRoute: FunctionComponent<Props> = props => {
};
}, [
props.storyID,
props.siteID,
props.queueName,
subscribeToQueueCommentEntered,
subscribeToQueueCommentLeft,
@@ -124,6 +131,7 @@ const createQueueRoute = (
viewer={null}
emptyElement={emptyElement}
storyID={match.params.storyID}
siteID={match.params.siteID}
/>
);
}
@@ -138,6 +146,7 @@ const createQueueRoute = (
viewer={data.viewer}
emptyElement={emptyElement}
storyID={match.params.storyID}
siteID={match.params.siteID}
/>
);
},
@@ -210,8 +219,8 @@ const createQueueRoute = (
export const PendingQueueRoute = createQueueRoute(
GQLMODERATION_QUEUE.PENDING,
graphql`
query QueueRoutePendingQuery($storyID: ID, $count: Int) {
moderationQueues(storyID: $storyID) {
query QueueRoutePendingQuery($storyID: ID, $siteID: ID, $count: Int) {
moderationQueues(storyID: $storyID, siteID: $siteID) {
pending {
...QueueRoute_queue @arguments(count: $count)
}
@@ -229,10 +238,11 @@ export const PendingQueueRoute = createQueueRoute(
# Notice that we re-use our fragment, and the shape of this query matches our fragment spec.
query QueueRoutePaginationPendingQuery(
$storyID: ID
$siteID: ID
$count: Int!
$cursor: Cursor
) {
moderationQueues(storyID: $storyID) {
moderationQueues(storyID: $storyID, siteID: $siteID) {
pending {
...QueueRoute_queue @arguments(count: $count, cursor: $cursor)
}
@@ -250,8 +260,8 @@ export const PendingQueueRoute = createQueueRoute(
export const ReportedQueueRoute = createQueueRoute(
GQLMODERATION_QUEUE.REPORTED,
graphql`
query QueueRouteReportedQuery($storyID: ID) {
moderationQueues(storyID: $storyID) {
query QueueRouteReportedQuery($storyID: ID, $siteID: ID) {
moderationQueues(storyID: $storyID, siteID: $siteID) {
reported {
...QueueRoute_queue
}
@@ -269,10 +279,11 @@ export const ReportedQueueRoute = createQueueRoute(
# Notice that we re-use our fragment, and the shape of this query matches our fragment spec.
query QueueRoutePaginationReportedQuery(
$storyID: ID
$siteID: ID
$count: Int!
$cursor: Cursor
) {
moderationQueues(storyID: $storyID) {
moderationQueues(storyID: $storyID, siteID: $siteID) {
reported {
...QueueRoute_queue @arguments(count: $count, cursor: $cursor)
}
@@ -290,8 +301,8 @@ export const ReportedQueueRoute = createQueueRoute(
export const UnmoderatedQueueRoute = createQueueRoute(
GQLMODERATION_QUEUE.UNMODERATED,
graphql`
query QueueRouteUnmoderatedQuery($storyID: ID) {
moderationQueues(storyID: $storyID) {
query QueueRouteUnmoderatedQuery($storyID: ID, $siteID: ID) {
moderationQueues(storyID: $storyID, siteID: $siteID) {
unmoderated {
...QueueRoute_queue
}
@@ -309,10 +320,11 @@ export const UnmoderatedQueueRoute = createQueueRoute(
# Notice that we re-use our fragment, and the shape of this query matches our fragment spec.
query QueueRoutePaginationUnmoderatedQuery(
$storyID: ID
$siteID: ID
$count: Int!
$cursor: Cursor
) {
moderationQueues(storyID: $storyID) {
moderationQueues(storyID: $storyID, siteID: $siteID) {
unmoderated {
...QueueRoute_queue @arguments(count: $count, cursor: $cursor)
}
@@ -9,6 +9,7 @@ import { GQLMODERATION_QUEUE } from "coral-framework/schema";
interface QueueViewNewInput {
storyID: string | null;
siteID: string | null;
queue: GQLMODERATION_QUEUE;
}
@@ -16,7 +17,12 @@ const QueueViewNewMutation = createMutation(
"viewNew",
async (environment: Environment, input: QueueViewNewInput) => {
await commitLocalUpdatePromisified(environment, async store => {
const connection = getQueueConnection(store, input.queue, input.storyID);
const connection = getQueueConnection(
store,
input.queue,
input.storyID,
input.siteID
);
if (!connection) {
return;
}
@@ -0,0 +1,8 @@
.button {
height: calc(4 * var(--mini-unit));
}
.buttonText {
overflow-x: hidden;
text-overflow: ellipsis;
}
@@ -0,0 +1,76 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent } from "react";
import PaginatedSelect from "coral-admin/components/PaginatedSelect";
import { getModerationLink, QUEUE_NAME } from "coral-framework/helpers";
import { PropTypesOf } from "coral-framework/types";
import SiteSelectorSelected from "./SiteSelectorSelected";
import SiteSelectorSite from "./SiteSelectorSite";
import styles from "./SiteSelector.css";
interface Props {
sites: Array<{ id: string } & PropTypesOf<typeof SiteSelectorSite>["site"]>;
site:
| { id: string } & PropTypesOf<typeof SiteSelectorSelected>["site"]
| null;
queueName: string;
onLoadMore: () => void;
hasMore: boolean;
disableLoadMore: boolean;
loading: boolean;
}
const SiteSelector: FunctionComponent<Props> = ({
sites,
site,
queueName,
loading,
onLoadMore,
disableLoadMore,
hasMore,
}) => {
return (
<PaginatedSelect
icon="web_asset"
loading={loading}
onLoadMore={onLoadMore}
disableLoadMore={disableLoadMore}
hasMore={hasMore}
className={styles.button}
selected={
<>
{site && <SiteSelectorSelected site={site} />}
{!site && (
<Localized id="site-selector-all-sites">
<span className={styles.buttonText}>All sites</span>
</Localized>
)}
</>
}
>
<>
<SiteSelectorSite
link={getModerationLink({ queue: queueName as QUEUE_NAME })}
site={null}
active={!site}
/>
{sites.map(s => (
<SiteSelectorSite
link={getModerationLink({
queue: queueName as QUEUE_NAME,
siteID: s.id,
})}
key={s.id}
site={s}
active={(site && site.id === s.id) || false}
/>
))}
</>
</PaginatedSelect>
);
};
export default SiteSelector;
@@ -0,0 +1,101 @@
import React from "react";
import { graphql, RelayPaginationProp } from "react-relay";
import {
useLoadMore,
useRefetch,
withPaginationContainer,
} from "coral-framework/lib/relay";
import { PropTypesOf } from "coral-ui/types";
import { SiteSelectorContainer_query as QueryData } from "coral-admin/__generated__/SiteSelectorContainer_query.graphql";
import { SiteSelectorContainerPaginationQueryVariables } from "coral-admin/__generated__/SiteSelectorContainerPaginationQuery.graphql";
import SiteSelector from "./SiteSelector";
interface Props {
query: QueryData | null;
site: PropTypesOf<typeof SiteSelector>["site"] | null;
relay: RelayPaginationProp;
queueName: string;
}
const SiteSelectorContainer: React.FunctionComponent<Props> = props => {
const sites = props.query
? props.query.sites.edges.map(edge => edge.node)
: [];
const [loadMore, isLoadingMore] = useLoadMore(props.relay, 10);
const [, isRefetching] = useRefetch<
SiteSelectorContainerPaginationQueryVariables
>(props.relay);
return (
<SiteSelector
loading={!props.query || isRefetching}
sites={sites}
site={props.site}
onLoadMore={loadMore}
hasMore={!isRefetching && props.relay.hasMore()}
disableLoadMore={isLoadingMore}
queueName={props.queueName}
/>
);
};
type FragmentVariables = SiteSelectorContainerPaginationQueryVariables;
const enhanced = withPaginationContainer<
Props,
SiteSelectorContainerPaginationQueryVariables,
FragmentVariables
>(
{
query: graphql`
fragment SiteSelectorContainer_query on Query
@argumentDefinitions(
count: { type: "Int!", defaultValue: 10 }
cursor: { type: "Cursor" }
) {
sites(first: $count, after: $cursor)
@connection(key: "SitesConfig_sites") {
edges {
node {
id
...SiteSelectorSite_site
}
}
}
}
`,
},
{
direction: "forward",
getConnectionFromProps(props) {
return props.query && props.query.sites;
},
// This is also the default implementation of `getFragmentVariables` if it isn't provided.
getFragmentVariables(prevVars, totalCount) {
return {
...prevVars,
count: totalCount,
};
},
getVariables(props, { count, cursor }, fragmentVariables) {
return {
count,
cursor,
};
},
query: graphql`
# Pagination query to be fetched upon calling 'loadMore'.
# Notice that we re-use our fragment, and the shape of this query matches our fragment spec.
query SiteSelectorContainerPaginationQuery(
$count: Int!
$cursor: Cursor
) {
...SiteSelectorContainer_query
@arguments(count: $count, cursor: $cursor)
}
`,
}
)(SiteSelectorContainer);
export default enhanced;
@@ -0,0 +1,4 @@
.root {
overflow-x: hidden;
text-overflow: ellipsis;
}
@@ -0,0 +1,26 @@
import React, { FunctionComponent } from "react";
import { graphql, withFragmentContainer } from "coral-framework/lib/relay";
import { SiteSelectorSelected_site } from "coral-admin/__generated__/SiteSelectorSelected_site.graphql";
import styles from "./SiteSelectorSelected.css";
interface Props {
site: SiteSelectorSelected_site;
}
const SiteSelectorSelected: FunctionComponent<Props> = ({ site }) => {
return <span className={styles.root}>{site.name}</span>;
};
const enhanced = withFragmentContainer<Props>({
site: graphql`
fragment SiteSelectorSelected_site on Site {
name
id
}
`,
})(SiteSelectorSelected);
export default enhanced;
@@ -0,0 +1,14 @@
.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);
}
.active {
font-weight: var(--v2-font-weight-primary-bold);
}
@@ -0,0 +1,44 @@
import { Localized } from "@fluent/react/compat";
import cn from "classnames";
import { Link } from "found";
import React, { FunctionComponent } from "react";
import { graphql, withFragmentContainer } from "coral-framework/lib/relay";
import { SiteSelectorSite_site } from "coral-admin/__generated__/SiteSelectorSite_site.graphql";
import styles from "./SiteSelectorSite.css";
interface Props {
site: SiteSelectorSite_site | null;
active?: boolean;
link?: string;
}
const SiteSelectorSite: FunctionComponent<Props> = ({ site, link, active }) => {
return (
<Link
className={cn(styles.root, {
[styles.active]: active,
})}
to={link || ""}
>
{site ? (
site.name
) : (
<Localized id="sites-selector-allSites">All sites</Localized>
)}
</Link>
);
};
const enhanced = withFragmentContainer<Props>({
site: graphql`
fragment SiteSelectorSite_site on Site {
name
id
}
`,
})(SiteSelectorSite);
export default enhanced;
@@ -0,0 +1,2 @@
export { default, default as SiteSelector } from "./SiteSelector";
export { default as SiteSelectorContainer } from "./SiteSelectorContainer";
@@ -6,6 +6,19 @@ exports[`renders correctly 1`] = `
>
<ForwardRef(render)
allStories={true}
settings={
Object {
"multisite": false,
}
}
siteID={null}
siteSelector={
<Relay(SiteSelectorContainer)
query=""
queueName=""
site={null}
/>
}
story={Object {}}
/>
<withPropsOnChange(SubBar)
@@ -13,6 +26,7 @@ exports[`renders correctly 1`] = `
>
<Relay(ModerateNavigationContainer)
moderationQueues={Object {}}
site={null}
story={Object {}}
/>
</withPropsOnChange(SubBar)>
@@ -0,0 +1,9 @@
.buttonText {
font-weight: var(--v2-font-weight-primary-regular);
}
.root {
border-radius: var(--round-corners);
border: 1px solid var(--v2-palette-input-border);
height: 34px;
}
@@ -0,0 +1,79 @@
import { Localized } from "@fluent/react/compat";
import React, { FunctionComponent } from "react";
import PaginatedSelect from "coral-admin/components/PaginatedSelect";
import { PropTypesOf } from "coral-framework/types";
import { FieldSet, HorizontalGutter, Label } from "coral-ui/components/v2";
import SiteFilterOption from "./SiteFilterOption";
import SiteFilterSelected from "./SiteFilterSelected";
import styles from "./SiteFilter.css";
interface Props {
sites: Array<
{ id: string } & PropTypesOf<typeof SiteFilterOption>["site"] &
PropTypesOf<typeof SiteFilterSelected>["site"]
>;
siteID: string | null;
onSelect: (id: string | null) => void;
onLoadMore: () => void;
hasMore: boolean;
disableLoadMore: boolean;
loading: boolean;
}
const SiteFilter: FunctionComponent<Props> = ({
siteID,
sites,
onSelect,
onLoadMore,
hasMore,
disableLoadMore,
loading,
}) => {
const selected = sites.find(s => s.id === siteID);
return (
<FieldSet>
<HorizontalGutter spacing={2}>
<Localized id="stories-filter-sites">
<Label>Site</Label>
</Localized>
<PaginatedSelect
onLoadMore={onLoadMore}
hasMore={hasMore}
disableLoadMore={disableLoadMore}
className={styles.root}
loading={loading}
selected={
<>
{selected ? (
<SiteFilterSelected site={selected} />
) : (
<Localized id="sites-filter-sites-allSites">
<span className={styles.buttonText}>All sites</span>
</Localized>
)}
</>
}
>
<SiteFilterOption
onSelect={() => onSelect(null)}
site={null}
active={!siteID}
/>
{sites.map(s => (
<SiteFilterOption
onSelect={id => onSelect(id)}
site={s}
active={s.id === siteID}
key={s.id}
/>
))}
</PaginatedSelect>
</HorizontalGutter>
</FieldSet>
);
};
export default SiteFilter;
@@ -0,0 +1,97 @@
import React from "react";
import { graphql, RelayPaginationProp } from "react-relay";
import {
useLoadMore,
useRefetch,
withPaginationContainer,
} from "coral-framework/lib/relay";
import { SiteFilterContainer_query as QueryData } from "coral-admin/__generated__/SiteFilterContainer_query.graphql";
import { SiteFilterContainerPaginationQueryVariables } from "coral-admin/__generated__/SiteFilterContainerPaginationQuery.graphql";
import SiteFilter from "./SiteFilter";
interface Props {
query: QueryData | null;
relay: RelayPaginationProp;
siteID: string | null;
onSelect: (id: string) => void;
}
const SiteFilterContainer: React.FunctionComponent<Props> = props => {
const sites = props.query
? props.query.sites.edges.map(edge => edge.node)
: [];
const [loadMore, isLoadingMore] = useLoadMore(props.relay, 10);
const [, isRefetching] = useRefetch<
SiteFilterContainerPaginationQueryVariables
>(props.relay);
return (
<SiteFilter
onSelect={props.onSelect}
loading={!props.query || isRefetching}
sites={sites}
siteID={props.siteID}
onLoadMore={loadMore}
hasMore={!isRefetching && props.relay.hasMore()}
disableLoadMore={isLoadingMore}
/>
);
};
type FragmentVariables = SiteFilterContainerPaginationQueryVariables;
const enhanced = withPaginationContainer<
Props,
SiteFilterContainerPaginationQueryVariables,
FragmentVariables
>(
{
query: graphql`
fragment SiteFilterContainer_query on Query
@argumentDefinitions(
count: { type: "Int!", defaultValue: 10 }
cursor: { type: "Cursor" }
) {
sites(first: $count, after: $cursor)
@connection(key: "SitesConfig_sites") {
edges {
node {
id
...SiteFilterOption_site
...SiteFilterSelected_site
}
}
}
}
`,
},
{
direction: "forward",
getConnectionFromProps(props) {
return props.query && props.query.sites;
},
// This is also the default implementation of `getFragmentVariables` if it isn't provided.
getFragmentVariables(prevVars, totalCount) {
return {
...prevVars,
count: totalCount,
};
},
getVariables(props, { count, cursor }, fragmentVariables) {
return {
count,
cursor,
};
},
query: graphql`
# Pagination query to be fetched upon calling 'loadMore'.
# Notice that we re-use our fragment, and the shape of this query matches our fragment spec.
query SiteFilterContainerPaginationQuery($count: Int!, $cursor: Cursor) {
...SiteFilterContainer_query @arguments(count: $count, cursor: $cursor)
}
`,
}
)(SiteFilterContainer);
export default enhanced;
@@ -0,0 +1,9 @@
.root {
display: block;
font-weight: var(--v2-font-weight-primary-regular);
padding: var(--v2-spacing-2);
}
.active {
font-weight: var(--v2-font-weight-primary-bold);
}
@@ -0,0 +1,56 @@
import { Localized } from "@fluent/react/compat";
import cn from "classnames";
import React, { FunctionComponent, useCallback } from "react";
import { graphql, withFragmentContainer } from "coral-framework/lib/relay";
import { Button } from "coral-ui/components/v2";
import { SiteFilterOption_site } from "coral-admin/__generated__/SiteFilterOption_site.graphql";
import styles from "./SiteFilterOption.css";
interface Props {
site: SiteFilterOption_site | null;
onSelect: (id: string | null) => void;
active: boolean;
}
const SiteFilterOption: FunctionComponent<Props> = ({
site,
onSelect,
active,
}) => {
const root = cn(styles.root, {
[styles.active]: active,
});
const onClick = useCallback(() => {
onSelect(site ? site.id : null);
}, [site]);
return (
<Button
uppercase={false}
color="mono"
variant="text"
onClick={onClick}
className={root}
>
{site && site.name}
{!site && (
<Localized id="site-filter-option-allSites">
<span>All sites</span>
</Localized>
)}
</Button>
);
};
const enhanced = withFragmentContainer<Props>({
site: graphql`
fragment SiteFilterOption_site on Site {
name
id
}
`,
})(SiteFilterOption);
export default enhanced;
@@ -0,0 +1,3 @@
.root {
font-weight: var(--v2-font-weight-primary-regular);
}
@@ -0,0 +1,25 @@
import React, { FunctionComponent } from "react";
import { graphql, withFragmentContainer } from "coral-framework/lib/relay";
import { SiteFilterSelected_site } from "coral-admin/__generated__/SiteFilterSelected_site.graphql";
import styles from "./SiteFilterSelected.css";
interface Props {
site: SiteFilterSelected_site;
}
const SiteFilterSelected: FunctionComponent<Props> = ({ site }) => {
return <span className={styles.root}>{site.name}</span>;
};
const enhanced = withFragmentContainer<Props>({
site: graphql`
fragment SiteFilterSelected_site on Site {
name
}
`,
})(SiteFilterSelected);
export default enhanced;
@@ -0,0 +1 @@
export { default } from "./SiteFilterContainer";
@@ -17,3 +17,5 @@
}
.statusColumn {
}
.siteColumn {
}
@@ -2,7 +2,7 @@ import { Link } from "found";
import React, { FunctionComponent } from "react";
import NotAvailable from "coral-admin/components/NotAvailable";
import { getModerationLink } from "coral-admin/helpers";
import { getModerationLink } from "coral-framework/helpers";
import { PropTypesOf } from "coral-framework/types";
import { TableCell, TableRow, TextLink } from "coral-ui/components/v2";
@@ -17,18 +17,28 @@ interface Props {
publishDate: string | null;
story: PropTypesOf<typeof StoryStatus>["story"];
viewer: PropTypesOf<typeof StoryStatus>["viewer"];
siteName: string;
siteID: string;
multisite: boolean;
}
const UserRow: FunctionComponent<Props> = props => (
<TableRow>
<TableCell className={styles.titleColumn}>
<Link to={getModerationLink("default", props.storyID)} as={TextLink}>
<Link to={getModerationLink({ storyID: props.storyID })} as={TextLink}>
{props.title || <NotAvailable />}
</Link>
</TableCell>
<TableCell className={styles.authorColumn}>
{props.author || <NotAvailable />}
</TableCell>
{props.multisite && (
<TableCell className={styles.siteColumn}>
<Link to={getModerationLink({ siteID: props.siteID })} as={TextLink}>
{props.siteName}
</Link>
</TableCell>
)}
<TableCell className={styles.publishDateColumn}>
{props.publishDate || <NotAvailable />}
</TableCell>
@@ -12,6 +12,7 @@ import StoryRow from "./StoryRow";
interface Props {
story: StoryData;
viewer: ViewerData;
multisite: boolean;
}
const StoryRowContainer: FunctionComponent<Props> = props => {
@@ -26,6 +27,9 @@ const StoryRowContainer: FunctionComponent<Props> = props => {
author={author}
story={props.story}
viewer={props.viewer}
siteName={props.story.site.name}
siteID={props.story.site.id}
multisite={props.multisite}
publishDate={
publishedAt
? new Intl.DateTimeFormat(locales, {
@@ -57,6 +61,10 @@ const enhanced = withFragmentContainer<Props>({
author
publishedAt
}
site {
name
id
}
isClosed
...StoryStatusChangeContainer_story
}
@@ -3,6 +3,9 @@ $tableHeaderAltTextColor: var(--v2-colors-mono-100);
.titleColumn {
width: 50%;
}
.titleColumnNarrow {
width: 32.5%;
}
.authorColumn {
width: 17.5%;
}
@@ -12,6 +15,9 @@ $tableHeaderAltTextColor: var(--v2-colors-mono-100);
.statusColumn {
width: 15%;
}
.siteColumn {
width: 17.5%;
}
.clickToModerate {
font-size: var(--v2-font-size-2);
font-weight: var(--v2-font-weight-primary-semi-bold);
@@ -1,4 +1,5 @@
import { Localized } from "@fluent/react/compat";
import cn from "classnames";
import React, { FunctionComponent } from "react";
import AutoLoadMore from "coral-admin/components/AutoLoadMore";
@@ -28,6 +29,7 @@ interface Props {
disableLoadMore: boolean;
loading: boolean;
isSearching: boolean;
multisite: boolean;
}
const StoryTable: FunctionComponent<Props> = props => (
@@ -36,7 +38,11 @@ const StoryTable: FunctionComponent<Props> = props => (
<Table fullWidth>
<TableHead>
<TableRow>
<TableCell className={styles.titleColumn}>
<TableCell
className={cn(styles.titleColumn, {
[styles.titleColumnNarrow]: props.multisite,
})}
>
<Localized id="stories-column-title">
<span>Title</span>
</Localized>{" "}
@@ -52,6 +58,11 @@ const StoryTable: FunctionComponent<Props> = props => (
<Localized id="stories-column-author">
<TableCell className={styles.authorColumn}>Author</TableCell>
</Localized>
{props.multisite && (
<Localized id="stories-column-site">
<TableCell className={styles.siteColumn}>Site</TableCell>
</Localized>
)}
<Localized id="stories-column-publishDate">
<TableCell className={styles.publishDateColumn}>
Publish Date
@@ -65,7 +76,12 @@ const StoryTable: FunctionComponent<Props> = props => (
<TableBody>
{!props.loading &&
props.stories.map(u => (
<StoryRowContainer key={u.id} story={u} viewer={props.viewer!} />
<StoryRowContainer
key={u.id}
story={u}
viewer={props.viewer!}
multisite={props.multisite}
/>
))}
</TableBody>
</Table>
@@ -8,11 +8,12 @@ import {
withPaginationContainer,
} from "coral-framework/lib/relay";
import { GQLSTORY_STATUS_RL } from "coral-framework/schema";
import { HorizontalGutter } from "coral-ui/components/v2";
import { Flex, HorizontalGutter } from "coral-ui/components/v2";
import { StoryTableContainer_query as QueryData } from "coral-admin/__generated__/StoryTableContainer_query.graphql";
import { StoryTableContainerPaginationQueryVariables } from "coral-admin/__generated__/StoryTableContainerPaginationQuery.graphql";
import SiteFilterContainer from "./SiteFilter";
import StoryTable from "./StoryTable";
import StoryTableFilter from "./StoryTableFilter";
@@ -34,30 +35,42 @@ const StoryTableContainer: FunctionComponent<Props> = props => {
const [statusFilter, setStatusFilter] = useState<GQLSTORY_STATUS_RL | null>(
null
);
const [siteFilter, setSiteFilter] = useState<string | null>(null);
const [, isRefetching] = useRefetch<
Pick<
StoryTableContainerPaginationQueryVariables,
"searchFilter" | "statusFilter"
"searchFilter" | "statusFilter" | "siteID"
>
>(props.relay, {
searchFilter: searchFilter || null,
statusFilter,
siteID: siteFilter,
});
return (
<IntersectionProvider>
<HorizontalGutter size="double">
<StoryTableFilter
onSetStatusFilter={setStatusFilter}
statusFilter={statusFilter}
onSetSearchFilter={setSearchFilter}
searchFilter={searchFilter}
/>
<Flex itemGutter="double">
<StoryTableFilter
onSetStatusFilter={setStatusFilter}
statusFilter={statusFilter}
onSetSearchFilter={setSearchFilter}
searchFilter={searchFilter}
/>
{props.query && props.query.settings.multisite && (
<SiteFilterContainer
query={props.query}
siteID={siteFilter}
onSelect={setSiteFilter}
/>
)}
</Flex>
<StoryTable
viewer={props.query && props.query.viewer}
loading={!props.query || isRefetching}
stories={stories}
onLoadMore={loadMore}
multisite={props.query ? props.query.settings.multisite : false}
hasMore={!isRefetching && props.relay.hasMore()}
disableLoadMore={isLoadingMore}
isSearching={Boolean(statusFilter) || Boolean(searchFilter)}
@@ -83,15 +96,21 @@ const enhanced = withPaginationContainer<
cursor: { type: "Cursor" }
statusFilter: { type: "STORY_STATUS" }
searchFilter: { type: "String" }
siteID: { type: "ID" }
) {
viewer {
...StoryRowContainer_viewer
}
settings {
multisite
}
...SiteFilterContainer_query
stories(
first: $count
after: $cursor
status: $statusFilter
query: $searchFilter
siteID: $siteID
) @connection(key: "StoryTable_stories") {
edges {
node {
@@ -121,6 +140,7 @@ const enhanced = withPaginationContainer<
cursor,
statusFilter: fragmentVariables.statusFilter,
searchFilter: fragmentVariables.searchFilter,
siteID: fragmentVariables.siteID,
};
},
query: graphql`
@@ -131,6 +151,7 @@ const enhanced = withPaginationContainer<
$cursor: Cursor
$statusFilter: STORY_STATUS
$searchFilter: String
$siteID: ID
) {
...StoryTableContainer_query
@arguments(
@@ -138,6 +159,7 @@ const enhanced = withPaginationContainer<
cursor: $cursor
statusFilter: $statusFilter
searchFilter: $searchFilter
siteID: $siteID
)
}
`,
@@ -83,8 +83,8 @@ const StoryTableFilter: FunctionComponent<Props> = props => (
</FieldSet>
<FieldSet>
<HorizontalGutter spacing={2}>
<Localized id="stories-filter-showMe">
<Label>Show Me</Label>
<Localized id="stories-filter-statuses">
<Label>Status</Label>
</Localized>
<Localized
id="stories-filter-statusSelectField"
@@ -17,6 +17,7 @@ import {
emptyModerationQueues,
emptyRejectedComments,
settings,
siteConnection,
users,
} from "../fixtures";
@@ -36,6 +37,7 @@ async function createTestRenderer(
QueryToModerationQueuesResolver
>(() => emptyModerationQueues),
comments: () => emptyRejectedComments,
sites: () => siteConnection,
viewer: () => viewer,
},
}),
@@ -18,6 +18,7 @@ import {
emptyModerationQueues,
emptyRejectedComments,
settings,
siteConnection,
} from "../fixtures";
async function createTestRenderer(
@@ -30,6 +31,7 @@ async function createTestRenderer(
createResolversStub<GQLResolver>({
Query: {
settings: () => settings,
sites: () => siteConnection,
moderationQueues: createQueryResolverStub<
QueryToModerationQueuesResolver
>(() => emptyModerationQueues),
@@ -17,6 +17,7 @@ import {
emptyModerationQueues,
emptyRejectedComments,
settings,
siteConnection,
users,
} from "../fixtures";
@@ -35,6 +36,7 @@ async function createTestRenderer(
moderationQueues: () => emptyModerationQueues,
comments: () => emptyRejectedComments,
viewer: () => viewer,
sites: () => siteConnection,
},
}),
params.resolvers
@@ -19,6 +19,7 @@ import {
emptyModerationQueues,
emptyRejectedComments,
settings,
siteConnection,
} from "../fixtures";
async function createTestRenderer(
@@ -36,6 +37,7 @@ async function createTestRenderer(
settings: () => settings,
moderationQueues: () => emptyModerationQueues,
comments: () => emptyRejectedComments,
sites: () => siteConnection,
},
}),
params.resolvers
@@ -16,6 +16,7 @@ import {
emptyModerationQueues,
emptyRejectedComments,
settings,
siteConnection,
users,
} from "../fixtures";
@@ -32,6 +33,7 @@ async function createTestRenderer(
Query: {
settings: () => settings,
moderationQueues: () => emptyModerationQueues,
sites: () => siteConnection,
comments: () => emptyRejectedComments,
viewer: () => viewer,
},
@@ -127,85 +127,6 @@ exports[`renders configure advanced 1`] = `
className="Box-root HorizontalGutter-root HorizontalGutter-double"
data-testid="configure-advancedContainer"
>
<fieldset
className="FieldSet-root Box-root ConfigBox-root"
>
<div
className="Box-root Flex-root ConfigBox-title Flex-flex Flex-justifySpaceBetween"
>
<div>
<legend
className="Header-root"
>
Embed code
</legend>
</div>
<div />
</div>
<div
className="ConfigBox-content"
>
<fieldset
className="FieldSet-root Box-root HorizontalGutter-root HorizontalGutter-spacing-4"
>
<p
className="FormFieldDescription-root"
>
Copy and paste the code below into your CMS to embed Coral comment streams in
each of your sites stories.
</p>
<textarea
className="Textarea-root EmbedCode-textArea"
readOnly={true}
rows={24}
value="<div id=\\"coral_thread\\"></div>
<script type=\\"text/javascript\\">
(function() {
var d = document, s = d.createElement('script');
s.src = 'http://localhost/assets/js/embed.js';
s.async = false;
s.defer = true;
s.onload = function() {
Coral.createStreamEmbed({
id: \\"coral_thread\\",
autoRender: true,
rootURL: 'http://localhost',
// Uncomment these lines and replace with the ID of the
// story's ID and URL from your CMS to provide the
// tightest integration. Refer to our documentation at
// https://docs.coralproject.net for all the configuration
// options.
// storyID: '\${storyID}',
// storyURL: '\${storyURL}',
});
};
(d.head || d.body).appendChild(s);
})();
</script>"
/>
<div
className="Box-root HorizontalGutter-root EmbedCode-copyArea HorizontalGutter-full"
>
<button
className="BaseButton-root Button-root Button-sizeRegular Button-colorMono Button-variantFlat Button-uppercase"
data-color="mono"
data-variant="flat"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
<span>
Copy
</span>
</button>
</div>
</fieldset>
</div>
</fieldset>
<div
className="Box-root ConfigBox-root"
>
@@ -349,66 +270,6 @@ When disabled, users will have to refresh the page to see new comments.
</div>
</div>
</div>
<div
className="Box-root ConfigBox-root"
>
<div
className="Box-root Flex-root ConfigBox-title Flex-flex Flex-justifySpaceBetween"
>
<div>
<label
className="Header-root"
htmlFor="configure-advanced-allowedDomains"
>
Permitted domains
</label>
</div>
<div />
</div>
<div
className="ConfigBox-content"
>
<div
className="Box-root HorizontalGutter-root HorizontalGutter-spacing-4"
>
<div
className="Box-root HorizontalGutter-root FormField-root HorizontalGutter-spacing-2"
>
<p
className="FormFieldDescription-root"
>
Domains where your Coral instance is allowed to be embedded
including the scheme (ex. http://localhost:3000, https://staging.domain.com,
https://domain.com).
</p>
<div
className="Box-root HorizontalGutter-root HorizontalGutter-spacing-2"
>
<div
className="TextField-root TextField-fullWidth"
>
<input
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
className="TextField-input TextField-colorRegular"
disabled={false}
id="configure-advanced-allowedDomains"
name="allowedDomains"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
placeholder=""
spellCheck={false}
type="text"
value="http://localhost:8080"
/>
</div>
</div>
</div>
</div>
</div>
</div>
<div
className="Box-root ConfigBox-root"
>
@@ -124,7 +124,7 @@ exports[`renders configure organization 1`] = `
className="Main-root"
>
<div
className="Box-root HorizontalGutter-root HorizontalGutter-double"
className="Box-root HorizontalGutter-root HorizontalGutter-spacing-4"
data-testid="configure-organizationContainer"
>
<div
@@ -181,6 +181,60 @@ exports[`renders configure organization 1`] = `
</div>
</div>
</div>
<div
className="Box-root ConfigBox-root"
>
<div
className="Box-root Flex-root ConfigBox-title Flex-flex Flex-justifySpaceBetween"
>
<div>
<label
className="Header-root"
htmlFor="configure-organization-organization.url"
>
Organization URL
</label>
</div>
<div />
</div>
<div
className="ConfigBox-content"
>
<div
className="Box-root HorizontalGutter-root HorizontalGutter-spacing-4"
>
<p
className="FormFieldDescription-root"
>
Your organization url will appear on emails sent by Coral to your community and organization members.
</p>
<div
className="Box-root HorizontalGutter-root HorizontalGutter-spacing-2"
>
<div
className="TextField-root TextField-fullWidth"
>
<input
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
className="TextField-input TextField-colorRegular"
disabled={false}
id="configure-organization-organization.url"
name="organization.url"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
placeholder=""
spellCheck={false}
type="text"
value="https://test.com/"
/>
</div>
</div>
</div>
</div>
</div>
<div
className="Box-root ConfigBox-root"
>
@@ -247,9 +301,9 @@ moderation questions.
<div>
<label
className="Header-root"
htmlFor="configure-organization-organization.url"
htmlFor="configure-organization-organization.sites"
>
Organization URL
Sites
</label>
</div>
<div />
@@ -263,32 +317,129 @@ moderation questions.
<p
className="FormFieldDescription-root"
>
Your organization url will appear on emails sent by Coral to your community and organization members.
Add a new site to your organization or edit an existing site's details.
</p>
<div
className="Box-root HorizontalGutter-root HorizontalGutter-spacing-2"
<a
className="BaseButton-root Button-root Button-sizeLarge Button-colorRegular Button-variantRegular Button-uppercase Button-iconLeft"
data-color="regular"
data-variant="regular"
href="/admin/configure/organization/sites/new"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
<div
className="TextField-root TextField-fullWidth"
<i
aria-hidden="true"
className="Icon-root Icon-sm"
>
<input
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
className="TextField-input TextField-colorRegular"
disabled={false}
id="configure-organization-organization.url"
name="organization.url"
onBlur={[Function]}
onChange={[Function]}
onFocus={[Function]}
placeholder=""
spellCheck={false}
type="text"
value="https://test.com/"
/>
</div>
</div>
add
</i>
Add site
</a>
<table
className="Table-root Table-fullWidth"
>
<thead
className="TableHead-root"
>
<tr
className="TableRow-root"
>
<th
className="TableCell-root TableCell-header"
>
Site name
</th>
<th
className="TableCell-root TableCell-header"
/>
</tr>
</thead>
<tbody
className="TableBody-root"
>
<tr
className="TableRow-root TableRow-body"
>
<td
className="TableCell-root TableCell-body"
>
Test Site
</td>
<td
className="TableCell-root TableCell-body"
>
<div
className="Box-root Flex-root Flex-flex Flex-justifyFlexEnd"
>
<a
className="BaseButton-root Button-root Button-sizeRegular Button-colorRegular Button-variantText Button-uppercase Button-iconRight"
data-color="regular"
data-variant="text"
href="/admin/configure/organization/sites/site-1"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
Details
<i
aria-hidden="true"
className="Icon-root Icon-sm"
>
keyboard_arrow_right
</i>
</a>
</div>
</td>
</tr>
<tr
className="TableRow-root TableRow-body"
>
<td
className="TableCell-root TableCell-body"
>
Second Site
</td>
<td
className="TableCell-root TableCell-body"
>
<div
className="Box-root Flex-root Flex-flex Flex-justifyFlexEnd"
>
<a
className="BaseButton-root Button-root Button-sizeRegular Button-colorRegular Button-variantText Button-uppercase Button-iconRight"
data-color="regular"
data-variant="text"
href="/admin/configure/organization/sites/site-2"
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
onTouchEnd={[Function]}
type="button"
>
Details
<i
aria-hidden="true"
className="Icon-root Icon-sm"
>
keyboard_arrow_right
</i>
</a>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
@@ -175,106 +175,3 @@ it("renders without live configuration when not configurable", async () => {
within(advancedContainer).queryByLabelText("Comment Stream Live Updates")
).toEqual(null);
});
it("change permitted domains to be empty", async () => {
const resolvers = createResolversStub<GQLResolver>({
Mutation: {
updateSettings: ({ variables }) => {
expectAndFail(variables.settings.allowedDomains).toEqual([]);
return {
settings: pureMerge(settings, variables.settings),
};
},
},
});
const {
configureContainer,
advancedContainer,
saveChangesButton,
} = await createTestRenderer({
resolvers,
});
const permittedDomainsField = within(advancedContainer).getByLabelText(
"Permitted domains"
);
// Let's change the permitted domains.
act(() => permittedDomainsField.props.onChange(""));
// Send form
act(() => {
within(configureContainer)
.getByType("form")
.props.onSubmit();
});
// Submit button and text field should be disabled.
expect(saveChangesButton.props.disabled).toBe(true);
expect(permittedDomainsField.props.disabled).toBe(true);
// Wait for submission to be finished
await act(async () => {
await wait(() => {
expect(permittedDomainsField.props.disabled).toBe(false);
});
});
// Should have successfully sent with server.
expect(resolvers.Mutation!.updateSettings!.called).toBe(true);
});
it("change permitted domains to include more domains", async () => {
const resolvers = createResolversStub<GQLResolver>({
Mutation: {
updateSettings: ({ variables }) => {
expectAndFail(variables.settings.allowedDomains).toEqual([
"http://localhost:8080",
"http://localhost:3000",
]);
return {
settings: pureMerge(settings, variables.settings),
};
},
},
});
const {
configureContainer,
advancedContainer,
saveChangesButton,
} = await createTestRenderer({
resolvers,
});
const permittedDomainsField = within(advancedContainer).getByLabelText(
"Permitted domains"
);
// Let's change the permitted domains.
act(() =>
permittedDomainsField.props.onChange(
"http://localhost:8080, http://localhost:3000"
)
);
// Send form
act(() => {
within(configureContainer)
.getByType("form")
.props.onSubmit();
});
// Submit button and text field should be disabled.
expect(saveChangesButton.props.disabled).toBe(true);
expect(permittedDomainsField.props.disabled).toBe(true);
// Wait for submission to be finished
await act(async () => {
await wait(() => {
expect(permittedDomainsField.props.disabled).toBe(false);
});
});
// Should have successfully sent with server.
expect(resolvers.Mutation!.updateSettings!.called).toBe(true);
});
@@ -11,7 +11,7 @@ import {
} from "coral-framework/testHelpers";
import create from "../create";
import { settings, users } from "../fixtures";
import { settings, siteConnection, users } from "../fixtures";
beforeEach(() => {
replaceHistoryLocation("http://localhost/admin/configure/organization");
@@ -29,6 +29,7 @@ async function createTestRenderer(
Query: {
settings: () => settings,
viewer: () => viewer,
sites: () => siteConnection,
},
}),
params.resolvers
@@ -126,69 +127,3 @@ it("change organization name", async () => {
// Should have successfully sent with server.
expect(resolvers.Mutation!.updateSettings!.called).toBe(true);
});
it("change organization contact email", async () => {
const resolvers = createResolversStub<GQLResolver>({
Mutation: {
updateSettings: ({ variables }) => {
expectAndFail(variables.settings.organization!.contactEmail).toEqual(
"test@coralproject.net"
);
return {
settings: pureMerge(settings, variables.settings),
};
},
},
});
const {
configureContainer,
organizationContainer,
saveChangesButton,
} = await createTestRenderer({ resolvers });
const organizationEmailField = within(organizationContainer).getByLabelText(
"Organization email"
);
// Let's change some organization name.
act(() => organizationEmailField.props.onChange(""));
// Send form
act(() => {
within(configureContainer)
.getByType("form")
.props.onSubmit();
});
// Should show validation error.
within(organizationContainer).getByText("This field is required.");
// Let's change to some valid organization name.
act(() => organizationEmailField.props.onChange("test@coralproject.net"));
// Should not show validation error.
expect(
within(organizationContainer).queryByText("This field is required.")
).toBeNull();
// Send form
act(() => {
within(configureContainer)
.getByType("form")
.props.onSubmit();
});
// Submit button and text field should be disabled.
expect(saveChangesButton.props.disabled).toBe(true);
expect(organizationEmailField.props.disabled).toBe(true);
// Wait for submission to be finished
await act(async () => {
await wait(() => {
expect(organizationEmailField.props.disabled).toBe(false);
});
});
// Should have successfully sent with server.
expect(resolvers.Mutation!.updateSettings!.called).toBe(true);
});
+37 -1
View File
@@ -13,6 +13,8 @@ import {
GQLMODERATION_MODE,
GQLModerationQueues,
GQLSettings,
GQLSite,
GQLSitesConnection,
GQLStoriesConnection,
GQLStory,
GQLSTORY_STATUS,
@@ -65,7 +67,6 @@ export const settings = createFixture<GQLSettings>({
smtp: {},
},
customCSSURL: "",
allowedDomains: ["http://localhost:8080"],
editCommentWindowLength: 30000,
communityGuidelines: {
enabled: false,
@@ -169,6 +170,7 @@ export const settings = createFixture<GQLSettings>({
slack: {
channels: [],
},
multisite: false,
});
export const settingsWithEmptyAuth = createFixture<GQLSettings>(
@@ -236,6 +238,28 @@ export const settingsWithEmptyAuth = createFixture<GQLSettings>(
settings
);
export const site = createFixture<GQLSite>({
name: "Test Site",
id: "site-id",
createdAt: "2018-05-06T18:24:00.000Z",
allowedOrigins: ["http://test-site.com"],
});
export const sites = createFixtures<GQLSite>([
{
name: "Test Site",
id: "site-1",
createdAt: "2018-07-06T18:24:00.000Z",
allowedOrigins: ["http://test-site.com"],
},
{
name: "Second Site",
id: "site-2",
createdAt: "2018-09-06T18:24:00.000Z",
allowedOrigins: ["http://test-2-site.com"],
},
]);
export const moderationActions = createFixtures<GQLCommentModerationAction>([
{
id: "07e8f815-e165-4b5d-b438-7163415c8cf7",
@@ -463,6 +487,7 @@ export const stories = createFixtures<GQLStory>([
title: "Finally a Cure for Cancer",
publishedAt: "2018-11-29T16:01:51.897Z",
},
site: sites[0],
},
{
id: "story-2",
@@ -476,6 +501,7 @@ export const stories = createFixtures<GQLStory>([
title: "First Colony on Mars",
publishedAt: "2018-11-29T16:01:51.897Z",
},
site: sites[1],
},
{
id: "story-3",
@@ -489,6 +515,7 @@ export const stories = createFixtures<GQLStory>([
title: "World hunger has been defeated",
publishedAt: "2018-11-29T16:01:51.897Z",
},
site: sites[1],
},
]);
@@ -548,6 +575,7 @@ export const baseComment = createFixture<GQLComment>({
nodes: [],
},
story: stories[0],
site: sites[0],
// TODO: Should be allowed to pass null here..
parent: undefined,
deleted: undefined,
@@ -828,3 +856,11 @@ export const disabledLocalRegistration = createFixture<GQLSettings>(
},
})
);
export const siteConnection = createFixture<GQLSitesConnection>({
edges: [
{ node: sites[0], cursor: sites[0].createdAt },
{ node: sites[1], cursor: sites[1].createdAt },
],
pageInfo: { endCursor: null, hasNextPage: false },
});
@@ -134,14 +134,18 @@ exports[`approves comment in reported queue: dangling 1`] = `
</span>
</div>
<div
className="ModerateCard-storyTitle"
className="ModerateCard-commentOn"
>
Finally a Cure for Cancer
<span
className="ModerateCard-storyTitle"
>
Finally a Cure for Cancer
</span>
</div>
<div>
<a
className="TextLink-root ModerateCard-link"
href="/admin/moderate/story-1"
href="/admin/moderate/stories/story-1"
onClick={[Function]}
>
Moderate Story
@@ -387,14 +391,18 @@ exports[`rejects comment in reported queue: dangling 1`] = `
</span>
</div>
<div
className="ModerateCard-storyTitle"
className="ModerateCard-commentOn"
>
Finally a Cure for Cancer
<span
className="ModerateCard-storyTitle"
>
Finally a Cure for Cancer
</span>
</div>
<div>
<a
className="TextLink-root ModerateCard-link"
href="/admin/moderate/story-1"
href="/admin/moderate/stories/story-1"
onClick={[Function]}
>
Moderate Story
@@ -637,14 +645,18 @@ exports[`renders reported queue with comments 1`] = `
</span>
</div>
<div
className="ModerateCard-storyTitle"
className="ModerateCard-commentOn"
>
Finally a Cure for Cancer
<span
className="ModerateCard-storyTitle"
>
Finally a Cure for Cancer
</span>
</div>
<div>
<a
className="TextLink-root ModerateCard-link"
href="/admin/moderate/story-1"
href="/admin/moderate/stories/story-1"
onClick={[Function]}
>
Moderate Story
@@ -874,14 +886,18 @@ exports[`renders reported queue with comments 1`] = `
</span>
</div>
<div
className="ModerateCard-storyTitle"
className="ModerateCard-commentOn"
>
Finally a Cure for Cancer
<span
className="ModerateCard-storyTitle"
>
Finally a Cure for Cancer
</span>
</div>
<div>
<a
className="TextLink-root ModerateCard-link"
href="/admin/moderate/story-1"
href="/admin/moderate/stories/story-1"
onClick={[Function]}
>
Moderate Story
@@ -1127,14 +1143,18 @@ exports[`renders reported queue with comments 2`] = `
</span>
</div>
<div
className="ModerateCard-storyTitle"
className="ModerateCard-commentOn"
>
Finally a Cure for Cancer
<span
className="ModerateCard-storyTitle"
>
Finally a Cure for Cancer
</span>
</div>
<div>
<a
className="TextLink-root ModerateCard-link"
href="/admin/moderate/story-1"
href="/admin/moderate/stories/story-1"
onClick={[Function]}
>
Moderate Story
@@ -1364,14 +1384,18 @@ exports[`renders reported queue with comments 2`] = `
</span>
</div>
<div
className="ModerateCard-storyTitle"
className="ModerateCard-commentOn"
>
Finally a Cure for Cancer
<span
className="ModerateCard-storyTitle"
>
Finally a Cure for Cancer
</span>
</div>
<div>
<a
className="TextLink-root ModerateCard-link"
href="/admin/moderate/story-1"
href="/admin/moderate/stories/story-1"
onClick={[Function]}
>
Moderate Story
@@ -1607,14 +1631,18 @@ exports[`renders reported queue with comments and load more 1`] = `
</span>
</div>
<div
className="ModerateCard-storyTitle"
className="ModerateCard-commentOn"
>
Finally a Cure for Cancer
<span
className="ModerateCard-storyTitle"
>
Finally a Cure for Cancer
</span>
</div>
<div>
<a
className="TextLink-root ModerateCard-link"
href="/admin/moderate/story-1"
href="/admin/moderate/stories/story-1"
onClick={[Function]}
>
Moderate Story
@@ -134,14 +134,18 @@ exports[`approves comment in rejected queue: dangling 1`] = `
</span>
</div>
<div
className="ModerateCard-storyTitle"
className="ModerateCard-commentOn"
>
Finally a Cure for Cancer
<span
className="ModerateCard-storyTitle"
>
Finally a Cure for Cancer
</span>
</div>
<div>
<a
className="TextLink-root ModerateCard-link"
href="/admin/moderate/story-1"
href="/admin/moderate/stories/story-1"
onClick={[Function]}
>
Moderate Story
@@ -384,14 +388,18 @@ exports[`renders rejected queue with comments 1`] = `
</span>
</div>
<div
className="ModerateCard-storyTitle"
className="ModerateCard-commentOn"
>
Finally a Cure for Cancer
<span
className="ModerateCard-storyTitle"
>
Finally a Cure for Cancer
</span>
</div>
<div>
<a
className="TextLink-root ModerateCard-link"
href="/admin/moderate/story-1"
href="/admin/moderate/stories/story-1"
onClick={[Function]}
>
Moderate Story
@@ -621,14 +629,18 @@ exports[`renders rejected queue with comments 1`] = `
</span>
</div>
<div
className="ModerateCard-storyTitle"
className="ModerateCard-commentOn"
>
Finally a Cure for Cancer
<span
className="ModerateCard-storyTitle"
>
Finally a Cure for Cancer
</span>
</div>
<div>
<a
className="TextLink-root ModerateCard-link"
href="/admin/moderate/story-1"
href="/admin/moderate/stories/story-1"
onClick={[Function]}
>
Moderate Story
@@ -864,14 +876,18 @@ exports[`renders rejected queue with comments and load more 1`] = `
</span>
</div>
<div
className="ModerateCard-storyTitle"
className="ModerateCard-commentOn"
>
Finally a Cure for Cancer
<span
className="ModerateCard-storyTitle"
>
Finally a Cure for Cancer
</span>
</div>
<div>
<a
className="TextLink-root ModerateCard-link"
href="/admin/moderate/story-1"
href="/admin/moderate/stories/story-1"
onClick={[Function]}
>
Moderate Story
@@ -19,6 +19,8 @@ import {
emptyRejectedComments,
featuredComments,
settings,
site,
siteConnection,
unmoderatedComments,
users,
} from "../fixtures";
@@ -37,6 +39,8 @@ async function createTestRenderer(
viewer: () => viewer,
moderationQueues: () => emptyModerationQueues,
comments: () => emptyRejectedComments,
site: () => site,
sites: () => siteConnection,
},
}),
params.resolvers
@@ -20,6 +20,8 @@ import {
emptyRejectedComments,
reportedComments,
settings,
site,
siteConnection,
users,
} from "../fixtures";
@@ -41,6 +43,8 @@ async function createTestRenderer(
viewer: () => viewer,
moderationQueues: () => emptyModerationQueues,
comments: () => emptyRejectedComments,
site: () => site,
sites: () => siteConnection,
},
}),
params.resolvers
@@ -25,6 +25,8 @@ import {
emptyRejectedComments,
reportedComments,
settings,
site,
siteConnection,
users,
} from "../fixtures";
@@ -43,9 +45,11 @@ async function createTestRenderer(
createResolversStub<GQLResolver>({
Query: {
settings: () => settings,
site: () => site,
viewer: () => viewer,
moderationQueues: () => emptyModerationQueues,
comments: () => emptyRejectedComments,
sites: () => siteConnection,
},
}),
params.resolvers
@@ -267,7 +271,7 @@ it("shows a moderate story", async () => {
moderateStory.props.onClick({});
// Expect a routing request was made to the right url.
expect(transitionControl.history[0].pathname).toBe(
`/admin/moderate/${reportedComments[0].story.id}`
`/admin/moderate/stories/${reportedComments[0].story.id}`
);
});
});
@@ -20,6 +20,8 @@ import {
emptyRejectedComments,
reportedComments,
settings,
site,
siteConnection,
users,
} from "../fixtures";
@@ -38,6 +40,8 @@ async function createTestRenderer(
viewer: () => viewer,
moderationQueues: () => emptyModerationQueues,
comments: () => emptyRejectedComments,
sites: () => siteConnection,
site: () => site,
},
}),
params.resolvers
@@ -21,6 +21,8 @@ import {
emptyRejectedComments,
reportedComments,
settings,
site,
siteConnection,
users,
} from "../fixtures";
@@ -38,6 +40,8 @@ async function createTestRenderer(
Query: {
settings: () => settings,
viewer: () => viewer,
sites: () => siteConnection,
site: () => site,
moderationQueues: () =>
pureMerge(emptyModerationQueues, {
reported: {
@@ -22,6 +22,8 @@ import {
rejectedComments,
reportedComments,
settings,
site,
siteConnection,
users,
} from "../fixtures";
@@ -43,6 +45,8 @@ async function createTestRenderer(
viewer: () => viewer,
moderationQueues: () => emptyModerationQueues,
comments: () => emptyRejectedComments,
site: () => site,
sites: () => siteConnection,
},
}),
params.resolvers
@@ -136,7 +140,7 @@ it("shows a moderate story", async () => {
moderateStory.props.onClick({});
// Expect a routing request was made to the right url.
expect(transitionControl.history[0].pathname).toBe(
`/admin/moderate/${reportedComments[0].story.id}`
`/admin/moderate/stories/${reportedComments[0].story.id}`
);
});
@@ -20,6 +20,8 @@ import {
emptyRejectedComments,
emptyStories,
settings,
site,
siteConnection,
stories,
storyConnection,
users,
@@ -43,6 +45,8 @@ async function createTestRenderer(
viewer: () => viewer,
moderationQueues: () => emptyModerationQueues,
comments: () => emptyRejectedComments,
site: () => site,
sites: () => siteConnection,
},
}),
params.resolvers
@@ -165,7 +169,7 @@ describe("all stories", () => {
// Expect a routing request was made to the right url.
expect(transitionControl.history[0].pathname).toBe(
`/admin/moderate/${story.id}`
`/admin/moderate/stories/${story.id}`
);
});
it("search with too many results", async () => {
@@ -216,7 +220,9 @@ describe("all stories", () => {
});
describe("specified story", () => {
beforeEach(() => {
replaceHistoryLocation(`http://localhost/admin/moderate/${stories[0].id}`);
replaceHistoryLocation(
`http://localhost/admin/moderate/stories/${stories[0].id}`
);
});
it("renders search bar", async () => {
await act(async () => {

Some files were not shown because too many files have changed in this diff Show More