Skip to content
9 changes: 9 additions & 0 deletions blocks/sheet/sheet.css
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,15 @@ body {
width: 200px;
}

.da-sheet-not-permitted {
display: flex;
align-items: center;
justify-content: center;
min-height: 200px;
color: light-dark(#555, #aaa);
font: var(--nx-font-body, 14px/1.4 system-ui, sans-serif);
}

.da-version-wrapper {
display: flex;
justify-content: center;
Expand Down
17 changes: 17 additions & 0 deletions blocks/sheet/sheet.js
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,23 @@ export default async function init(el) {
bindStatus('sheet-dirty', document, () => { isDirty = true; });
bindStatus('sheet-clean', document, () => { isDirty = false; });

// getData (called via reloadSheet) emits this on every non-version load so the
// grid can be swapped for a message when the user can't read the file at all.
document.addEventListener('sheet-load-status', ({ detail: { errMsg } }) => {
daSheet.hidden = !!errMsg;
if (!errMsg) {
wrapper.querySelector('.da-sheet-not-permitted')?.remove();
return;
}
let notPermitted = wrapper.querySelector('.da-sheet-not-permitted');
if (!notPermitted) {
notPermitted = document.createElement('div');
notPermitted.className = 'da-sheet-not-permitted';
wrapper.append(notPermitted);
}
notPermitted.textContent = errMsg;
});

// Set data against the title & sheet
setSheet(details, daTitle, daSheet);

Expand Down
16 changes: 15 additions & 1 deletion blocks/sheet/utils/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,16 @@ export function getPermissions() {
return permissions;
}

function sheetErrorFromResponse(resp) {
if (resp.status === 401) return 'Sign in required';
if (resp.status === 403) return 'Not permitted';
return undefined;
}

function emitLoadStatus(errMsg) {
document.dispatchEvent(new CustomEvent('sheet-load-status', { detail: { errMsg } }));
}

// Takes a pathDetails object ({ org, site, path, view }). For a version restore,
// pass the same doc details plus a `versionId` and it routes through versions.get.
export async function getData(input) {
Expand Down Expand Up @@ -129,7 +139,11 @@ export async function getData(input) {
permissions = resp.permissions;
canWrite = resp.permissions?.some((permission) => permission === 'write');

if (!resp.ok) return getDefaultSheet();
if (!resp.ok) {
if (!isVersion) emitLoadStatus(sheetErrorFromResponse(resp));
return getDefaultSheet();
}
if (!isVersion) emitLoadStatus();

const sheets = [];

Expand Down
80 changes: 80 additions & 0 deletions test/unit/blocks/sheet/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -471,3 +471,83 @@ describe('Sheets', () => {
}
});
});

function buildMockFetchStatus(status) {
return async (url) => {
if (url.startsWith('https://admin.hlx.page/ping')) {
return new Response('', { status: 200, headers: new Headers() });
}
if (url.startsWith('https://admin.da.live/source/')) {
return new Response('', { status });
}
return undefined;
};
}

// getData signals its own status via a document event (matching the existing
// sheet-dirty / sheet-clean convention in utils/utils.js) rather than a getter
// over module state, so sheet.js doesn't have to thread a DOM node through
// setSheet/reloadSheet just to know whether to show a not-permitted message.
async function captureLoadEvents(run) {
const events = [];
const onStatus = (e) => events.push({ ...e.detail });
document.addEventListener('sheet-load-status', onStatus);
try {
await run();
} finally {
document.removeEventListener('sheet-load-status', onStatus);
}
return events;
}

describe('sheet-load-status event', () => {
it('emits a "Sign in required" message for a 401', async () => {
const savedFetch = window.fetch;
try {
window.fetch = buildMockFetchStatus(401);

const events = await captureLoadEvents(() => sh.getData(SOURCE_DETAILS));
expect(events).to.deep.equal([{ errMsg: 'Sign in required' }]);
} finally {
window.fetch = savedFetch;
}
});

it('emits a "Not permitted" message for a 403', async () => {
const savedFetch = window.fetch;
try {
window.fetch = buildMockFetchStatus(403);

const events = await captureLoadEvents(() => sh.getData(SOURCE_DETAILS));
expect(events).to.deep.equal([{ errMsg: 'Not permitted' }]);
} finally {
window.fetch = savedFetch;
}
});

it('emits no message for a 404 (treated as a new, empty sheet)', async () => {
const savedFetch = window.fetch;
try {
window.fetch = buildMockFetchStatus(404);

const events = await captureLoadEvents(() => sh.getData(SOURCE_DETAILS));
expect(events).to.deep.equal([{ errMsg: undefined }]);
} finally {
window.fetch = savedFetch;
}
});

it('emits no message once a load succeeds after a prior error', async () => {
const savedFetch = window.fetch;
try {
window.fetch = buildMockFetchStatus(403);
await sh.getData(SOURCE_DETAILS);

window.fetch = buildMockFetch('{ "total": 0, "limit": 0, "offset": 0, "data": [], ":type": "sheet" }');
const events = await captureLoadEvents(() => sh.getData(SOURCE_DETAILS));
expect(events).to.deep.equal([{ errMsg: undefined }]);
} finally {
window.fetch = savedFetch;
}
});
});
Loading