mirror of
https://github.com/wassname/talk.git
synced 2026-09-09 11:38:08 +08:00
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:
co-authored by
Wyatt Johnson
parent
014aa2d86a
commit
707d65a119
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
`,
|
||||
|
||||
+6
-2
@@ -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,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 {
|
||||
|
||||
@@ -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;
|
||||
+4
-8
@@ -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);
|
||||
|
||||
+13
-5
@@ -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;
|
||||
+4
-26
@@ -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 site’s 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));
|
||||
|
||||
+10
-1
@@ -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>
|
||||
|
||||
+62
-11
@@ -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 site’s 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);
|
||||
});
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user