diff --git a/src/core/build/createWebpackConfig.ts b/src/core/build/createWebpackConfig.ts index 0f063f3b9..898e934d8 100644 --- a/src/core/build/createWebpackConfig.ts +++ b/src/core/build/createWebpackConfig.ts @@ -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]", diff --git a/src/core/client/admin/App/GlobalNotification/GlobalNotificationContext.tsx b/src/core/client/admin/App/GlobalNotification/GlobalNotificationContext.tsx new file mode 100644 index 000000000..3c963c459 --- /dev/null +++ b/src/core/client/admin/App/GlobalNotification/GlobalNotificationContext.tsx @@ -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({ + 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 ; +} + +export { NotificationProvider, NotificationContext }; diff --git a/src/core/client/admin/App/GlobalNotification/NotificationContainer.tsx b/src/core/client/admin/App/GlobalNotification/NotificationContainer.tsx new file mode 100644 index 000000000..6a9da2619 --- /dev/null +++ b/src/core/client/admin/App/GlobalNotification/NotificationContainer.tsx @@ -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
{state.message}
; +}; + +export default NotificationContainer; diff --git a/src/core/client/admin/App/GlobalNotification/index.ts b/src/core/client/admin/App/GlobalNotification/index.ts new file mode 100644 index 000000000..896c4d306 --- /dev/null +++ b/src/core/client/admin/App/GlobalNotification/index.ts @@ -0,0 +1,3 @@ +export { NotificationProvider } from "./GlobalNotificationContext"; +export { default as NotificationContainer } from "./NotificationContainer"; +export { default as useNotification } from "./useNotification"; diff --git a/src/core/client/admin/App/GlobalNotification/useNotification.ts b/src/core/client/admin/App/GlobalNotification/useNotification.ts new file mode 100644 index 000000000..9aea0cac6 --- /dev/null +++ b/src/core/client/admin/App/GlobalNotification/useNotification.ts @@ -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; diff --git a/src/core/client/admin/App/Main.tsx b/src/core/client/admin/App/Main.tsx index 28cd1a922..99d77a780 100644 --- a/src/core/client/admin/App/Main.tsx +++ b/src/core/client/admin/App/Main.tsx @@ -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 = ({ children, viewer }) => ( - {children} + + + {children} + ); diff --git a/src/core/client/admin/App/__snapshots__/Main.spec.tsx.snap b/src/core/client/admin/App/__snapshots__/Main.spec.tsx.snap index 7a5a9c95f..192e75437 100644 --- a/src/core/client/admin/App/__snapshots__/Main.spec.tsx.snap +++ b/src/core/client/admin/App/__snapshots__/Main.spec.tsx.snap @@ -28,7 +28,10 @@ exports[`renders correctly 1`] = ` /> - child + + + child + `; diff --git a/src/core/client/admin/components/ModerateCard/ModerateCard.css b/src/core/client/admin/components/ModerateCard/ModerateCard.css index bb8375909..84aafec8f 100644 --- a/src/core/client/admin/components/ModerateCard/ModerateCard.css +++ b/src/core/client/admin/components/ModerateCard/ModerateCard.css @@ -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; diff --git a/src/core/client/admin/components/ModerateCard/ModerateCard.spec.tsx b/src/core/client/admin/components/ModerateCard/ModerateCard.spec.tsx index 850dec2b1..59a505cf7 100644 --- a/src/core/client/admin/components/ModerateCard/ModerateCard.spec.tsx +++ b/src/core/client/admin/components/ModerateCard/ModerateCard.spec.tsx @@ -23,6 +23,7 @@ const baseProps: PropTypesOf = { viewContextHref: "http://localhost/comment", suspectWords: ["suspect"], bannedWords: ["banned"], + siteName: null, onApprove: noop, onReject: noop, onFeature: noop, diff --git a/src/core/client/admin/components/ModerateCard/ModerateCard.tsx b/src/core/client/admin/components/ModerateCard/ModerateCard.tsx index d45f13a1d..32e873a74 100644 --- a/src/core/client/admin/components/ModerateCard/ModerateCard.tsx +++ b/src/core/client/admin/components/ModerateCard/ModerateCard.tsx @@ -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; onApprove: () => void; onReject: () => void; @@ -96,6 +98,7 @@ const ModerateCard: FunctionComponent = ({ storyTitle, storyHref, onModerateStory, + siteName, moderatedBy, selected, onFocusOrClick, @@ -250,7 +253,15 @@ const ModerateCard: FunctionComponent = ({ : -
{storyTitle}
+
+ {siteName && ( + + {siteName} + keyboard_arrow_right + + )} + {storyTitle} +
= ({ 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 = ({ selected={selected} selectPrev={selectPrev} selectNext={selectNext} + siteName={settings.multisite ? comment.site.name : null} onBan={openBanModal} moderatedBy={ = ({ ) } - 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({ title } } + site { + id + name + } permalink enteredLive deleted @@ -314,6 +319,7 @@ const enhanced = withFragmentContainer({ banned suspect } + multisite ...MarkersContainer_settings } `, diff --git a/src/core/client/admin/components/ModerateCard/__snapshots__/ModerateCard.spec.tsx.snap b/src/core/client/admin/components/ModerateCard/__snapshots__/ModerateCard.spec.tsx.snap index 88fd87c7a..e6cfd6121 100644 --- a/src/core/client/admin/components/ModerateCard/__snapshots__/ModerateCard.spec.tsx.snap +++ b/src/core/client/admin/components/ModerateCard/__snapshots__/ModerateCard.spec.tsx.snap @@ -761,9 +761,13 @@ exports[`renders story info 1`] = `
- Cancer cured! + + Cancer cured! +
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; +} diff --git a/src/core/client/admin/components/PaginatedSelect/PaginatedSelect.tsx b/src/core/client/admin/components/PaginatedSelect/PaginatedSelect.tsx new file mode 100644 index 000000000..7f33dc885 --- /dev/null +++ b/src/core/client/admin/components/PaginatedSelect/PaginatedSelect.tsx @@ -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 = ({ + loading, + onLoadMore, + disableLoadMore, + hasMore, + children, + icon, + selected, + className, +}) => { + return ( + ( + + + + {children} + {loading && ( + + + + )} + {hasMore && ( + + + + )} + + + + )} + > + {({ toggleVisibility, ref, visible }) => ( + + )} + + ); +}; + +export default PaginatedSelect; diff --git a/src/core/client/admin/components/PaginatedSelect/index.ts b/src/core/client/admin/components/PaginatedSelect/index.ts new file mode 100644 index 000000000..bcf587a6a --- /dev/null +++ b/src/core/client/admin/components/PaginatedSelect/index.ts @@ -0,0 +1 @@ +export { default } from "./PaginatedSelect"; diff --git a/src/core/client/admin/helpers/getModerationLink.ts b/src/core/client/admin/helpers/getModerationLink.ts deleted file mode 100644 index a8707b1e0..000000000 --- a/src/core/client/admin/helpers/getModerationLink.ts +++ /dev/null @@ -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}`; -} diff --git a/src/core/client/admin/helpers/getQueueConnection.ts b/src/core/client/admin/helpers/getQueueConnection.ts index ae16fd954..bded7865a 100644 --- a/src/core/client/admin/helpers/getQueueConnection.ts +++ b/src/core/client/admin/helpers/getQueueConnection.ts @@ -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; } diff --git a/src/core/client/admin/helpers/index.ts b/src/core/client/admin/helpers/index.ts index 56b0ff727..1555be6e2 100644 --- a/src/core/client/admin/helpers/index.ts +++ b/src/core/client/admin/helpers/index.ts @@ -1,2 +1 @@ export { default as getQueueConnection } from "./getQueueConnection"; -export { default as getModerationLink } from "./getModerationLink"; diff --git a/src/core/client/admin/local/local.graphql b/src/core/client/admin/local/local.graphql index eff5f0146..a9516b153 100644 --- a/src/core/client/admin/local/local.graphql +++ b/src/core/client/admin/local/local.graphql @@ -31,6 +31,7 @@ type Local { redirectPath: String authView: View authError: String + siteID: String } extend type Query { diff --git a/src/core/client/admin/routeConfig.tsx b/src/core/client/admin/routeConfig.tsx index d6a8eabc1..bcc148404 100644 --- a/src/core/client/admin/routeConfig.tsx +++ b/src/core/client/admin/routeConfig.tsx @@ -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( - + + - + + + - - + + + + @@ -78,6 +113,11 @@ export default makeRouteConfig( + + + + + diff --git a/src/core/client/admin/routes/Configure/Configure.tsx b/src/core/client/admin/routes/Configure/Configure.tsx index 346b528be..a7c9d2ce6 100644 --- a/src/core/client/admin/routes/Configure/Configure.tsx +++ b/src/core/client/admin/routes/Configure/Configure.tsx @@ -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 = ({ - - - General - - - Organization - - - Moderation - - - - Banned and Suspect Words - - - - Authentication - - - Email - - - Slack - - - Advanced - - + diff --git a/src/core/client/admin/routes/Configure/ConfigureLinks.tsx b/src/core/client/admin/routes/Configure/ConfigureLinks.tsx new file mode 100644 index 000000000..64c0531dd --- /dev/null +++ b/src/core/client/admin/routes/Configure/ConfigureLinks.tsx @@ -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 ( + + + General + + + Organization + + + Moderation + + + Banned and Suspect Words + + + Authentication + + + Email + + + Slack + + + Advanced + + + ); +}; + +export default ConfigureLinks; diff --git a/src/core/client/admin/routes/Configure/sections/Advanced/AdvancedConfigContainer.tsx b/src/core/client/admin/routes/Configure/sections/Advanced/AdvancedConfigContainer.tsx index 00fa67f46..b6de315d4 100644 --- a/src/core/client/admin/routes/Configure/sections/Advanced/AdvancedConfigContainer.tsx +++ b/src/core/client/admin/routes/Configure/sections/Advanced/AdvancedConfigContainer.tsx @@ -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 = ({ useMemo(() => form.initialize(purgeMetadata(settings)), []); return ( - - ); @@ -45,11 +41,8 @@ const enhanced = withFragmentContainer({ 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 } `, diff --git a/src/core/client/admin/routes/Configure/sections/Advanced/EmbedCodeContainer.tsx b/src/core/client/admin/routes/Configure/sections/Advanced/EmbedCodeContainer.tsx deleted file mode 100644 index 268e0a0a2..000000000 --- a/src/core/client/admin/routes/Configure/sections/Advanced/EmbedCodeContainer.tsx +++ /dev/null @@ -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 = ({ settings }) => { - return ; -}; - -const enhanced = withFragmentContainer({ - settings: graphql` - fragment EmbedCodeContainer_settings on Settings { - staticURI - } - `, -})(EmbedCodeContainer); - -export default enhanced; diff --git a/src/core/client/admin/routes/Configure/sections/Advanced/PermittedDomainsConfig.tsx b/src/core/client/admin/routes/Configure/sections/Advanced/PermittedDomainsConfig.tsx deleted file mode 100644 index 6cf992571..000000000 --- a/src/core/client/admin/routes/Configure/sections/Advanced/PermittedDomainsConfig.tsx +++ /dev/null @@ -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 = ({ disabled }) => ( - -
- Permitted domains -
-
- } - > - - } - > - - 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). - - - - {({ input, meta }) => ( - - )} - - - -); - -export default PermittedDomainsConfig; diff --git a/src/core/client/admin/routes/Configure/sections/Organization/EmptySitesMessage.tsx b/src/core/client/admin/routes/Configure/sections/Organization/EmptySitesMessage.tsx new file mode 100644 index 000000000..4ebc7ceab --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/Organization/EmptySitesMessage.tsx @@ -0,0 +1,10 @@ +import { Localized } from "@fluent/react/compat"; +import React, { FunctionComponent } from "react"; + +const EmptySitesMessage: FunctionComponent = props => ( + +
We could not find any sites matching your criteria.
+
+); + +export default EmptySitesMessage; diff --git a/src/core/client/admin/routes/Configure/sections/Organization/OrganizationConfigContainer.tsx b/src/core/client/admin/routes/Configure/sections/Organization/OrganizationConfigContainer.tsx index 429820ad0..ec8e43ab2 100644 --- a/src/core/client/admin/routes/Configure/sections/Organization/OrganizationConfigContainer.tsx +++ b/src/core/client/admin/routes/Configure/sections/Organization/OrganizationConfigContainer.tsx @@ -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 = ({ const form = useForm(); useMemo(() => form.initialize(purgeMetadata(settings)), []); return ( - + <> - - + + ); }; const enhanced = withFragmentContainer({ 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); diff --git a/src/core/client/admin/routes/Configure/sections/Organization/OrganizationConfigRoute.tsx b/src/core/client/admin/routes/Configure/sections/Organization/OrganizationConfigRoute.tsx index e5f3c49f2..1e2d4480e 100644 --- a/src/core/client/admin/routes/Configure/sections/Organization/OrganizationConfigRoute.tsx +++ b/src/core/client/admin/routes/Configure/sections/Organization/OrganizationConfigRoute.tsx @@ -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 { ); } return ( - + + + + ); } } @@ -37,6 +44,7 @@ const enhanced = withRouteConfig({ settings { ...OrganizationConfigContainer_settings } + ...SitesConfigContainer_query } `, cacheConfig: { force: true }, diff --git a/src/core/client/admin/routes/Configure/sections/Organization/SiteRowContainer.tsx b/src/core/client/admin/routes/Configure/sections/Organization/SiteRowContainer.tsx new file mode 100644 index 000000000..09aad23a1 --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/Organization/SiteRowContainer.tsx @@ -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 = ({ site }) => { + return ( + + {site.name} + + + keyboard_arrow_right} + > + + + + + + ); +}; + +const enhanced = withFragmentContainer({ + site: graphql` + fragment SiteRowContainer_site on Site { + id + name + createdAt + } + `, +})(SiteRowContainer); + +export default enhanced; diff --git a/src/core/client/admin/routes/Configure/sections/Organization/SitesConfig.tsx b/src/core/client/admin/routes/Configure/sections/Organization/SitesConfig.tsx new file mode 100644 index 000000000..694faf4b1 --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/Organization/SitesConfig.tsx @@ -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["site"]>; + onLoadMore: () => void; + hasMore: boolean; + disableLoadMore: boolean; + loading: boolean; +} + +const SitesConfig: FunctionComponent = ({ + sites, + loading, + disableLoadMore, + onLoadMore, + hasMore, +}) => { + return ( + +
+ Sites +
+
+ } + > + + + Add a new site to your organization or edit an existing site's + details. + + + add} + > + + + + + ); +}; + +export default SitesConfig; diff --git a/src/core/client/admin/routes/Configure/sections/Organization/SitesConfigContainer.tsx b/src/core/client/admin/routes/Configure/sections/Organization/SitesConfigContainer.tsx new file mode 100644 index 000000000..bd813e1cc --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/Organization/SitesConfigContainer.tsx @@ -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 => { + 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 ( + + + + ); +}; + +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; diff --git a/src/core/client/admin/routes/Configure/sections/Organization/SitesTable.tsx b/src/core/client/admin/routes/Configure/sections/Organization/SitesTable.tsx new file mode 100644 index 000000000..246a6625c --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/Organization/SitesTable.tsx @@ -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["site"]>; + onLoadMore: () => void; + hasMore: boolean; + disableLoadMore: boolean; + loading: boolean; +} + +const SitesTable: FunctionComponent = props => { + return ( + <> + + + + + Site name + + + + + + {props.sites.map(site => ( + + ))} + +
+ {!props.loading && props.sites.length === 0 && } + {props.loading && ( + + + + )} + {props.hasMore && ( + + + + )} + + ); +}; + +export default SitesTable; diff --git a/src/core/client/admin/routes/Configure/sections/Sites/AddSiteRoute.tsx b/src/core/client/admin/routes/Configure/sections/Sites/AddSiteRoute.tsx new file mode 100644 index 000000000..ae1fc9c71 --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/Sites/AddSiteRoute.tsx @@ -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 => { + const { router } = useRouter(); + const { setMessage, clearMessage } = useNotification(); + const onSiteCreate = useCallback( + (id: string, name: string) => { + router.replace(`/admin/configure/organization/sites/${id}`); + setMessage( + + + {name} has been added to{" "} + {props.data && props.data.settings.organization.name} + + + ); + }, + [props.data] + ); + if (!props.data) { + return null; + } + return ( + +
+ Add a new site to {props.data.settings.organization.name} +
+ + } + > + +
+ ); +}; + +const enhanced = withRouteConfig({ + query: graphql` + query AddSiteRouteQuery { + settings { + organization { + name + } + } + } + `, + cacheConfig: { force: true }, +})(AddSiteRoute); + +export default enhanced; diff --git a/src/core/client/admin/routes/Configure/sections/Sites/CreateSiteForm.tsx b/src/core/client/admin/routes/Configure/sections/Sites/CreateSiteForm.tsx new file mode 100644 index 000000000..37c70577a --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/Sites/CreateSiteForm.tsx @@ -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 = ({ onCreate }) => { + const createSite = useMutation(CreateSiteMutation); + const [submitError, setSubmitError] = useState(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 ( +
+
+ {({ handleSubmit, invalid, submitting, ...formProps }) => ( + + + + + + + + + + Site name will appear on emails sent by Coral to your + community and organization members. + + + + + {({ input, meta }) => ( + + )} + + + + + + + + + + Domains where your Coral comment streams are allowed to be + embedded (ex. http://localhost:3000, + https://staging.domain.com, https://domain.com). + + + + + {({ input, meta }) => ( + + )} + + + {submitError && ( + + {submitError} + + )} + + + + + add} + > + + + + +
+ )} + +
+ ); +}; + +export default CreateSiteForm; diff --git a/src/core/client/admin/routes/Configure/sections/Sites/CreateSiteMutation.ts b/src/core/client/admin/routes/Configure/sections/Sites/CreateSiteMutation.ts new file mode 100644 index 000000000..6702c5c04 --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/Sites/CreateSiteMutation.ts @@ -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, + { uuidGenerator }: CoralContext + ) => { + const id = uuidGenerator(); + const now = new Date(); + return commitMutationPromiseNormalized(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; diff --git a/src/core/client/admin/routes/Configure/sections/Sites/EditSiteForm.tsx b/src/core/client/admin/routes/Configure/sections/Sites/EditSiteForm.tsx new file mode 100644 index 000000000..e4a8ca4ee --- /dev/null +++ b/src/core/client/admin/routes/Configure/sections/Sites/EditSiteForm.tsx @@ -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 = ({ + site, + settings, + onEditSuccess, +}) => { + const updateSite = useMutation(UpdateSiteMutation); + const [submitError, setSubmitError] = useState(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 ( +
+
+ {({ handleSubmit, invalid, submitting, ...formProps }) => ( + + + + + + + + + + Site name will appear on emails sent by Coral to your + community and organization members. + + + + + {({ input, meta }) => ( + + )} + + + + + + + + + + Domains where your Coral comment streams are allowed to be + embedded (ex. http://localhost:3000, + https://staging.domain.com, https://domain.com). + + + + + {({ input, meta }) => ( + + )} + + + + + + + + + + + + {submitError && ( + + {submitError} + + )} + + + + + + +
+ )} + +
+ ); +}; + +const enhanced = withFragmentContainer({ + site: graphql` + fragment EditSiteForm_site on Site { + name + createdAt + id + allowedOrigins + } + `, + settings: graphql` + fragment EditSiteForm_settings on Settings { + staticURI + } + `, +})(EditSiteForm); + +export default enhanced; diff --git a/src/core/client/admin/routes/Configure/sections/Advanced/EmbedCode.css b/src/core/client/admin/routes/Configure/sections/Sites/EmbedCode.css similarity index 100% rename from src/core/client/admin/routes/Configure/sections/Advanced/EmbedCode.css rename to src/core/client/admin/routes/Configure/sections/Sites/EmbedCode.css diff --git a/src/core/client/admin/routes/Configure/sections/Advanced/EmbedCode.tsx b/src/core/client/admin/routes/Configure/sections/Sites/EmbedCode.tsx similarity index 78% rename from src/core/client/admin/routes/Configure/sections/Advanced/EmbedCode.tsx rename to src/core/client/admin/routes/Configure/sections/Sites/EmbedCode.tsx index 712f019b1..dc85f954d 100644 --- a/src/core/client/admin/routes/Configure/sections/Advanced/EmbedCode.tsx +++ b/src/core/client/admin/routes/Configure/sections/Sites/EmbedCode.tsx @@ -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 = ({ staticURI, getMessage }) => { }, [staticURI]); return ( - -
}>Embed code
- - } - container={
} - > - - - Copy and paste the code below into your CMS to embed Coral comment - streams in each of your site’s stories. - - + <>