diff --git a/editor.planx.uk/src/components/Header.tsx b/editor.planx.uk/src/components/Header.tsx index b5a6acd96d..277461b41e 100644 --- a/editor.planx.uk/src/components/Header.tsx +++ b/editor.planx.uk/src/components/Header.tsx @@ -494,6 +494,7 @@ const EditorToolbar: React.FC<{ + {user && ( ({ - display: "flex", backgroundColor: theme.palette.background.paper, color: theme.palette.text.primary, justifyContent: "space-between", @@ -17,37 +17,33 @@ const TestEnvironmentWarning = styled(Box)(({ theme }) => ({ })); const TestEnvironmentBanner: React.FC = () => { - const [showWarning, setShowWarning] = useState(true); + const [isTestEnvBannerVisible, hideTestEnvBanner] = useStore(state => [state.isTestEnvBannerVisible, state.hideTestEnvBanner]); - const isTestEnvironment = () => !window.location.href.includes(".uk"); + if (!isTestEnvBannerVisible) return null; return ( - <> - {isTestEnvironment() && showWarning && ( - - - - - - This is a testing environment for new features. - Do not use it to make permanent content changes. - - - - - - )} - + + + + + + This is a testing environment for new features. + Do not use it to make permanent content changes. + + + + + ); }; diff --git a/editor.planx.uk/src/lib/featureFlags.ts b/editor.planx.uk/src/lib/featureFlags.ts index 0993f38f67..85fc186aa8 100644 --- a/editor.planx.uk/src/lib/featureFlags.ts +++ b/editor.planx.uk/src/lib/featureFlags.ts @@ -1,5 +1,5 @@ // add/edit/remove feature flags in array below -const AVAILABLE_FEATURE_FLAGS = ["UNDO"] as const; +const AVAILABLE_FEATURE_FLAGS = ["UNDO", "EDITOR_NAVIGATION"] as const; type FeatureFlag = (typeof AVAILABLE_FEATURE_FLAGS)[number]; diff --git a/editor.planx.uk/src/pages/FlowEditor/components/EditorMenu.tsx b/editor.planx.uk/src/pages/FlowEditor/components/EditorMenu.tsx new file mode 100644 index 0000000000..09c3dbb45f --- /dev/null +++ b/editor.planx.uk/src/pages/FlowEditor/components/EditorMenu.tsx @@ -0,0 +1,113 @@ +import FactCheckIcon from "@mui/icons-material/FactCheck"; +import TuneIcon from "@mui/icons-material/Tune"; +import Box from "@mui/material/Box"; +import IconButton from "@mui/material/IconButton"; +import { styled } from "@mui/material/styles"; +import Tooltip, { tooltipClasses, TooltipProps } from "@mui/material/Tooltip"; +import React from "react"; +import { useCurrentRoute, useNavigation } from "react-navi"; +import { rootFlowPath } from "routes/utils"; +import { FONT_WEIGHT_SEMI_BOLD } from "theme"; +import EditorIcon from "ui/icons/Editor"; + +const MENU_WIDTH = "46px"; + +const Root = styled(Box)(({ theme }) => ({ + width: MENU_WIDTH, + flexShrink: 0, + background: theme.palette.background.paper, + borderRight: `1px solid ${theme.palette.border.main}`, +})); + +const MenuWrap = styled("ul")(({ theme }) => ({ + listStyle: "none", + margin: 0, + padding: theme.spacing(4, 0, 0, 0), +})); + +const MenuItem = styled("li")(({ theme }) => ({ + margin: theme.spacing(0.75, 0), + padding: 0, +})); + +const TooltipWrap = styled(({ className, ...props }: TooltipProps) => ( + +))(() => ({ + [`& .${tooltipClasses.arrow}`]: { + color: "#2c2c2c", + }, + [`& .${tooltipClasses.tooltip}`]: { + backgroundColor: "#2c2c2c", + left: "-5px", + fontSize: "0.8em", + borderRadius: 0, + fontWeight: FONT_WEIGHT_SEMI_BOLD, + }, +})); + +const MenuButton = styled(IconButton, { + shouldForwardProp: (prop) => prop !== "isActive", +})<{ isActive: boolean }>(({ theme, isActive }) => ({ + color: theme.palette.primary.main, + width: MENU_WIDTH, + height: MENU_WIDTH, + border: "1px solid transparent", + borderRightColor: theme.palette.border.main, + "&:hover": { + background: "white", + borderTopColor: theme.palette.border.light, + borderBottomColor: theme.palette.border.light, + }, + ...(isActive && { + background: theme.palette.common.white, + color: theme.palette.text.primary, + border: `1px solid ${theme.palette.border.main}`, + borderRightColor: "transparent", + }), +})); + +function EditorMenu() { + const { navigate } = useNavigation(); + const { lastChunk } = useCurrentRoute(); + const rootPath = rootFlowPath(); + + const isActive = (route: string) => lastChunk.url.pathname.endsWith(route); + const handleClick = (route: string) => + !isActive(route) && navigate(rootPath + route); + + const routes = [ + { + title: "Editor", + Icon: EditorIcon, + route: "/", + }, + { + title: "Service settings", + Icon: TuneIcon, + route: "/service", + }, + { + title: "Submissions log", + Icon: FactCheckIcon, + route: "/submissions-log", + }, + ]; + + return ( + + + {routes.map(({ title, Icon, route }) => ( + handleClick(route)} key={title}> + + + + + + + ))} + + + ); +} + +export default EditorMenu; diff --git a/editor.planx.uk/src/pages/FlowEditor/floweditor.scss b/editor.planx.uk/src/pages/FlowEditor/floweditor.scss index 7cde130e1c..50f12b90e9 100644 --- a/editor.planx.uk/src/pages/FlowEditor/floweditor.scss +++ b/editor.planx.uk/src/pages/FlowEditor/floweditor.scss @@ -38,12 +38,6 @@ $fontMonospace: "Source Code Pro", monospace; // ------------------------------------------------ -#editor-container { - flex: 1; - display: flex; - overflow: hidden; -} - #editor { flex: 1; overflow: auto; diff --git a/editor.planx.uk/src/pages/FlowEditor/index.tsx b/editor.planx.uk/src/pages/FlowEditor/index.tsx index 8905a3667f..089c87965f 100644 --- a/editor.planx.uk/src/pages/FlowEditor/index.tsx +++ b/editor.planx.uk/src/pages/FlowEditor/index.tsx @@ -4,6 +4,8 @@ import "./floweditor.scss"; import { gql, useSubscription } from "@apollo/client"; import UndoOutlined from "@mui/icons-material/UndoOutlined"; import Box from "@mui/material/Box"; +import Link from "@mui/material/Link"; +import { styled } from "@mui/material/styles"; import Typography from "@mui/material/Typography"; import { formatOps } from "@planx/graph"; import { OT } from "@planx/graph/types"; @@ -16,6 +18,7 @@ import Flow from "./components/Flow"; import PreviewBrowser from "./components/PreviewBrowser"; import { useStore } from "./lib/store"; import useScrollControlsAndRememberPosition from "./lib/useScrollControlsAndRememberPosition"; +import EditNoteIcon from '@mui/icons-material/EditNote'; interface Operation { id: string; @@ -33,6 +36,12 @@ const formatLastEditDate = (date: string): string => { addSuffix: true, }); }; +const EditorContainer = styled(Box)(() => ({ + display: "flex", + alignItems: "stretch", + overflow: "hidden", + flexGrow: 1, +})); const formatLastEditMessage = ( date: string, @@ -94,16 +103,21 @@ export const LastEdited = () => { return ( ({ - padding: theme.spacing(1), + backgroundColor: theme.palette.grey[200], + borderBottom: `1px solid ${theme.palette.border.main}`, + padding: theme.spacing(0.5, 1), paddingLeft: theme.spacing(2), + display: "flex", + alignItems: "center", [theme.breakpoints.up("md")]: { - paddingLeft: theme.spacing(3), + paddingLeft: theme.spacing(2), }, })} > {message} + View edit history ); }; @@ -197,7 +211,7 @@ const FlowEditor: React.FC = ({ flow, breadcrumbs }) => { const showPreview = useStore((state) => state.showPreview); return ( - + = ({ flow, breadcrumbs }) => { url={`${window.location.origin}${rootFlowPath(false)}/published`} /> )} - + ); }; -export default FlowEditor; +export default FlowEditor; \ No newline at end of file diff --git a/editor.planx.uk/src/pages/FlowEditor/lib/store/editor.ts b/editor.planx.uk/src/pages/FlowEditor/lib/store/editor.ts index f850c66d25..98bdb902b9 100644 --- a/editor.planx.uk/src/pages/FlowEditor/lib/store/editor.ts +++ b/editor.planx.uk/src/pages/FlowEditor/lib/store/editor.ts @@ -37,6 +37,8 @@ export interface EditorUIStore { flowLayout: FlowLayout; showPreview: boolean; togglePreview: () => void; + isTestEnvBannerVisible: boolean; + hideTestEnvBanner: () => void; } export const editorUIStore: StateCreator< @@ -52,6 +54,10 @@ export const editorUIStore: StateCreator< togglePreview: () => { set({ showPreview: !get().showPreview }); }, + + isTestEnvBannerVisible: true, + + hideTestEnvBanner: () => set({ isTestEnvBannerVisible: false }), }); interface PublishFlowResponse { diff --git a/editor.planx.uk/src/pages/layout/FlowEditorLayout.tsx b/editor.planx.uk/src/pages/layout/FlowEditorLayout.tsx new file mode 100644 index 0000000000..58e2793a16 --- /dev/null +++ b/editor.planx.uk/src/pages/layout/FlowEditorLayout.tsx @@ -0,0 +1,27 @@ +import React, { PropsWithChildren } from "react"; +import EditorMenu from "pages/FlowEditor/components/EditorMenu"; +import Box from "@mui/material/Box"; +import { styled } from "@mui/material/styles"; +import { ErrorBoundary } from "react-error-boundary"; +import ErrorFallback from "components/ErrorFallback"; +import { hasFeatureFlag } from "lib/featureFlags"; + +const Root = styled(Box)(() => ({ + display: "flex", + alignItems: "stretch", + overflow: "hidden", + flexGrow: 1, +})) + +const FlowEditorLayout: React.FC = ({ children }) => ( + + { hasFeatureFlag("EDITOR_NAVIGATION") && } + + {children} + + +); + +export default FlowEditorLayout; + + diff --git a/editor.planx.uk/src/routes/flow.tsx b/editor.planx.uk/src/routes/flow.tsx index 3ea3d56982..22a797e35c 100644 --- a/editor.planx.uk/src/routes/flow.tsx +++ b/editor.planx.uk/src/routes/flow.tsx @@ -1,7 +1,6 @@ import { gql } from "@apollo/client"; +import Box from "@mui/material/Box"; import { ComponentType as TYPES } from "@opensystemslab/planx-core/types"; -import ErrorFallback from "components/ErrorFallback"; -import TestEnvironmentBanner from "components/TestEnvironmentBanner"; import natsort from "natsort"; import { compose, @@ -9,15 +8,17 @@ import { map, Matcher, mount, - NotFoundError, redirect, route, withData, withView, } from "navi"; +import DataManagerSettings from "pages/FlowEditor/components/Settings/DataManagerSettings"; +import ServiceFlags from "pages/FlowEditor/components/Settings/ServiceFlags"; +import ServiceSettings from "pages/FlowEditor/components/Settings/ServiceSettings"; +import Submissions from "pages/FlowEditor/components/Settings/Submissions"; import mapAccum from "ramda/src/mapAccum"; import React from "react"; -import { ErrorBoundary } from "react-error-boundary"; import { View } from "react-navi"; import { client } from "../lib/graphql"; @@ -26,8 +27,9 @@ import components from "../pages/FlowEditor/components/forms"; import FormModal from "../pages/FlowEditor/components/forms/FormModal"; import { SLUGS } from "../pages/FlowEditor/data/types"; import { useStore } from "../pages/FlowEditor/lib/store"; -import type { Flow, FlowSettings } from "../types"; +import type { Flow } from "../types"; import { makeTitle } from "./utils"; +import { flowEditorView } from "./views/flowEditor"; const sorter = natsort({ insensitive: true }); const sortFlows = (a: { text: string }, b: { text: string }) => @@ -174,98 +176,88 @@ const nodeRoutes = mount({ "/:parent/nodes/:id/edit": editNode, }); -interface FlowMetadata { - flowSettings: FlowSettings; - flowAnalyticsLink: string; - isFlowPublished: boolean; -} - -interface GetFlowMetadata { - flows: { - flowSettings: FlowSettings; - flowAnalyticsLink: string; - publishedFlowsAggregate: { - aggregate: { - count: number; - }; - }; - }[]; -} - -const getFlowMetadata = async ( - flowSlug: string, - team: string, -): Promise => { - const { - data: { flows }, - } = await client.query({ - query: gql` - query GetFlow($slug: String!, $team_slug: String!) { - flows( - limit: 1 - where: { slug: { _eq: $slug }, team: { slug: { _eq: $team_slug } } } - ) { - id - flowSettings: settings - flowAnalyticsLink: analytics_link - publishedFlowsAggregate: published_flows_aggregate { - aggregate { - count - } - } - } - } - `, - variables: { - slug: flowSlug, - team_slug: team, - }, - }); - - const flow = flows[0]; - if (!flows) throw new NotFoundError(`Flow ${flowSlug} not found for ${team}`); - - const metadata = { - flowSettings: flow.flowSettings, - flowAnalyticsLink: flow.flowAnalyticsLink, - isFlowPublished: flow.publishedFlowsAggregate?.aggregate.count > 0, - }; - return metadata; -}; +const SettingsContainer = () => ( + + + +); const routes = compose( withData((req) => ({ flow: req.params.flow.split(",")[0], })), - withView(async (req) => { - const [flow, ...breadcrumbs] = req.params.flow.split(","); - const { flowSettings, flowAnalyticsLink, isFlowPublished } = - await getFlowMetadata(flow, req.params.team); - useStore.setState({ flowSettings, flowAnalyticsLink, isFlowPublished }); - - return ( - <> - - - - - - - ); - }), + withView(flowEditorView), mount({ "/": route(async (req) => { return { title: makeTitle([req.params.team, req.params.flow].join("/")), - view: , + view: () => { + const [flow, ...breadcrumbs] = req.params.flow.split(","); + return ( + + ); + }, }; }), - "/nodes": nodeRoutes, + "/nodes": compose( + withView((req) => { + const [flow, ...breadcrumbs] = req.params.flow.split(","); + return ( + <> + + + + ); + }), + nodeRoutes, + ), "/settings": lazy(() => import("./flowSettings")), + + "/service": compose( + withView(SettingsContainer), + + route(async (req) => ({ + title: makeTitle( + [req.params.team, req.params.flow, "service"].join("/"), + ), + view: ServiceSettings, + })), + ), + + "/service-flags": compose( + withView(SettingsContainer), + + route(async (req) => ({ + title: makeTitle( + [req.params.team, req.params.flow, "service-flags"].join("/"), + ), + view: ServiceFlags, + })), + ), + + "/data": compose( + withView(SettingsContainer), + + route(async (req) => ({ + title: makeTitle([req.params.team, req.params.flow, "data"].join("/")), + view: DataManagerSettings, + })), + ), + + "/submissions-log": compose( + withView(SettingsContainer), + + route(async (req) => ({ + title: makeTitle( + [req.params.team, req.params.flow, "submissions-log"].join("/"), + ), + view: Submissions, + })), + ), }), ); diff --git a/editor.planx.uk/src/routes/views/flowEditor.tsx b/editor.planx.uk/src/routes/views/flowEditor.tsx new file mode 100644 index 0000000000..ee1db590d3 --- /dev/null +++ b/editor.planx.uk/src/routes/views/flowEditor.tsx @@ -0,0 +1,85 @@ +import { gql } from "@apollo/client"; +import { NaviRequest, NotFoundError } from "navi"; +import React from "react"; +import { View } from "react-navi"; + +import { client } from "../../lib/graphql"; +import { useStore } from "../../pages/FlowEditor/lib/store"; +import type { FlowSettings } from "../../types"; +import FlowEditorLayout from "pages/layout/FlowEditorLayout"; + +interface FlowMetadata { + flowSettings: FlowSettings; + flowAnalyticsLink: string; + isFlowPublished: boolean; +} + +interface GetFlowMetadata { + flows: { + flowSettings: FlowSettings; + flowAnalyticsLink: string; + publishedFlowsAggregate: { + aggregate: { + count: number; + }; + }; + }[]; +} + +const getFlowMetadata = async ( + flowSlug: string, + team: string, +): Promise => { + const { + data: { flows }, + } = await client.query({ + query: gql` + query GetFlow($slug: String!, $team_slug: String!) { + flows( + limit: 1 + where: { slug: { _eq: $slug }, team: { slug: { _eq: $team_slug } } } + ) { + id + flowSettings: settings + flowAnalyticsLink: analytics_link + publishedFlowsAggregate: published_flows_aggregate { + aggregate { + count + } + } + } + } + `, + variables: { + slug: flowSlug, + team_slug: team, + }, + }); + + const flow = flows[0]; + if (!flows) throw new NotFoundError(`Flow ${flowSlug} not found for ${team}`); + + const metadata = { + flowSettings: flow.flowSettings, + flowAnalyticsLink: flow.flowAnalyticsLink, + isFlowPublished: flow.publishedFlowsAggregate?.aggregate.count > 0, + }; + return metadata; +}; + + +/** + * View wrapper for all flowEditor routes + */ +export const flowEditorView = async (req: NaviRequest) => { + const [ flow ] = req.params.flow.split(","); + const { flowSettings, flowAnalyticsLink, isFlowPublished } = + await getFlowMetadata(flow, req.params.team); + useStore.setState({ flowSettings, flowAnalyticsLink, isFlowPublished }); + + return ( + + + + ); +}; diff --git a/editor.planx.uk/src/ui/icons/Editor.tsx b/editor.planx.uk/src/ui/icons/Editor.tsx new file mode 100644 index 0000000000..8239a23efd --- /dev/null +++ b/editor.planx.uk/src/ui/icons/Editor.tsx @@ -0,0 +1,15 @@ +import SvgIcon from "@mui/material/SvgIcon"; +import * as React from "react"; + +export default function EditorIcon() { + return ( + + + + + + ); +}