We just wrapped up AG Studio Launch Week See what we shipped

React Embedded AnalyticsUndo & Redo

Version 3.1.0

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.

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 history

The 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:

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:

Undo & Redo API Copy Link

Properties Copy Link

suppressKeyboardCopy Link
AgSuppressKeyboard
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.
historyCopy Link
AgHistoryOptions
How Studio's undo and redo history behaves.

API Methods Copy Link

setStateCopy Link
Function
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.
Function
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.
Function
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.
getHistoryCopy Link
Function
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.
clearHistoryCopy Link
Function
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.
translateCopy Link
Function
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.