diff --git a/editor.planx.uk/src/@planx/graph/__tests__/formatOps.test.ts b/editor.planx.uk/src/@planx/graph/__tests__/formatOps.test.ts
new file mode 100644
index 0000000000..4e978641cb
--- /dev/null
+++ b/editor.planx.uk/src/@planx/graph/__tests__/formatOps.test.ts
@@ -0,0 +1,269 @@
+import { formatOps,Graph } from "../index";
+
+describe("Update operations", () => {
+ test("Updating a single property of a node", () => {
+ const ops = [
+ {
+ p: ["FW5G3EMBI3", "data", "text"],
+ oi: "Which vegetables?",
+ od: "Which fruits?",
+ },
+ ];
+
+ expect(formatOps(flowWithChecklist, ops)).toEqual([
+ 'Updated Checklist text from "Which fruits?" to "Which vegetables?"',
+ ]);
+ });
+
+ test("Updating many properties of a node", () => {
+ const ops = [
+ {
+ p: ["FW5G3EMBI3", "data", "fn"],
+ oi: "fruit",
+ },
+ {
+ p: ["WDwUTbF7Gq", "data", "val"],
+ oi: "berry.blue",
+ },
+ {
+ p: ["xTBfSd1Tjy", "data", "val"],
+ oi: "banana",
+ },
+ ];
+
+ expect(formatOps(flowWithChecklist, ops)).toEqual([
+ 'Added Checklist fn "fruit"',
+ 'Added Answer val "berry.blue"',
+ 'Added Answer val "banana"',
+ ]);
+ });
+});
+
+describe("Insert operations", () => {
+ test("Adding a new node to the graph", () => {
+ const ops = [
+ {
+ p: ["_root", "edges", 0],
+ li: "ubCTG9OtFw",
+ },
+ {
+ p: ["ubCTG9OtFw"],
+ oi: {
+ type: 8,
+ data: {
+ title: "This is a test",
+ color: "#c81bcb",
+ resetButton: false,
+ },
+ },
+ },
+ ];
+
+ expect(formatOps(emptyFlow, ops)).toEqual([
+ 'Added Notice "This is a test"',
+ ]);
+ });
+
+ test("Adding a new node and its' children to the graph", () => {
+ const ops = [
+ {
+ p: ["_root", "edges", 0],
+ li: "FW5G3EMBI3",
+ },
+ {
+ p: ["FW5G3EMBI3"],
+ oi: {
+ type: 105,
+ data: {
+ allRequired: false,
+ text: "Which fruits?",
+ },
+ edges: ["WDwUTbF7Gq", "SO5XbLwSYp", "xTBfSd1Tjy"],
+ },
+ },
+ {
+ p: ["WDwUTbF7Gq"],
+ oi: {
+ data: {
+ text: "Blueberry",
+ },
+ type: 200,
+ },
+ },
+ {
+ p: ["SO5XbLwSYp"],
+ oi: {
+ data: {
+ text: "Orange",
+ },
+ type: 200,
+ },
+ },
+ {
+ p: ["xTBfSd1Tjy"],
+ oi: {
+ data: {
+ text: "Banana",
+ },
+ type: 200,
+ },
+ },
+ ];
+
+ expect(formatOps(emptyFlow, ops)).toEqual([
+ 'Added Checklist "Which fruits?"',
+ 'Added Answer "Blueberry"',
+ 'Added Answer "Orange"',
+ 'Added Answer "Banana"',
+ ]);
+ });
+
+ test("Adding a new child and data property to an existing node", () => {
+ const ops = [
+ {
+ p: ["FW5G3EMBI3", "data", "description"],
+ oi: "
Fruits contain seeds and come from the flower of a plant
",
+ },
+ {
+ p: ["FW5G3EMBI3", "edges"],
+ oi: ["WDwUTbF7Gq", "SO5XbLwSYp", "xTBfSd1Tjy", "zzQAMXexRj"],
+ od: ["WDwUTbF7Gq", "SO5XbLwSYp", "xTBfSd1Tjy"],
+ },
+ {
+ p: ["zzQAMXexRj"],
+ oi: {
+ data: {
+ text: "Strawberry",
+ },
+ type: 200,
+ },
+ },
+ ];
+
+ expect(formatOps(flowWithChecklist, ops)).toEqual([
+ 'Added Checklist description "Fruits contain seeds and come from the flower of a plant
"',
+ "Updated order of Checklist edges",
+ 'Added Answer "Strawberry"',
+ ]);
+ });
+});
+
+describe("Remove operations", () => {
+ test("Removing a node from the graph", () => {
+ const ops = [
+ {
+ p: ["_root", "edges", 1],
+ ld: "FW5G3EMBI3",
+ },
+ {
+ p: ["WDwUTbF7Gq"],
+ od: {
+ data: {
+ text: "Blueberry",
+ },
+ type: 200,
+ },
+ },
+ {
+ p: ["SO5XbLwSYp"],
+ od: {
+ data: {
+ text: "Orange",
+ },
+ type: 200,
+ },
+ },
+ {
+ p: ["xTBfSd1Tjy"],
+ od: {
+ data: {
+ text: "Banana",
+ },
+ type: 200,
+ },
+ },
+ {
+ p: ["FW5G3EMBI3"],
+ od: {
+ type: 105,
+ data: {
+ allRequired: false,
+ text: "Which fruits?",
+ },
+ edges: ["wBwRtMce7c", "SO5XbLwSYp", "xTBfSd1Tjy"],
+ },
+ },
+ ];
+
+ expect(formatOps(flowWithChecklist, ops)).toEqual([
+ 'Removed Answer "Blueberry"',
+ 'Removed Answer "Orange"',
+ 'Removed Answer "Banana"',
+ 'Removed Checklist "Which fruits?"',
+ ]);
+ });
+
+ test("Removing a child of a node", () => {
+ const ops = [
+ {
+ p: ["FW5G3EMBI3", "edges"],
+ oi: ["WDwUTbF7Gq", "xTBfSd1Tjy"],
+ od: ["WDwUTbF7Gq", "SO5XbLwSYp", "xTBfSd1Tjy"],
+ },
+ {
+ p: ["SO5XbLwSYp"],
+ od: {
+ data: {
+ text: "Orange",
+ },
+ type: 200,
+ },
+ },
+ ];
+
+ expect(formatOps(flowWithChecklist, ops)).toEqual([
+ "Updated order of Checklist edges",
+ 'Removed Answer "Orange"',
+ ]);
+ });
+
+ test.todo("Removing a data property of an existing node");
+});
+
+const emptyFlow: Graph = {
+ _root: {
+ edges: [],
+ },
+};
+
+const flowWithChecklist: Graph = {
+ _root: {
+ edges: ["FW5G3EMBI3"],
+ },
+ FW5G3EMBI3: {
+ type: 105,
+ data: {
+ allRequired: false,
+ text: "Which fruits?",
+ },
+ edges: ["WDwUTbF7Gq", "SO5XbLwSYp", "xTBfSd1Tjy"],
+ },
+ WDwUTbF7Gq: {
+ data: {
+ text: "Blueberry",
+ },
+ type: 200,
+ },
+ SO5XbLwSYp: {
+ data: {
+ text: "Orange",
+ },
+ type: 200,
+ },
+ xTBfSd1Tjy: {
+ data: {
+ text: "Banana",
+ },
+ type: 200,
+ },
+};
diff --git a/editor.planx.uk/src/@planx/graph/index.ts b/editor.planx.uk/src/@planx/graph/index.ts
index 4eb69594ad..36c89a7296 100644
--- a/editor.planx.uk/src/@planx/graph/index.ts
+++ b/editor.planx.uk/src/@planx/graph/index.ts
@@ -499,3 +499,83 @@ export const sortIdsDepthFirst =
(a, b) => allNodeIdsSorted.indexOf(a) - allNodeIdsSorted.indexOf(b),
);
};
+
+/**
+ * Translates a list of ShareDB operations into a human-readable change summary.
+ * See https://github.com/ottypes/json0?tab=readme-ov-file#summary-of-operations
+ */
+export const formatOps = (graph: Graph, ops: Array): string[] => {
+ const output: string[] = [];
+
+ // Updating a node or its properties (update = delete + insert)
+ const handleUpdate = (node: Node, op: OT.Object.Replace) => {
+ if (op.od.type && op.oi.type) {
+ output.push(
+ `Replaced ${TYPES[op.od.type]} "${op.od.data?.title || op.od.data?.text
+ }" with ${TYPES[op.oi.type]} "${op.oi.data?.title || op.oi.data?.text
+ }"`,
+ );
+ } else if (op.p.includes("data")) {
+ output.push(
+ `Updated ${node.type ? TYPES[node.type] : "node"} ${op.p?.[2]} from "${op.od}" to "${op.oi
+ }"`,
+ );
+ } else if (op.p.includes("edges")) {
+ output.push(
+ `Updated order of ${node.type ? TYPES[node.type] : "graph"} edges`,
+ );
+ }
+ };
+
+ // Updating the _root list (update = list insert or list delete)
+ const handleRootUpdate = (op: OT.Array.Replace) => {
+ if (op.p.includes("edges") && op.p.includes("_root")) {
+ output.push(`Re-ordered the graph`);
+ }
+ };
+
+ // Adding (inserting) a node or its properties
+ const handleAdd = (node: Node, op: OT.Object.Add) => {
+ if (op.oi.type) {
+ output.push(
+ `Added ${TYPES[op.oi.type]} "${op.oi.data?.title || op.oi.data?.text
+ }"`,
+ );
+ } else if (op.p.includes("data")) {
+ output.push(`Added ${node.type ? TYPES[node.type] : "node"} ${op.p?.[2]} "${op.oi}"`);
+ } else if (op.p.includes("edges")) {
+ output.push(`Added ${node.type ? TYPES[node.type] : "node"} to branch`);
+ }
+ };
+
+ // Removing (deleting) a node or its properties
+ const handleRemove = (node: Node, op: OT.Object.Remove) => {
+ if (op.od.type) {
+ output.push(
+ `Removed ${TYPES[op.od.type]} "${op.od.data?.title || op.od.data?.text
+ }"`,
+ );
+ } else if (op.p.includes("data")) {
+ output.push(`Removed ${node.type ? TYPES[node.type] : "node"} ${op.p?.[2]} "${op.od}"`);
+ } else if (op.p.includes("edges")) {
+ output.push(`Removed ${node.type ? TYPES[node.type] : "node"} from branch`);
+ }
+ };
+
+ ops.map((op) => {
+ const node = graph[op.p?.[0]];
+ const operationTypes = Object.keys(op);
+
+ if (operationTypes.includes("od") && operationTypes.includes("oi")) {
+ handleUpdate(node, op as OT.Object.Replace);
+ } else if (operationTypes.includes("oi")) {
+ handleAdd(node, op as OT.Object.Add);
+ } else if (operationTypes.includes("od")) {
+ handleRemove(node, op as OT.Object.Remove);
+ } else if (operationTypes.includes("li") && operationTypes.includes("ld")) {
+ handleRootUpdate(op as OT.Array.Replace);
+ }
+ });
+
+ return output;
+};
diff --git a/editor.planx.uk/src/lib/featureFlags.ts b/editor.planx.uk/src/lib/featureFlags.ts
index 25e6735304..0993f38f67 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 = [] as const;
+const AVAILABLE_FEATURE_FLAGS = ["UNDO"] as const;
type FeatureFlag = (typeof AVAILABLE_FEATURE_FLAGS)[number];
@@ -25,7 +25,7 @@ const activeFeatureFlags = (() => {
*/
export const toggleFeatureFlag = (
featureFlag: FeatureFlag,
- autoReload = true
+ autoReload = true,
) => {
const supportedFlag = AVAILABLE_FEATURE_FLAGS.includes(featureFlag);
@@ -35,13 +35,13 @@ export const toggleFeatureFlag = (
activeFeatureFlags.add(featureFlag);
} else {
throw new Error(
- `${featureFlag} is not a supported feature flag, try again. Available flags are: ${AVAILABLE_FEATURE_FLAGS}`
+ `${featureFlag} is not a supported feature flag, try again. Available flags are: ${AVAILABLE_FEATURE_FLAGS}`,
);
}
localStorage.setItem(
"FEATURE_FLAGS",
- JSON.stringify(Array.from(activeFeatureFlags))
+ JSON.stringify(Array.from(activeFeatureFlags)),
);
if (autoReload) window.location.reload();
@@ -71,6 +71,6 @@ if (process.env.REACT_APP_ENV !== "test") {
]
.sort()
.join(", ")}`
- : `🎏 no active feature flags`
+ : `🎏 no active feature flags`,
);
}
diff --git a/editor.planx.uk/src/pages/FlowEditor/index.tsx b/editor.planx.uk/src/pages/FlowEditor/index.tsx
index 2d9386170d..6278bf4a08 100644
--- a/editor.planx.uk/src/pages/FlowEditor/index.tsx
+++ b/editor.planx.uk/src/pages/FlowEditor/index.tsx
@@ -4,7 +4,10 @@ import "./floweditor.scss";
import { gql, useSubscription } from "@apollo/client";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
+import { formatOps } from "@planx/graph";
+import { OT } from "@planx/graph/types";
import { format } from "date-fns";
+import { hasFeatureFlag } from "lib/featureFlags";
import React, { useRef } from "react";
import { rootFlowPath } from "../../routes/utils";
@@ -19,10 +22,11 @@ interface Operation {
firstName: string;
lastName: string;
};
+ data: Array;
}
export const LastEdited = () => {
- const [flowId] = useStore((state) => [state.id]);
+ const [flowId, flow] = useStore((state) => [state.id, state.flow]);
const formattedDate = (dateString?: string) => {
if (!dateString) return "";
@@ -43,6 +47,7 @@ export const LastEdited = () => {
firstName: first_name
lastName: last_name
}
+ data(path: "op")
}
}
`,
@@ -62,22 +67,28 @@ export const LastEdited = () => {
if (data && !data.operations[0].actor) return null;
let message: string;
+ let ops: Operation["data"] | undefined;
+ let formattedOps: string[] | undefined;
if (loading || !data) {
message = "Loading...";
+ ops = undefined;
+ formattedOps = undefined;
} else {
const {
operations: [operation],
} = data;
message = `Last edit by ${operation?.actor?.firstName} ${operation?.actor
?.lastName} ${formattedDate(operation?.createdAt)}`;
+ ops = operation?.data;
+ formattedOps = formatOps(flow, ops);
}
return (
({
- backgroundColor: theme.palette.background.paper,
- borderBottom: `1px solid ${theme.palette.border.main}`,
+ // backgroundColor: theme.palette.background.paper,
+ // borderBottom: `1px solid ${theme.palette.border.main}`,
padding: theme.spacing(1),
paddingLeft: theme.spacing(2),
[theme.breakpoints.up("md")]: {
@@ -88,6 +99,15 @@ export const LastEdited = () => {
{message}
+ {hasFeatureFlag("UNDO") && formattedOps && (
+
+ {[...new Set(formattedOps)].map((op, i) => (
+
+ {op}
+
+ ))}
+
+ )}
);
};