Skip to content

Commit 8076be4

Browse files
committed
[arribada] feat(gantt): undo / Ctrl+Z for bar date edits
Record an issue's dates before each bar drag/resize; an Undo button (and Ctrl/Cmd+Z, ignored while typing) re-applies the previous dates via updateIssue. Stack is per-project (cleared on project change), capped at 50.
1 parent 6c4eaba commit 8076be4

3 files changed

Lines changed: 115 additions & 1 deletion

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/**
2+
* Copyright (c) 2026-present Arribada Initiative and contributors
3+
* SPDX-License-Identifier: AGPL-3.0-only
4+
* See the LICENSE file for details.
5+
*
6+
* Small Undo control for the issue gantt — appears only when a bar date edit can
7+
* be reverted. Ctrl+Z does the same thing (wired in base-gantt-root).
8+
*/
9+
import { observer } from "mobx-react";
10+
import { Undo2 } from "lucide-react";
11+
import { ganttUndo } from "@/plane-web/store/gantt-undo";
12+
13+
export const GanttUndoButton = observer(function GanttUndoButton({ onUndo }: { onUndo: () => void }) {
14+
if (!ganttUndo.canUndo) return null;
15+
return (
16+
<button
17+
type="button"
18+
onClick={onUndo}
19+
title="Undo last date change (Ctrl+Z)"
20+
className="flex items-center gap-1.5 rounded-md border border-subtle bg-layer-1 px-2 py-1 text-12 text-secondary shadow-sm hover:bg-layer-2"
21+
>
22+
<Undo2 className="size-3.5" />
23+
Undo
24+
</button>
25+
);
26+
});

apps/web/ce/store/gantt-undo.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
/**
2+
* Copyright (c) 2026-present Arribada Initiative and contributors
3+
* SPDX-License-Identifier: AGPL-3.0-only
4+
* See the LICENSE file for details.
5+
*
6+
* A tiny undo stack for gantt bar date edits. The bar drag/resize handler records
7+
* each issue's dates BEFORE the change; Ctrl+Z (or the Undo button) re-applies the
8+
* previous dates. Cleared when the project changes so undo never crosses projects.
9+
*/
10+
import { action, computed, makeObservable, observable } from "mobx";
11+
12+
export type GanttUndoEntry = {
13+
projectId: string | null | undefined;
14+
issueId: string;
15+
prev: { start_date?: string | null; target_date?: string | null };
16+
};
17+
18+
class GanttUndoStore {
19+
stack: GanttUndoEntry[] = [];
20+
21+
constructor() {
22+
makeObservable(this, {
23+
stack: observable.shallow,
24+
canUndo: computed,
25+
push: action,
26+
pop: action,
27+
clear: action,
28+
});
29+
}
30+
31+
get canUndo(): boolean {
32+
return this.stack.length > 0;
33+
}
34+
35+
push(entry: GanttUndoEntry): void {
36+
this.stack.push(entry);
37+
if (this.stack.length > 50) this.stack.shift();
38+
}
39+
40+
pop(): GanttUndoEntry | undefined {
41+
return this.stack.pop();
42+
}
43+
44+
clear(): void {
45+
this.stack = [];
46+
}
47+
}
48+
49+
export const ganttUndo = new GanttUndoStore();

apps/web/core/components/issues/issue-layouts/gantt/base-gantt-root.tsx

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import { renderFormattedPayloadDate } from "@plane/utils";
1818
import { TimeLineTypeContext } from "@/components/gantt-chart/contexts";
1919
import { GanttChartRoot } from "@/components/gantt-chart/root";
2020
import { GanttColorBy } from "@/plane-web/components/gantt-chart/color-by";
21+
import { GanttUndoButton } from "@/plane-web/components/gantt-chart/undo-button";
22+
import { ganttUndo } from "@/plane-web/store/gantt-undo";
2123
import { IssueGanttSidebar } from "@/components/gantt-chart/sidebar/issues/sidebar";
2224
// hooks
2325
import { useIssues } from "@/hooks/store/use-issues";
@@ -85,12 +87,48 @@ export const BaseGanttRoot = observer(function BaseGanttRoot(props: IBaseGanttRo
8587
const updateIssueBlockStructure = async (issue: TIssue, data: IBlockUpdateData) => {
8688
if (!workspaceSlug) return;
8789

90+
// record the pre-change dates so Ctrl+Z / Undo can revert a bar drag or resize
91+
if (data.start_date !== undefined || data.target_date !== undefined) {
92+
ganttUndo.push({
93+
projectId: issue.project_id,
94+
issueId: issue.id,
95+
prev: { start_date: issue.start_date, target_date: issue.target_date },
96+
});
97+
}
98+
8899
const payload: any = { ...data };
89100
if (data.sort_order) payload.sort_order = data.sort_order.newSortOrder;
90101

91102
updateIssue && (await updateIssue(issue.project_id, issue.id, payload));
92103
};
93104

105+
// revert the last recorded bar date edit
106+
const handleGanttUndo = useCallback(async () => {
107+
const entry = ganttUndo.pop();
108+
if (!entry || !updateIssue) return;
109+
await updateIssue(entry.projectId, entry.issueId, entry.prev);
110+
}, [updateIssue]);
111+
112+
// Ctrl/Cmd+Z reverts the last bar date edit (ignored while typing in a field)
113+
useEffect(() => {
114+
const onKey = (e: KeyboardEvent) => {
115+
if (!((e.ctrlKey || e.metaKey) && !e.shiftKey && e.key.toLowerCase() === "z")) return;
116+
const el = document.activeElement as HTMLElement | null;
117+
if (el?.tagName === "INPUT" || el?.tagName === "TEXTAREA" || el?.isContentEditable) return;
118+
if (!ganttUndo.canUndo) return;
119+
e.preventDefault();
120+
void handleGanttUndo();
121+
};
122+
window.addEventListener("keydown", onKey);
123+
return () => window.removeEventListener("keydown", onKey);
124+
}, [handleGanttUndo]);
125+
126+
// undo history is scoped to the current project
127+
useEffect(() => {
128+
ganttUndo.clear();
129+
return () => ganttUndo.clear();
130+
}, [projectId]);
131+
94132
const isAllowed = allowPermissions([EUserPermissions.ADMIN, EUserPermissions.MEMBER], EUserPermissionsLevel.PROJECT);
95133
const updateBlockDates = useCallback(
96134
(
@@ -129,8 +167,9 @@ export const BaseGanttRoot = observer(function BaseGanttRoot(props: IBaseGanttRo
129167
<IssueLayoutHOC layout={EIssueLayoutTypes.GANTT}>
130168
<TimeLineTypeContext.Provider value={GANTT_TIMELINE_TYPE.ISSUE}>
131169
<div className="relative h-full w-full">
132-
<div className="absolute left-3 top-1.5 z-20">
170+
<div className="absolute left-3 top-1.5 z-20 flex items-center gap-2">
133171
<GanttColorBy />
172+
<GanttUndoButton onUndo={handleGanttUndo} />
134173
</div>
135174
<GanttChartRoot
136175
border={false}

0 commit comments

Comments
 (0)