From 707d65a11901d24b81c3207e125c7a28c2900056 Mon Sep 17 00:00:00 2001 From: Tessa Thornton Date: Wed, 12 Feb 2020 09:22:07 -0500 Subject: [PATCH] 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 --- src/core/build/createWebpackConfig.ts | 5 - .../GlobalNotificationContext.tsx | 45 ++ .../NotificationContainer.tsx | 12 + .../admin/App/GlobalNotification/index.ts | 3 + .../App/GlobalNotification/useNotification.ts | 39 ++ src/core/client/admin/App/Main.tsx | 9 +- .../App/__snapshots__/Main.spec.tsx.snap | 5 +- .../components/ModerateCard/ModerateCard.css | 12 +- .../ModerateCard/ModerateCard.spec.tsx | 1 + .../components/ModerateCard/ModerateCard.tsx | 13 +- .../ModerateCard/ModerateCardContainer.tsx | 12 +- .../__snapshots__/ModerateCard.spec.tsx.snap | 8 +- .../PaginatedSelect/PaginatedSelect.css | 45 ++ .../PaginatedSelect/PaginatedSelect.tsx | 98 +++++ .../admin/components/PaginatedSelect/index.ts | 1 + .../client/admin/helpers/getModerationLink.ts | 10 - .../admin/helpers/getQueueConnection.ts | 9 +- src/core/client/admin/helpers/index.ts | 1 - src/core/client/admin/local/local.graphql | 1 + src/core/client/admin/routeConfig.tsx | 50 ++- .../admin/routes/Configure/Configure.tsx | 32 +- .../admin/routes/Configure/ConfigureLinks.tsx | 38 ++ .../Advanced/AdvancedConfigContainer.tsx | 7 - .../sections/Advanced/EmbedCodeContainer.tsx | 26 -- .../Advanced/PermittedDomainsConfig.tsx | 71 --- .../Organization/EmptySitesMessage.tsx | 10 + .../OrganizationConfigContainer.tsx | 12 +- .../Organization/OrganizationConfigRoute.tsx | 18 +- .../Organization/SiteRowContainer.tsx | 49 +++ .../sections/Organization/SitesConfig.tsx | 67 +++ .../Organization/SitesConfigContainer.tsx | 94 ++++ .../sections/Organization/SitesTable.tsx | 63 +++ .../Configure/sections/Sites/AddSiteRoute.tsx | 75 ++++ .../sections/Sites/CreateSiteForm.tsx | 147 +++++++ .../sections/Sites/CreateSiteMutation.ts | 57 +++ .../Configure/sections/Sites/EditSiteForm.tsx | 167 ++++++++ .../{Advanced => Sites}/EmbedCode.css | 0 .../{Advanced => Sites}/EmbedCode.tsx | 30 +- .../Configure/sections/Sites/SiteRoute.tsx | 66 +++ .../Configure/sections/Sites/SitesRoute.tsx | 27 ++ .../sections/Sites/UpdateSiteMutation.ts | 41 ++ .../routes/Configure/sections/Sites/index.ts | 1 + .../admin/routes/Moderate/Moderate.spec.tsx | 7 + .../client/admin/routes/Moderate/Moderate.tsx | 35 +- .../routes/Moderate/ModerateContainer.tsx | 56 ++- .../ModerateNavigationContainer.tsx | 11 +- .../ModerateNavigation/Navigation.tsx | 27 +- .../routes/Moderate/ModerateSearchBar/Bar.css | 10 +- .../routes/Moderate/ModerateSearchBar/Bar.tsx | 18 +- .../Moderate/ModerateSearchBar/Field.css | 14 +- .../Moderate/ModerateSearchBar/Field.tsx | 16 +- .../ModerateSearchBar/ModerateAllOption.css | 4 + .../ModerateSearchBar/ModerateAllOption.tsx | 9 +- .../ModerateSearchBarContainer.tsx | 73 +++- .../ModerateSearchBar/SearchStoryFetch.ts | 11 +- .../routes/Moderate/Queue/QueueRoute.tsx | 34 +- .../Moderate/Queue/QueueViewNewMutation.tsx | 8 +- .../Moderate/SiteSelector/SiteSelector.css | 8 + .../Moderate/SiteSelector/SiteSelector.tsx | 76 ++++ .../SiteSelector/SiteSelectorContainer.tsx | 101 +++++ .../SiteSelector/SiteSelectorSelected.css | 4 + .../SiteSelector/SiteSelectorSelected.tsx | 26 ++ .../SiteSelector/SiteSelectorSite.css | 14 + .../SiteSelector/SiteSelectorSite.tsx | 44 ++ .../routes/Moderate/SiteSelector/index.ts | 2 + .../__snapshots__/Moderate.spec.tsx.snap | 14 + .../routes/Stories/SiteFilter/SiteFilter.css | 9 + .../routes/Stories/SiteFilter/SiteFilter.tsx | 79 ++++ .../SiteFilter/SiteFilterContainer.tsx | 97 +++++ .../Stories/SiteFilter/SiteFilterOption.css | 9 + .../Stories/SiteFilter/SiteFilterOption.tsx | 56 +++ .../Stories/SiteFilter/SiteFilterSelected.css | 3 + .../Stories/SiteFilter/SiteFilterSelected.tsx | 25 ++ .../admin/routes/Stories/SiteFilter/index.ts | 1 + .../client/admin/routes/Stories/StoryRow.css | 2 + .../client/admin/routes/Stories/StoryRow.tsx | 14 +- .../routes/Stories/StoryRowContainer.tsx | 8 + .../admin/routes/Stories/StoryTable.css | 6 + .../admin/routes/Stories/StoryTable.tsx | 20 +- .../routes/Stories/StoryTableContainer.tsx | 38 +- .../admin/routes/Stories/StoryTableFilter.tsx | 4 +- .../admin/test/auth/redirectLoggedIn.spec.tsx | 2 + .../test/auth/redirectLoggedOut.spec.tsx | 2 + .../admin/test/auth/restricted.spec.tsx | 2 + .../admin/test/auth/signInWithEmail.spec.tsx | 2 + .../client/admin/test/auth/signOut.spec.tsx | 2 + .../__snapshots__/advanced.spec.tsx.snap | 139 ------ .../__snapshots__/organization.spec.tsx.snap | 203 +++++++-- .../admin/test/configure/advanced.spec.tsx | 103 ----- .../test/configure/organization.spec.tsx | 69 +-- src/core/client/admin/test/fixtures.ts | 38 +- .../__snapshots__/regularQueue.spec.tsx.snap | 70 ++- .../__snapshots__/rejectedQueue.spec.tsx.snap | 40 +- .../admin/test/moderate/feature.spec.tsx | 4 + .../admin/test/moderate/liveCounts.spec.tsx | 4 + .../admin/test/moderate/regularQueue.spec.tsx | 6 +- .../regularQueueLiveNewComments.spec.tsx | 4 + .../moderate/regularQueueLiveStatus.spec.tsx | 4 + .../test/moderate/rejectedQueue.spec.tsx | 6 +- .../admin/test/moderate/searchBar.spec.tsx | 10 +- .../admin/test/moderate/tabBar.spec.tsx | 2 + .../__snapshots__/stories.spec.tsx.snap | 404 +++++++++--------- .../admin/test/stories/stories.spec.tsx | 6 +- src/core/client/embed/index.html | 22 +- .../helpers/getModerationLink.spec.ts | 46 ++ .../framework/helpers/getModerationLink.ts | 31 ++ src/core/client/framework/helpers/index.ts | 1 + src/core/client/framework/helpers/urls.tsx | 2 +- .../framework/lib/bootstrap/CoralContext.tsx | 1 + .../framework/lib/bootstrap/withContext.tsx | 2 +- .../lib/relay/withLocalStateContainer.tsx | 2 +- src/core/client/framework/rest/install.ts | 7 +- src/core/client/install/App/Header.tsx | 2 +- src/core/client/install/App/InstallWizard.tsx | 32 +- .../install/App/steps/AddOrganizationStep.tsx | 61 ++- .../App/steps/CreateYourAccountStep.tsx | 6 +- .../client/install/App/steps/FinalStep.tsx | 19 +- .../App/steps/PermittedDomainsStep.tsx | 14 +- src/core/client/stream/local/local.graphql | 1 + .../tabs/Comments/Stream/StreamContainer.tsx | 1 + .../Configure/ModerateStreamContainer.tsx | 4 +- .../closedOrDisabledCommentStream.spec.tsx | 4 +- .../renderConfigure.spec.tsx.snap | 2 +- src/core/client/stream/test/fixtures.ts | 10 + src/core/client/test/helpers/fixture.ts | 1 - .../components/AbsoluteTime/AbsoluteTime.tsx | 13 +- .../ui/components/UIContext/UIContext.ts | 4 + .../client/ui/components/UIContext/index.ts | 2 +- src/core/client/ui/components/index.ts | 6 +- .../v2/AppNotification/AppNotification.css | 15 + .../v2/AppNotification/AppNotification.tsx | 49 +++ .../ui/components/v2/AppNotification/index.ts | 1 + .../components/v2/BaseButton/BaseButton.tsx | 7 + .../client/ui/components/v2/Button/Button.css | 6 +- .../client/ui/components/v2/Button/Button.tsx | 8 +- .../components/v2/SelectField/SelectField.css | 5 +- src/core/client/ui/components/v2/index.ts | 1 + src/core/common/errors.ts | 8 +- .../common/helpers/isNonNullArray.spec.ts | 11 + src/core/common/helpers/isNonNullArray.ts | 9 + src/core/common/types.ts | 10 + src/core/server/app/handlers/api/install.ts | 19 +- .../server/app/handlers/api/story/count.ts | 4 +- .../server/app/middleware/csp/tenant.spec.ts | 44 +- src/core/server/app/middleware/csp/tenant.ts | 106 ++++- src/core/server/app/router/client.ts | 23 +- src/core/server/app/router/index.ts | 1 + src/core/server/app/url.ts | 2 +- src/core/server/errors/index.ts | 32 +- src/core/server/errors/translations.ts | 2 +- src/core/server/graph/loaders/Comments.ts | 3 +- src/core/server/graph/loaders/Sites.ts | 25 ++ src/core/server/graph/loaders/Stories.ts | 14 +- src/core/server/graph/loaders/index.ts | 2 + src/core/server/graph/mutators/Comments.ts | 13 +- src/core/server/graph/mutators/Sites.ts | 14 + src/core/server/graph/mutators/Stories.ts | 8 +- src/core/server/graph/mutators/index.ts | 2 + src/core/server/graph/resolvers/Comment.ts | 1 + .../server/graph/resolvers/CommentCounts.ts | 7 +- .../server/graph/resolvers/ModerationQueue.ts | 2 +- .../graph/resolvers/ModerationQueues.ts | 48 ++- src/core/server/graph/resolvers/Mutation.ts | 7 + src/core/server/graph/resolvers/Query.ts | 2 + src/core/server/graph/resolvers/Settings.ts | 6 +- src/core/server/graph/resolvers/Story.ts | 1 + src/core/server/graph/schema/schema.graphql | 146 ++++++- src/core/server/locales/da/errors.ftl | 1 - src/core/server/locales/en-US/errors.ftl | 2 +- src/core/server/locales/fi-FI/errors.ftl | 3 +- src/core/server/locales/fr-FR/errors.ftl | 1 - src/core/server/locales/pt-BR/errors.ftl | 1 - src/core/server/models/action/comment.spec.ts | 3 + src/core/server/models/action/comment.ts | 7 +- src/core/server/models/comment/comment.ts | 14 +- .../index.ts => comment/counts/counts.ts} | 197 ++++----- .../server/models/comment/counts/empty.ts | 41 ++ .../server/models/comment/counts/index.ts | 3 + .../{story => comment}/counts/shared.ts | 60 +-- src/core/server/models/comment/helpers.ts | 29 +- src/core/server/models/comment/index.ts | 1 + src/core/server/models/site/index.ts | 175 ++++++++ src/core/server/models/story/counts/empty.ts | 19 - src/core/server/models/story/index.ts | 67 ++- src/core/server/models/tenant/tenant.ts | 7 +- src/core/server/models/user/user.ts | 28 +- src/core/server/services/comments/actions.ts | 94 ++-- .../services/comments/moderation/counts.ts | 9 +- .../services/comments/pipeline/pipeline.ts | 2 +- src/core/server/services/events/comments.ts | 7 +- src/core/server/services/migrate/batch.ts | 80 ++++ src/core/server/services/migrate/migration.ts | 15 +- .../migrations/1579189174931_create_sites.ts | 235 ++++++++++ .../1579878509162_add_indexes_to_sites.ts | 17 + .../server/services/mongodb/collections.ts | 7 +- src/core/server/services/sites/index.ts | 72 ++++ src/core/server/services/sites/url.spec.ts | 82 ++++ .../server/services/{tenant => sites}/url.ts | 14 +- src/core/server/services/stories/index.ts | 145 +++---- src/core/server/services/tenant/url.spec.ts | 105 ----- src/core/server/stacks/approveComment.ts | 8 +- src/core/server/stacks/createComment.ts | 23 +- src/core/server/stacks/editComment.ts | 30 +- src/core/server/stacks/helpers/index.ts | 2 +- .../server/stacks/helpers/publishChanges.ts | 6 +- ...AllCounts.ts => updateAllCommentCounts.ts} | 56 ++- src/core/server/stacks/rejectComment.ts | 8 +- src/locales/en-US/admin.ftl | 33 ++ src/locales/en-US/install.ftl | 64 ++- src/locales/pt-BR/install.ftl | 2 +- 210 files changed, 4695 insertions(+), 1632 deletions(-) create mode 100644 src/core/client/admin/App/GlobalNotification/GlobalNotificationContext.tsx create mode 100644 src/core/client/admin/App/GlobalNotification/NotificationContainer.tsx create mode 100644 src/core/client/admin/App/GlobalNotification/index.ts create mode 100644 src/core/client/admin/App/GlobalNotification/useNotification.ts create mode 100644 src/core/client/admin/components/PaginatedSelect/PaginatedSelect.css create mode 100644 src/core/client/admin/components/PaginatedSelect/PaginatedSelect.tsx create mode 100644 src/core/client/admin/components/PaginatedSelect/index.ts delete mode 100644 src/core/client/admin/helpers/getModerationLink.ts create mode 100644 src/core/client/admin/routes/Configure/ConfigureLinks.tsx delete mode 100644 src/core/client/admin/routes/Configure/sections/Advanced/EmbedCodeContainer.tsx delete mode 100644 src/core/client/admin/routes/Configure/sections/Advanced/PermittedDomainsConfig.tsx create mode 100644 src/core/client/admin/routes/Configure/sections/Organization/EmptySitesMessage.tsx create mode 100644 src/core/client/admin/routes/Configure/sections/Organization/SiteRowContainer.tsx create mode 100644 src/core/client/admin/routes/Configure/sections/Organization/SitesConfig.tsx create mode 100644 src/core/client/admin/routes/Configure/sections/Organization/SitesConfigContainer.tsx create mode 100644 src/core/client/admin/routes/Configure/sections/Organization/SitesTable.tsx create mode 100644 src/core/client/admin/routes/Configure/sections/Sites/AddSiteRoute.tsx create mode 100644 src/core/client/admin/routes/Configure/sections/Sites/CreateSiteForm.tsx create mode 100644 src/core/client/admin/routes/Configure/sections/Sites/CreateSiteMutation.ts create mode 100644 src/core/client/admin/routes/Configure/sections/Sites/EditSiteForm.tsx rename src/core/client/admin/routes/Configure/sections/{Advanced => Sites}/EmbedCode.css (100%) rename src/core/client/admin/routes/Configure/sections/{Advanced => Sites}/EmbedCode.tsx (78%) create mode 100644 src/core/client/admin/routes/Configure/sections/Sites/SiteRoute.tsx create mode 100644 src/core/client/admin/routes/Configure/sections/Sites/SitesRoute.tsx create mode 100644 src/core/client/admin/routes/Configure/sections/Sites/UpdateSiteMutation.ts create mode 100644 src/core/client/admin/routes/Configure/sections/Sites/index.ts create mode 100644 src/core/client/admin/routes/Moderate/SiteSelector/SiteSelector.css create mode 100644 src/core/client/admin/routes/Moderate/SiteSelector/SiteSelector.tsx create mode 100644 src/core/client/admin/routes/Moderate/SiteSelector/SiteSelectorContainer.tsx create mode 100644 src/core/client/admin/routes/Moderate/SiteSelector/SiteSelectorSelected.css create mode 100644 src/core/client/admin/routes/Moderate/SiteSelector/SiteSelectorSelected.tsx create mode 100644 src/core/client/admin/routes/Moderate/SiteSelector/SiteSelectorSite.css create mode 100644 src/core/client/admin/routes/Moderate/SiteSelector/SiteSelectorSite.tsx create mode 100644 src/core/client/admin/routes/Moderate/SiteSelector/index.ts create mode 100644 src/core/client/admin/routes/Stories/SiteFilter/SiteFilter.css create mode 100644 src/core/client/admin/routes/Stories/SiteFilter/SiteFilter.tsx create mode 100644 src/core/client/admin/routes/Stories/SiteFilter/SiteFilterContainer.tsx create mode 100644 src/core/client/admin/routes/Stories/SiteFilter/SiteFilterOption.css create mode 100644 src/core/client/admin/routes/Stories/SiteFilter/SiteFilterOption.tsx create mode 100644 src/core/client/admin/routes/Stories/SiteFilter/SiteFilterSelected.css create mode 100644 src/core/client/admin/routes/Stories/SiteFilter/SiteFilterSelected.tsx create mode 100644 src/core/client/admin/routes/Stories/SiteFilter/index.ts create mode 100644 src/core/client/framework/helpers/getModerationLink.spec.ts create mode 100644 src/core/client/framework/helpers/getModerationLink.ts create mode 100644 src/core/client/ui/components/v2/AppNotification/AppNotification.css create mode 100644 src/core/client/ui/components/v2/AppNotification/AppNotification.tsx create mode 100644 src/core/client/ui/components/v2/AppNotification/index.ts create mode 100644 src/core/common/helpers/isNonNullArray.spec.ts create mode 100644 src/core/common/helpers/isNonNullArray.ts create mode 100644 src/core/server/graph/loaders/Sites.ts create mode 100644 src/core/server/graph/mutators/Sites.ts rename src/core/server/models/{story/counts/index.ts => comment/counts/counts.ts} (61%) create mode 100644 src/core/server/models/comment/counts/empty.ts create mode 100644 src/core/server/models/comment/counts/index.ts rename src/core/server/models/{story => comment}/counts/shared.ts (80%) create mode 100644 src/core/server/models/site/index.ts delete mode 100644 src/core/server/models/story/counts/empty.ts create mode 100644 src/core/server/services/migrate/batch.ts create mode 100644 src/core/server/services/migrate/migrations/1579189174931_create_sites.ts create mode 100644 src/core/server/services/migrate/migrations/1579878509162_add_indexes_to_sites.ts create mode 100644 src/core/server/services/sites/index.ts create mode 100644 src/core/server/services/sites/url.spec.ts rename src/core/server/services/{tenant => sites}/url.ts (79%) delete mode 100644 src/core/server/services/tenant/url.spec.ts rename src/core/server/stacks/helpers/{updateAllCounts.ts => updateAllCommentCounts.ts} (55%) 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. - - + <>