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