Studio records each change made to a report, and can step back and forward through them.
Undo and redo cover the report state: widgets, layout, filters and schema. View state such as the selected page and the current selection is left where it is, then brought back into view for the restored change.
"use client";
import type {
AgFieldDefinition,
AgReportHistory,
AgReportHistoryEntry,
AgReportState,
AgStudioApi,
AgStudioMode,
} from "ag-studio";
import { enableStudioDevValidations } from "ag-studio";
import type { AgStudioRef } from "ag-studio-react";
import { AgStudio } from "ag-studio-react";
import React, {
StrictMode,
useCallback,
useMemo,
useRef,
useState,
} from "react";
import { createRoot } from "react-dom/client";
if (process.env.NODE_ENV !== "production") {
// Enable extended validations only for development
enableStudioDevValidations();
}
const INITIAL_STATE: AgReportState = {
selectedPageId: "main",
pages: [
{
id: "main",
widgets: {
"kpi-revenue": {
type: "value",
dataMapping: { value: [{ id: "sales.revenue", aggregation: "sum" }] },
format: {
style: { label: { enabled: true, text: "Total Revenue" } },
},
},
"by-region": {
type: "bar-chart-grouped",
dataMapping: {
categoryKey: [{ id: "sales.region" }],
valueKey: [{ id: "sales.revenue", aggregation: "sum" }],
},
format: { title: { enabled: true, text: "Revenue by Region" } },
},
"sales-grid": {
type: "grid",
dataMapping: {
cols: [
{ id: "sales.region" },
{ id: "sales.product" },
{ id: "sales.revenue", aggregation: "sum" },
{ id: "sales.units", aggregation: "sum" },
],
},
format: { title: { enabled: true, text: "Sales" } },
},
},
widgetLayout: {
"kpi-revenue": { xTrack: 0, yTrack: 0, xSpan: 24, ySpan: 6 },
"by-region": { xTrack: 0, yTrack: 6, xSpan: 24, ySpan: 16 },
"sales-grid": { xTrack: 0, yTrack: 22, xSpan: 24, ySpan: 16 },
},
},
],
};
const SALES_DATA = [
{ region: "North", product: "Widget", revenue: 4200, units: 120 },
{ region: "North", product: "Gadget", revenue: 3100, units: 80 },
{ region: "South", product: "Widget", revenue: 2600, units: 70 },
{ region: "South", product: "Gadget", revenue: 5400, units: 150 },
{ region: "East", product: "Widget", revenue: 1800, units: 45 },
{ region: "East", product: "Gadget", revenue: 3900, units: 110 },
{ region: "West", product: "Widget", revenue: 4700, units: 130 },
{ region: "West", product: "Gadget", revenue: 2200, units: 60 },
];
const SALES_FIELDS: AgFieldDefinition[] = [
{ id: "region", name: "Region", format: "textFormat" },
{ id: "product", name: "Product", format: "textFormat" },
{ id: "revenue", name: "Revenue", format: "currencyFormat" },
{ id: "units", name: "Units", format: "integerFormat" },
];
const INITIAL_WIDGET_IDS = ["kpi-revenue", "by-region", "sales-grid"];
function selectedPage(state: AgReportState) {
return state.pages.find((page) => page.id === state.selectedPageId)!;
}
// Read the added charts back from live state, so add and remove stay in step with undo and redo
// rather than tracking a private list that drifts once state is restored.
function addedChartIds(state: AgReportState): string[] {
const widgets = selectedPage(state).widgets ?? {};
return Object.keys(widgets).filter((id) => !INITIAL_WIDGET_IDS.includes(id));
}
function describeNext(api: AgStudioApi, history: AgReportHistory): string {
const name = (entry: AgReportHistoryEntry) =>
api.translate(entry.labelKey, entry.labelVariableValues);
const nextUndo = history.undo.at(-1);
const nextRedo = history.redo.at(-1);
const undoText = nextUndo
? `${name(nextUndo)} (${history.undo.length})`
: "nothing";
const redoText = nextRedo
? `${name(nextRedo)} (${history.redo.length})`
: "nothing";
return `undo: ${undoText} redo: ${redoText}`;
}
const StudioExample = () => {
const studioRef = useRef<AgStudioRef>(null);
const containerStyle = useMemo(() => ({ width: "100%", height: "100%" }), []);
const studioStyle = useMemo(() => ({ height: "100%", width: "100%" }), []);
const data = useMemo(
() => ({
sources: [
{ id: "sales", name: "Sales", data: SALES_DATA, fields: SALES_FIELDS },
],
}),
[],
);
const [mode, setMode] = useState<AgStudioMode>("edit");
const [canUndo, setCanUndo] = useState(false);
const [canRedo, setCanRedo] = useState(false);
const [status, setStatus] = useState("");
const onStateUpdated = useCallback(() => {
const history = studioRef.current!.api.getHistory();
setCanUndo(history.undo.length > 0);
setCanRedo(history.redo.length > 0);
setStatus(describeNext(studioRef.current!.api, history));
}, []);
const doUndo = useCallback(() => {
studioRef.current!.api.undo();
}, []);
const doRedo = useCallback(() => {
studioRef.current!.api.redo();
}, []);
const undoAll = useCallback(() => {
const { api } = studioRef.current!;
const oldest = api.getHistory().undo[0];
if (oldest) api.undo(oldest.id);
}, []);
const addChart = useCallback(() => {
const { api } = studioRef.current!;
const state = api.getState();
const page = selectedPage(state);
const added = addedChartIds(state);
const nextNum =
added.reduce(
(max, id) => Math.max(max, Number(id.slice("chart-".length)) || 0),
0,
) + 1;
const id = `chart-${nextNum}`;
const yTrack = 38 + (added.length + 1) * 16;
api.setState({
...state,
pages: state.pages.map((p) =>
p.id !== page.id
? p
: {
...p,
widgets: {
...p.widgets,
[id]: {
type: "bar-chart-grouped",
dataMapping: {
categoryKey: [{ id: "sales.product" }],
valueKey: [{ id: "sales.revenue", aggregation: "sum" }],
},
format: {
title: {
enabled: true,
text: `Revenue by Product (${id})`,
},
},
},
},
widgetLayout: {
...p.widgetLayout,
[id]: { xTrack: 0, yTrack, xSpan: 24, ySpan: 16 },
},
},
),
});
}, []);
const removeLast = useCallback(() => {
const { api } = studioRef.current!;
const state = api.getState();
const added = addedChartIds(state);
const id = added[added.length - 1];
if (id == null) return;
api.setState({
...state,
pages: state.pages.map((p) => {
const { [id]: _removedWidget, ...widgets } = p.widgets ?? {};
const { [id]: _removedLayout, ...widgetLayout } = p.widgetLayout ?? {};
return { ...p, widgets, widgetLayout };
}),
});
}, []);
const toggleMode = useCallback(() => {
setMode((current) => (current === "edit" ? "view" : "edit"));
}, []);
return (
<div style={containerStyle}>
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
<div className="example-controls">
<div className="controls-row">
<button onClick={doUndo} disabled={!canUndo}>
Undo
</button>
<button onClick={doRedo} disabled={!canRedo}>
Redo
</button>
<button onClick={undoAll} disabled={!canUndo}>
Undo All
</button>
<button onClick={addChart}>Add chart</button>
<button onClick={removeLast}>Remove last</button>
<button onClick={toggleMode}>
{mode === "edit" ? "Switch to View Mode" : "Switch to Edit Mode"}
</button>
<span className="history-status">{status}</span>
</div>
</div>
<AgStudio
ref={studioRef}
style={studioStyle}
className="my-studio-container"
data={data}
initialState={INITIAL_STATE}
mode={mode}
onStateUpdated={onStateUpdated}
/>
</div>
</div>
);
};
const root = createRoot(document.getElementById("root")!);
root.render(
<StrictMode>
<StudioExample />
</StrictMode>,
);
Keyboard Shortcuts Copy Link
While focus is inside Studio:
- ⌃ Ctrl/⌘ Cmd + Z - Undo the last change.
- ⌃ Ctrl/⌘ Cmd + ⇧ Shift + Z - Redo.
The shortcuts are bound to Studio's own element, so they work only while focus is inside it. Studio listens nowhere else: with focus in one of your application's inputs or controls, the key press is yours, and a text field inside Studio keeps the shortcut for its own editing history. See Keyboard Shortcuts for Studio's other shortcuts.
Undoing From Outside Studio Copy Link
To undo while focus is elsewhere in your application, bind the shortcut yourself and call undo() or redo():
document.addEventListener('keydown', (event) => {
if (!(event.ctrlKey || event.metaKey) || event.code !== 'KeyZ') return;
event.preventDefault();
if (event.shiftKey) {
api.redo();
} else {
api.undo();
}
});How far that binding reaches, and which of your own components it should leave alone, is yours to decide. Studio makes no assumptions about the page around it.
A binding on the document also fires for a press made inside Studio, because Studio does not stop the event propagating. Suppress the shortcut in Studio, as below, so one press does not undo twice.
Suppressing the Shortcuts Copy Link
suppressKeyboard stops Studio handling a shortcut, leaving the key press for the application to bind:
const suppressKeyboard = {
undo: true,
redo: true,
};
<AgStudio suppressKeyboard={suppressKeyboard} />Each is independent, so { redo: true } keeps undo on ⌃ Ctrl/⌘ Cmd + Z. A suppressed shortcut is left untouched rather than swallowed, so a handler of your own still receives the event.
Undoing and Redoing in Code Copy Link
undo() and redo() each step one change, and do nothing at the end of their stack:
api.undo();
api.redo();Making a new change discards whatever was waiting to be redone.
Reading the History Copy Link
Studio ships no undo controls of its own, so an application that wants them builds them from getHistory(). It returns the live state alongside the changes each action would step through:
const { undo, redo } = api.getHistory();
undoButton.disabled = undo.length === 0;
redoButton.disabled = redo.length === 0;Refresh the controls from the stateUpdated event, which fires after every committed change - undo and redo included - and also when the history is discarded, whether or not the live state itself changed. A host persisting on this event sees one write per clearHistory() call, and one per mode switch once it opts into history.onModeChange: 'clear'.
The last entry of each stack is the one that action takes next, and its labelKey names the change. An entry carries the locale key rather than text, so a list built from it follows the configured language whenever it is read (see Localisation). translate() resolves one:
const next = api.getHistory().undo.at(-1);
undoButton.title = next ? `Undo ${api.translate(next.labelKey, next.labelVariableValues)}` : 'Nothing to undo';Each entry also carries an id. Passing one to undo() or redo() steps through every change up to that entry as a single step, which is what a history list needs:
const { undo } = api.getHistory();
api.undo(undo[0].id); // back to the start of the historyThe history holds the last 100 changes by default, and is cleared when Studio loads initialState, unless what it loads is a history rather than bare report state. A later setState() is recorded as a change like any other, so it can be undone. It can also be cleared explicitly with api.clearHistory(), or automatically on a mode change with history.onModeChange: 'clear', both covered below.
Naming a Change Copy Link
A change made through setState() is recorded as a generic edit. Pass historyLabel to name it, so a history list can say what happened:
api.setState(nextState, { historyLabel: 'historyLabelAddWidget' });It takes a locale key rather than text, for the same reason an entry carries one: the name is resolved when the history is read, in whatever language is then in force. Studio's own keys are listed under Localisation, and localeText overrides any of them.
A key of your own works alongside them through getLocaleText, which takes any key rather than only Studio's:
const getLocaleText = ({ key, defaultValue }) =>
key === 'tidiedByAssistant' ? 'Tidied up by your assistant' : defaultValue;
<AgStudio getLocaleText={getLocaleText} />api.setState(nextState, { historyLabel: 'tidiedByAssistant' });A key nothing resolves falls back to the key itself, which shows up as an obviously wrong label rather than an empty one. Where the text takes values, pass them alongside the key and they reach translate() with it:
api.setState(nextState, {
historyLabel: 'renamedField',
historyLabelVariableValues: { name: 'Revenue' },
}); Who Made a Change Copy Link
Every entry carries a source saying which surface the change came through: 'ui' for an edit made in Studio's interface, 'api' for one your application wrote through setState(), and 'ai' for one an agent made, with agent naming which:
const { undo } = api.getHistory();
const rows = undo.toReversed().map((entry) => ({
id: entry.id,
name: api.translate(entry.labelKey, entry.labelVariableValues),
by: entry.source.type === 'ai' ? `agent ${entry.source.agent}` : entry.source.type,
}));Add a chart, then ask the assistant for one, and the list attributes each row to whoever made it. Clicking a row steps straight to that point rather than one change at a time:
"use client";
import React, {
useCallback,
useMemo,
useRef,
useState,
StrictMode,
} from "react";
import { createRoot } from "react-dom/client";
import { AgStudio, AgStudioRef } from "ag-studio-react";
import {
AgDataEngine,
AgDataSourcesDefinition,
AgFieldDefinition,
AgReportHistory,
AgReportHistoryEntry,
AgReportState,
AgStudioApi,
AgStudioGetLocaleTextParams,
AgStudioMode,
AgStudioProperties,
enableStudioDevValidations,
} from "ag-studio";
if (process.env.NODE_ENV !== "production") {
// Enable extended validations only for development
enableStudioDevValidations();
}
const salesData = [
{ region: "North", product: "Widget", revenue: 4200 },
{ region: "North", product: "Gadget", revenue: 3100 },
{ region: "South", product: "Widget", revenue: 2600 },
{ region: "South", product: "Gadget", revenue: 5400 },
{ region: "East", product: "Widget", revenue: 1800 },
{ region: "East", product: "Gadget", revenue: 3900 },
{ region: "West", product: "Widget", revenue: 4700 },
{ region: "West", product: "Gadget", revenue: 2200 },
];
const salesFields: AgFieldDefinition[] = [
{ id: "region", name: "Region", format: "textFormat" },
{ id: "product", name: "Product", format: "textFormat" },
{ id: "revenue", name: "Revenue", format: "currencyFormat" },
];
const INITIAL_WIDGET_IDS = ["by-region"];
function selectedPage(state: AgReportState) {
return state.pages.find((page) => page.id === state.selectedPageId)!;
}
const addedChartIds: (state: AgReportState) => string[] = (
state: AgReportState,
) => {
const widgets = selectedPage(state).widgets ?? {};
return Object.keys(widgets).filter((id) => !INITIAL_WIDGET_IDS.includes(id));
};
const stateWithChart: (
state: AgReportState,
dimension: "product" | "region",
title: string,
) => AgReportState = (
state: AgReportState,
dimension: "product" | "region",
title: string,
) => {
const page = selectedPage(state);
const added = addedChartIds(state);
const nextNum =
added.reduce(
(max, chartId) =>
Math.max(max, Number(chartId.slice("chart-".length)) || 0),
0,
) + 1;
const id = `chart-${nextNum}`;
return {
...state,
pages: state.pages.map((p) =>
p.id !== page.id
? p
: {
...p,
widgets: {
...p.widgets,
[id]: {
type: "bar-chart-grouped",
dataMapping: {
categoryKey: [{ id: `sales.${dimension}` }],
valueKey: [{ id: "sales.revenue", aggregation: "sum" }],
},
format: { title: { enabled: true, text: title } },
},
},
widgetLayout: {
...p.widgetLayout,
[id]: {
xTrack: 0,
yTrack: 18 + (nextNum - 1) * 14,
xSpan: 24,
ySpan: 14,
},
},
},
),
};
};
const describeSource: (entry: AgReportHistoryEntry) => string = (
entry: AgReportHistoryEntry,
) => {
const { source } = entry;
const by =
source.type === "ai"
? `agent ${source.agent}`
: source.type === "ui"
? "you, in Studio"
: "your app";
return `${by} at ${new Date(entry.at).toLocaleTimeString()}`;
};
const StudioExample = () => {
const studioRef = useRef<AgStudioRef>(null);
const containerStyle = useMemo(() => ({ width: "100%", height: "100%" }), []);
const studioStyle = useMemo(() => ({ height: "100%", width: "100%" }), []);
const [data, setData] = useState<AgDataSource>({
sources: [
{ id: "sales", name: "Sales", data: salesData, fields: salesFields },
],
});
const initialState = useMemo<AgReportState | AgReportHistory>(() => {
return {
selectedPageId: "main",
pages: [
{
id: "main",
widgets: {
"by-region": {
type: "bar-chart-grouped",
dataMapping: {
categoryKey: [{ id: "sales.region" }],
valueKey: [{ id: "sales.revenue", aggregation: "sum" }],
},
format: { title: { enabled: true, text: "Revenue by Region" } },
},
},
widgetLayout: {
"by-region": { xTrack: 0, yTrack: 0, xSpan: 24, ySpan: 18 },
},
},
],
};
}, []);
const getLocaleText = useCallback(
({ key, defaultValue }) =>
key === "addedByAssistant" ? "Added by the assistant" : defaultValue,
[],
);
const onStateUpdated = useCallback(() => {
refreshControls();
}, []);
// No `source`, so the change is recorded as an ordinary application write.
const addChart = useCallback(() => {
studioRef.current!.api.setState(
stateWithChart(
studioRef.current!.api.getState(),
"product",
"Revenue by Product",
),
);
}, []);
// What an application writes on its own agent's behalf: the change is named, and attributed to the
// agent that made it rather than to the user.
const askAssistant = useCallback(() => {
studioRef.current!.api.setState(
stateWithChart(
studioRef.current!.api.getState(),
"region",
"Revenue by Region (assistant)",
),
{
historyLabel: "addedByAssistant",
source: { type: "ai", agent: "assistant" },
},
);
}, []);
const doUndo = useCallback(() => {
studioRef.current!.api.undo();
}, []);
const doRedo = useCallback(() => {
studioRef.current!.api.redo();
}, []);
// Step straight to an entry rather than one change at a time, which is what a history list is for.
const undoTo = useCallback((entryId: string) => {
studioRef.current!.api.undo(entryId);
}, []);
const redoTo = useCallback((entryId: string) => {
studioRef.current!.api.redo(entryId);
}, []);
const entryRow = useCallback(
(entry: AgReportHistoryEntry, undone: boolean): HTMLLIElement => {
const row = document.createElement("li");
const button = document.createElement("button");
button.className = "history-entry";
button.dataset.undone = String(undone);
button.onclick = () => (undone ? redoTo(entry.id) : undoTo(entry.id));
const label = document.createElement("span");
label.className = "history-label";
// The entry carries the locale key, not text, so the list follows the language in force.
label.textContent = studioRef.current!.api.translate(
entry.labelKey,
entry.labelVariableValues,
);
const by = document.createElement("span");
by.className = "history-by";
by.textContent = describeSource(entry);
button.append(label, by);
row.append(button);
return row;
},
[redoTo, undoTo],
);
const refreshControls = useCallback(() => {
const { undo, redo } = studioRef.current!.api.getHistory();
(document.getElementById("undoBtn") as HTMLButtonElement).disabled =
undo.length === 0;
(document.getElementById("redoBtn") as HTMLButtonElement).disabled =
redo.length === 0;
const list = document.getElementById("historyList")!;
// Newest first, in the order the list is read: the furthest-forward undone change, down through
// the next to undo, to the oldest. `redo` is ordered away from the live state already, `undo`
// towards it.
list.replaceChildren(
...redo.map((entry) => entryRow(entry, true)),
...[...undo].reverse().map((entry) => entryRow(entry, false)),
);
document.getElementById("historyEmpty")!.hidden =
undo.length + redo.length > 0;
}, [entryRow]);
return (
<div style={containerStyle}>
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
<div className="example-controls">
<div className="controls-row">
<button onClick={addChart}>Add chart</button>
<button onClick={askAssistant}>Ask the assistant</button>
<button id="undoBtn" onClick={doUndo}>
Undo
</button>
<button id="redoBtn" onClick={doRedo}>
Redo
</button>
</div>
</div>
<div style={{ display: "flex", flex: "1", minHeight: "0" }}>
<AgStudio
ref={studioRef}
style={studioStyle}
className="my-studio-container"
data={data}
initialState={initialState}
mode={"edit"}
getLocaleText={getLocaleText}
onStateUpdated={onStateUpdated}
/>
<div className="history-panel">
<h3 className="history-heading">History</h3>
<ol id="historyList" className="history-list"></ol>
<p id="historyEmpty" className="history-empty">
No changes yet.
</p>
</div>
</div>
</div>
</div>
);
};
const root = createRoot(document.getElementById("root")!);
root.render(
<StrictMode>
<StudioExample />
</StrictMode>,
);
Changes made by Studio's built-in agents report that agent as their source, so an undo list distinguishes them both from each other and from the user's own edits. Name your own agent when you write on its behalf:
api.setState(nextState, {
historyLabel: 'tidiedByAssistant',
source: { type: 'ai', agent: 'my-assistant' },
});Changes from different sources are never folded into one entry, even where they would otherwise have been recorded together, so every entry has exactly one source to attribute it to. An entry keeps its source as it moves between the undo and redo stacks.
Restoring a History Copy Link
A history does not survive a page refresh on its own: loading report state starts a new baseline with empty stacks. getHistory() returns the live state alongside both stacks, so persisting it saves the report state and the history that belongs to it as one value:
// Before the page unloads
sessionStorage.setItem('studio', JSON.stringify(api.getHistory()));Pass that back as initialState to restore both. The property takes either report state, as getState() returns, or a history, as getHistory() returns:
const initialState = useMemo(() => {
return JSON.parse(sessionStorage.getItem('studio'));
}, []);
<AgStudio initialState={initialState} />Studio loads the history's current as the report state and rebuilds the stacks around it, so there is no way to restore a history against report state it was not recorded against. Each entry comes back with its key, timestamp, focus and source, and its name is resolved from the key afresh, so a history recorded in one language reads correctly in another.
maxEntries applies to a restored history as to any other: one longer than the limit loses its oldest entries, exactly as it would have on the way in.
Hand the history back as getHistory() produced it. One whose stacks have been edited or reordered is rejected, and reports an error through onErrorRaised; the report state still loads, so a corrupt history costs the undo stack rather than the dashboard.
Limiting the History Copy Link
Each entry holds the whole state either side of its change, so a long history on a large dashboard costs memory. Set history.maxEntries to trade how far back a user can undo against what that retention costs:
const history = {
maxEntries: 20,
};
<AgStudio history={history} />Once the history holds that many changes, recording a new one discards the oldest, which can no longer be undone. Undone changes do not add to the count: undoing moves a change from the undo side to the redo side rather than creating another one, so the two stacks together never exceed the limit.
A value below one keeps no history, leaving undo() and redo() with nothing to do. Changes still apply as normal; only the ability to reverse them is given up.
Clearing the History Copy Link
Call clearHistory() to discard the undo and redo stacks without touching the live state:
api.clearHistory();getHistory() then returns empty stacks, and undo()/redo() are no-ops until the next change. The live state is untouched, so the changes themselves remain - only the ability to step back over them goes.
Set history.onModeChange to have this happen automatically whenever mode changes:
const history = {
onModeChange: 'clear',
};
<AgStudio history={history} />The default, 'preserve', carries the history across a mode switch. 'clear' discards it on a switch in either direction, so a change made while in view mode (possible when filter editing is enabled there) is discarded too on the way back to edit mode. Mode does not otherwise gate undo or redo - both work in either mode.
Add a chart or two below, then clear the history explicitly or switch mode with onModeChange set to 'clear'. The counts show both stacks emptying while the charts stay where they are:
"use client";
import React, {
useCallback,
useMemo,
useRef,
useState,
StrictMode,
} from "react";
import { createRoot } from "react-dom/client";
import { AgStudio, AgStudioRef } from "ag-studio-react";
import {
AgDataEngine,
AgDataSourcesDefinition,
AgFieldDefinition,
AgHistoryOptions,
AgReportHistory,
AgReportState,
AgStudioApi,
AgStudioMode,
AgStudioProperties,
enableStudioDevValidations,
} from "ag-studio";
if (process.env.NODE_ENV !== "production") {
// Enable extended validations only for development
enableStudioDevValidations();
}
const salesData = [
{ region: "North", product: "Widget", revenue: 4200 },
{ region: "North", product: "Gadget", revenue: 3100 },
{ region: "South", product: "Widget", revenue: 2600 },
{ region: "South", product: "Gadget", revenue: 5400 },
{ region: "East", product: "Widget", revenue: 1800 },
{ region: "East", product: "Gadget", revenue: 3900 },
{ region: "West", product: "Widget", revenue: 4700 },
{ region: "West", product: "Gadget", revenue: 2200 },
];
const salesFields: AgFieldDefinition[] = [
{ id: "region", name: "Region", format: "textFormat" },
{ id: "product", name: "Product", format: "textFormat" },
{ id: "revenue", name: "Revenue", format: "currencyFormat" },
];
const INITIAL_WIDGET_IDS = ["by-region"];
// Held here rather than read back through `getProperty('history')`: a framework wrapper may model
// the property as its own component state, which does not settle until after this handler returns.
let onModeChange: "preserve" | "clear" = "preserve";
function selectedPage(state: AgReportState) {
return state.pages.find((page) => page.id === state.selectedPageId)!;
}
// Read the added charts back from live state, so the count stays in step with undo and redo rather
// than tracking a private list that drifts once state is restored.
const addedChartIds: (state: AgReportState) => string[] = (
state: AgReportState,
) => {
const widgets = selectedPage(state).widgets ?? {};
return Object.keys(widgets).filter((id) => !INITIAL_WIDGET_IDS.includes(id));
};
const StudioExample = () => {
const studioRef = useRef<AgStudioRef>(null);
const containerStyle = useMemo(() => ({ width: "100%", height: "100%" }), []);
const studioStyle = useMemo(() => ({ height: "100%", width: "100%" }), []);
const [data, setData] = useState<AgDataSource>({
sources: [
{ id: "sales", name: "Sales", data: salesData, fields: salesFields },
],
});
const initialState = useMemo<AgReportState | AgReportHistory>(() => {
return {
selectedPageId: "main",
pages: [
{
id: "main",
widgets: {
"by-region": {
type: "bar-chart-grouped",
dataMapping: {
categoryKey: [{ id: "sales.region" }],
valueKey: [{ id: "sales.revenue", aggregation: "sum" }],
},
format: { title: { enabled: true, text: "Revenue by Region" } },
},
},
widgetLayout: {
"by-region": { xTrack: 0, yTrack: 0, xSpan: 24, ySpan: 18 },
},
},
],
};
}, []);
const history = useMemo<AgHistoryOptions>(() => {
return { onModeChange: "preserve" };
}, []);
const onStateUpdated = useCallback(() => {
refreshControls();
}, []);
const addChart = useCallback(() => {
const state = studioRef.current!.api.getState();
const page = selectedPage(state);
const added = addedChartIds(state);
// Past the largest suffix in use, not the count: a chart deleted through Studio's own widget
// menu would otherwise free a number that is still on a surviving chart, and adding would
// overwrite it instead of adding one.
const nextNum =
added.reduce(
(max, chartId) =>
Math.max(max, Number(chartId.slice("chart-".length)) || 0),
0,
) + 1;
const id = `chart-${nextNum}`;
studioRef.current!.api.setState({
...state,
pages: state.pages.map((p) =>
p.id !== page.id
? p
: {
...p,
widgets: {
...p.widgets,
[id]: {
type: "bar-chart-grouped",
dataMapping: {
categoryKey: [{ id: "sales.product" }],
valueKey: [{ id: "sales.revenue", aggregation: "sum" }],
},
format: {
title: {
enabled: true,
text: `Revenue by Product (${id})`,
},
},
},
},
widgetLayout: {
...p.widgetLayout,
[id]: {
xTrack: 0,
yTrack: 18 + (nextNum - 1) * 14,
xSpan: 24,
ySpan: 14,
},
},
},
),
});
}, []);
const doUndo = useCallback(() => {
studioRef.current!.api.undo();
}, []);
const doRedo = useCallback(() => {
studioRef.current!.api.redo();
}, []);
// The live state is untouched: the charts stay, only the ability to step back over them goes.
const clearHistory = useCallback(() => {
studioRef.current!.api.clearHistory();
}, []);
const refreshControls = useCallback(() => {
const { undo, redo } = studioRef.current!.api.getHistory();
const mode = studioRef.current!.api.getProperty("mode");
(document.getElementById("undoBtn") as HTMLButtonElement).disabled =
undo.length === 0;
(document.getElementById("redoBtn") as HTMLButtonElement).disabled =
redo.length === 0;
(document.getElementById("clearBtn") as HTMLButtonElement).disabled =
undo.length === 0 && redo.length === 0;
document.getElementById("modeBtn")!.textContent =
mode === "edit" ? "Switch to View Mode" : "Switch to Edit Mode";
document.getElementById("onModeChangeBtn")!.textContent =
`onModeChange: ${onModeChange}`;
document.getElementById("undoCount")!.textContent = String(undo.length);
document.getElementById("redoCount")!.textContent = String(redo.length);
}, [onModeChange]);
const toggleOnModeChange = useCallback(() => {
onModeChange = onModeChange === "clear" ? "preserve" : "clear";
studioRef.current!.api.setProperty("history", {
onModeChange: onModeChange,
});
refreshControls();
}, [onModeChange, refreshControls]);
const toggleMode = useCallback(() => {
const next =
studioRef.current!.api.getProperty("mode") === "edit" ? "view" : "edit";
studioRef.current!.api.setProperty("mode", next);
refreshControls();
}, [refreshControls]);
return (
<div style={containerStyle}>
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
<div className="example-controls">
<div className="controls-row">
<button onClick={addChart}>Add chart</button>
<button id="undoBtn" onClick={doUndo}>
Undo
</button>
<button id="redoBtn" onClick={doRedo}>
Redo
</button>
<button id="clearBtn" onClick={clearHistory}>
Clear history
</button>
<button id="onModeChangeBtn" onClick={toggleOnModeChange}>
onModeChange: preserve
</button>
<button id="modeBtn" onClick={toggleMode}>
Switch to View Mode
</button>
<span className="history-status">
undo: <span id="undoCount">0</span>, redo:{" "}
<span id="redoCount">0</span>
</span>
</div>
</div>
<AgStudio
ref={studioRef}
style={studioStyle}
className="my-studio-container"
data={data}
initialState={initialState}
mode={"edit"}
history={history}
onStateUpdated={onStateUpdated}
/>
</div>
</div>
);
};
const root = createRoot(document.getElementById("root")!);
root.render(
<StrictMode>
<StudioExample />
</StrictMode>,
);
Undo & Redo API Copy Link
Properties Copy Link
Keyboard shortcuts Studio should not handle, so an application can bind them itself.
Each shortcut is suppressed independently, e.g. { redo: true } keeps undo on ctrl/cmd+Z and
leaves ctrl/cmd+shift+Z to the application. |
How Studio's undo and redo history behaves. |
API Methods Copy Link
Set the current state of Studio.
Can be used in conjunction with api.getState() or onStateUpdated
to save and restore Studio state.
The state is expected to be a full state object, not a partial state object.
State must be updated immutably as Studio uses reference equality
to determine which parts of state have changed.
options names the change in the undo history and says who made it; see
AgSetStateOptions. |
Undo the last change to the report state (widgets, layout, filters,
schema). View state such as the selected page or selection is
left as it is, then brought back into view for the restored change.
No-op when there is nothing to undo ( getHistory().undo is empty).
Pass a getHistory().undo entry's id to undo every change back through that entry in a
single step; an unknown id is a no-op. |
Redo the change most recently undone. No-op when there is nothing to redo
( getHistory().redo is empty). Making a fresh change discards the redo branch.
Pass a getHistory().redo entry's id to redo every change forward through that entry in a
single step; an unknown id is a no-op. |
The editing history: the live state plus what undo() and redo() would step through, each
entry labelled and timestamped. Drives control enablement (an empty undo stack means there is
nothing to undo) and a history list. History does not survive loading new state. |
Discard the undo and redo history, so getHistory() returns empty stacks and
undo()/redo() become no-ops until the next change. The live state is untouched. |
Resolve a locale key to text in the locale now in force, applying localeText and
getLocaleText. How a history entry's labelKey becomes something to show:
api.translate(entry.labelKey, entry.labelVariableValues).
A key Studio does not know is passed to getLocaleText like any other, and falls back to the
key itself where nothing resolves it, so your own keys work alongside Studio's. |