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 }) => (
+
+ ))}
+
+
+ );
+}
+
+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 (
+
+
+
+ );
+}