Skip to content

Commit c766025

Browse files
kptdobeclaudechrischrischris
authored
fix: make sheet version restore fully functional (#957)
* fix: make sheet version restore fully functional Three bugs made it impossible to restore a sheet from version history: 1. `resetSheets` cleared `el.className` (removing `da-sheet`), so the second restore's `document.querySelector('.da-sheet')` returned null and crashed with "Cannot read properties of null (reading 'jexcel')". Fix: remove only the `jexcel_tabs` class — the one jspreadsheet checks to decide whether to reuse existing children — instead of wiping all classes. 2. The restore handler called `staleCheck.markSynced(verReview.data)` with jspreadsheet-format data. `checkForDrift` fetches DA-JSON from the server, so these formats never matched, drift was always detected, and `onStale({ dirty: false })` silently reloaded the sheet from the server — undoing the restore before the user could save. Fix: call `staleCheck.markEdited()` instead, preserving the correct server-content baseline while marking the sheet dirty so any genuine concurrent edit shows a dialog rather than a silent reload. 3. After restore `da-sheet-panes.data` was never updated, so the Preview pane still showed the pre-restore content. Fix: set `sheetPanes.data = convertSheets(daTitle.sheet)` after reinitialising the spreadsheet. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: persist restored sheet version to server Sheet version restore was only updating the local jspreadsheet UI; the restored content was never POSTed, so a page refresh re-fetched the old data from the server. Extract restoreVersion into utils.js and have it call saveSheets after re-initializing the sheet — that drives the PUT to /source plus preview sync and staleCheck markSynced. Add unit tests covering both the PUT and the preview-pane update. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Chris Peyer <chrischrischris@users.noreply.github.com>
1 parent 5438c4c commit c766025

5 files changed

Lines changed: 227 additions & 7 deletions

File tree

blocks/sheet/sheet.js

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import getPathDetails from '../shared/pathDetails.js';
33
import { getNx } from '../../scripts/utils.js';
44
import '../edit/da-title/da-title.js';
55
import { getData } from './utils/index.js';
6-
import { staleCheck, showDaDialog } from './utils/utils.js';
6+
import { staleCheck, showDaDialog, restoreVersion } from './utils/utils.js';
77
import { convertSheets } from '../edit/utils/helpers.js';
88

99
const { default: getStyle } = await import(`${getNx()}/utils/styles.js`);
@@ -52,10 +52,7 @@ class DaSheetPanes extends LitElement {
5252
verReview.addEventListener('restore', async () => {
5353
const daTitle = document.querySelector('da-title');
5454
const daSheet = document.querySelector('.da-sheet');
55-
56-
const initSheet = (await import('./utils/index.js')).default;
57-
daTitle.sheet = await initSheet(daSheet, verReview.data);
58-
staleCheck.markSynced(verReview.data);
55+
await restoreVersion(daTitle, daSheet, verReview.data);
5956
verReview.remove();
6057
});
6158

blocks/sheet/utils/index.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ function resetSheets(el) {
1616
if (!el.jexcel) return;
1717
delete el.jexcel;
1818
el.innerHTML = '';
19-
el.className = '';
19+
el.classList.remove('jexcel_tabs');
2020
}
2121

2222
function finishSetup(el, data) {

blocks/sheet/utils/utils.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,12 @@ export const saveSheets = async (sheets) => {
9393

9494
const debouncedSaveSheets = debounce(saveSheets, DEBOUNCE_TIME);
9595

96+
export async function restoreVersion(daTitle, daSheet, versionData) {
97+
const initSheet = (await import('./index.js')).default;
98+
daTitle.sheet = await initSheet(daSheet, versionData);
99+
return saveSheets(daSheet.jexcel);
100+
}
101+
96102
export function handleSave(jexcel, view) {
97103
// markEdited must precede the config bail so config edits still mark dirty for stale-detection.
98104
staleCheck.markEdited();

test/unit/blocks/sheet/index.test.js

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,182 @@ setNx('/test/fixtures/nx', { hostname: 'example.com' });
77

88
const sh = await import('../../../../blocks/sheet/utils/index.js');
99

10+
function mockJspreadsheetTabs(container, data) {
11+
// Mirrors real jspreadsheet.tabs: if jexcel_tabs is present it tries to
12+
// reuse e.children[0] / e.children[1], which are undefined after innerHTML='',
13+
// causing "Cannot read properties of undefined (reading 'appendChild')".
14+
if (container.classList.contains('jexcel_tabs')) {
15+
const first = container.children[0];
16+
first.appendChild(document.createElement('div')); // throws if first is undefined
17+
}
18+
const innerContainers = data.map(() => '<div class="jexcel_container"></div>').join('');
19+
container.innerHTML = `<div></div><div>${innerContainers}</div>`;
20+
container.classList.add('jexcel_tabs');
21+
container.jexcel = data.map((d) => ({ name: d.sheetName, options: {} }));
22+
}
23+
24+
describe('init - double restore', () => {
25+
let el;
26+
let daTitle;
27+
let savedJspreadsheet;
28+
29+
beforeEach(() => {
30+
el = document.createElement('div');
31+
el.className = 'da-sheet';
32+
document.body.appendChild(el);
33+
34+
daTitle = document.createElement('da-title');
35+
daTitle.permissions = [];
36+
document.body.appendChild(daTitle);
37+
38+
savedJspreadsheet = window.jspreadsheet;
39+
window.jspreadsheet = { tabs: mockJspreadsheetTabs };
40+
});
41+
42+
afterEach(() => {
43+
el.remove();
44+
daTitle.remove();
45+
window.jspreadsheet = savedJspreadsheet;
46+
document.querySelectorAll('da-sheet-tabs').forEach((t) => t.remove());
47+
});
48+
49+
it('preserves da-sheet class after second init so restore handler can re-query it', async () => {
50+
const data = [{ sheetName: 'data', minDimensions: [20, 20], data: [['Key'], ['A']], columns: [{ width: '300' }] }];
51+
52+
await sh.default(el, data);
53+
await sh.default(el, data);
54+
55+
expect(el.classList.contains('da-sheet')).to.be.true;
56+
});
57+
58+
it('document.querySelector .da-sheet returns non-null after second init', async () => {
59+
const data = [{ sheetName: 'data', minDimensions: [20, 20], data: [['Key'], ['A']], columns: [{ width: '300' }] }];
60+
61+
await sh.default(el, data);
62+
await sh.default(el, data);
63+
64+
expect(document.querySelector('.da-sheet')).to.not.be.null;
65+
});
66+
67+
it('does not crash on third init call (page load + two restores)', async () => {
68+
const data = [{ sheetName: 'data', minDimensions: [20, 20], data: [['Key'], ['A']], columns: [{ width: '300' }] }];
69+
70+
// Simulates: page load, then first restore, then second restore
71+
await sh.default(el, data);
72+
await sh.default(el, data);
73+
74+
// The restore handler always queries .da-sheet at restore time
75+
const daSheet = document.querySelector('.da-sheet');
76+
expect(daSheet).to.not.be.null;
77+
await sh.default(daSheet, data);
78+
});
79+
});
80+
81+
describe('restoreVersion', () => {
82+
let savedFetch;
83+
let savedJspreadsheet;
84+
let savedHash;
85+
let daSheet;
86+
let daTitle;
87+
let panes;
88+
89+
beforeEach(() => {
90+
savedFetch = window.fetch;
91+
savedHash = window.location.hash;
92+
savedJspreadsheet = window.jspreadsheet;
93+
window.jspreadsheet = {
94+
tabs: (container, data) => {
95+
const inner = data.map(() => '<div class="jexcel_container"></div>').join('');
96+
container.innerHTML = `<div></div><div>${inner}</div>`;
97+
container.classList.add('jexcel_tabs');
98+
container.jexcel = data.map((d) => ({
99+
name: d.sheetName,
100+
options: {},
101+
getData: () => d.data,
102+
getConfig: () => ({ columns: d.columns || [] }),
103+
}));
104+
},
105+
};
106+
107+
daSheet = document.createElement('div');
108+
daSheet.className = 'da-sheet';
109+
document.body.appendChild(daSheet);
110+
111+
// da-sheet-tabs's connectedCallback queries document for a da-title to
112+
// read .permissions from — provide a stub. Since the test file does not
113+
// import sheet.js (which would register da-title as a lit element),
114+
// da-title remains HTMLUnknownElement and skips connectedCallback.
115+
daTitle = document.createElement('da-title');
116+
daTitle.permissions = ['read', 'write'];
117+
document.body.appendChild(daTitle);
118+
119+
panes = document.createElement('da-sheet-panes');
120+
document.body.appendChild(panes);
121+
});
122+
123+
afterEach(() => {
124+
window.fetch = savedFetch;
125+
window.jspreadsheet = savedJspreadsheet;
126+
if (savedHash) window.location.hash = savedHash;
127+
daSheet.remove();
128+
daTitle.remove();
129+
panes.remove();
130+
document.querySelectorAll('da-sheet-tabs').forEach((t) => t.remove());
131+
});
132+
133+
it('PUTs restored version data to /source and returns true', async () => {
134+
const { restoreVersion } = await import('../../../../blocks/sheet/utils/utils.js');
135+
136+
const versionData = [{
137+
sheetName: 'data',
138+
minDimensions: [20, 20],
139+
data: [['Key'], ['restored']],
140+
columns: [{ width: '300' }],
141+
}];
142+
143+
window.location.hash = '#/o/r/sheet';
144+
145+
let putUrl;
146+
let putBody;
147+
window.fetch = async (url, opts) => {
148+
if (opts?.method === 'PUT') {
149+
putUrl = String(url);
150+
putBody = opts.body;
151+
return new Response('', { status: 200 });
152+
}
153+
return new Response('', { status: 200 });
154+
};
155+
156+
const result = await restoreVersion(daTitle, daSheet, versionData);
157+
158+
expect(result).to.be.true;
159+
expect(daTitle.sheet).to.exist;
160+
expect(putUrl).to.contain('/source/o/r/sheet.json');
161+
expect(putBody).to.be.instanceOf(FormData);
162+
const blob = putBody.get('data');
163+
const payload = JSON.parse(await blob.text());
164+
expect(JSON.stringify(payload)).to.contain('restored');
165+
});
166+
167+
it('Updates da-sheet-panes data so preview reflects restored content', async () => {
168+
const { restoreVersion } = await import('../../../../blocks/sheet/utils/utils.js');
169+
170+
const versionData = [{
171+
sheetName: 'data',
172+
data: [['Key'], ['fromVersion']],
173+
columns: [{ width: '300' }],
174+
}];
175+
176+
window.location.hash = '#/o/r/sheet';
177+
window.fetch = async () => new Response('', { status: 200 });
178+
179+
await restoreVersion(daTitle, daSheet, versionData);
180+
181+
expect(panes.data).to.exist;
182+
expect(JSON.stringify(panes.data)).to.contain('fromVersion');
183+
});
184+
});
185+
10186
describe('Sheets', () => {
11187
it('Test single sheet getData', async () => {
12188
const json = `

test/unit/blocks/sheet/utils-utils.test.js

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { expect } from '@esm-bundle/chai';
2-
import { saveSheets, handleSave } from '../../../../blocks/sheet/utils/utils.js';
2+
import { saveSheets, handleSave, staleCheck } from '../../../../blocks/sheet/utils/utils.js';
33

44
describe('sheet/utils utils', () => {
55
let savedFetch;
@@ -51,6 +51,47 @@ describe('sheet/utils utils', () => {
5151
});
5252
});
5353

54+
describe('staleCheck after restore', () => {
55+
const SOURCE_URL = 'http://example.com/source/org/repo/sheet';
56+
const serverJson = { ':type': 'sheet', ':sheetname': 'data', data: [{ key: 'a' }] };
57+
58+
beforeEach(() => {
59+
staleCheck.start({ url: SOURCE_URL, onStale: () => {} });
60+
staleCheck.markSynced(serverJson);
61+
});
62+
63+
afterEach(() => {
64+
staleCheck.stop();
65+
});
66+
67+
it('markSynced with jspreadsheet-format array causes saveSheets to falsely bail', async () => {
68+
// Simulates the old (buggy) restore handler calling markSynced with wrong-format data
69+
const jspsheetData = [{ sheetName: 'data', data: [['key'], ['a']], columns: [] }];
70+
staleCheck.markSynced(jspsheetData);
71+
72+
window.location.hash = '#/org/repo/sheet';
73+
window.fetch = async () => new Response(JSON.stringify(serverJson), { status: 200 });
74+
75+
const sheets = [buildSheet('data', [['key'], ['a']])];
76+
const result = await saveSheets(sheets);
77+
expect(result).to.be.false; // drift falsely detected — save bailed
78+
});
79+
80+
it('Preserves correct baseline so saveSheets proceeds when server is unchanged', async () => {
81+
// Restore handler does NOT call markSynced with wrong-format data — baseline stays intact.
82+
83+
window.location.hash = '#/org/repo/sheet';
84+
window.fetch = async (url, opts) => {
85+
if (opts?.method === 'PUT') return new Response('', { status: 200 });
86+
return new Response(JSON.stringify(serverJson), { status: 200 });
87+
};
88+
89+
const sheets = [buildSheet('data', [['key'], ['a']])];
90+
const result = await saveSheets(sheets);
91+
expect(result).to.be.true; // no false drift — save went through
92+
});
93+
});
94+
5495
describe('handleSave', () => {
5596
it('Skips saving when view is "config"', () => {
5697
// No fetch call means handleSave didn't dispatch saveSheets

0 commit comments

Comments
 (0)