Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"description": "A simple CSV parser - BountyPay workflow demo",
"main": "src/parser.js",
"scripts": {
"test": "node test/parser.test.js"
"test": "node test/parser.test.js && node test/bounty-list.test.js"
},
"license": "MIT"
}
118 changes: 118 additions & 0 deletions src/bounty-list.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
const DEFAULT_ERROR_MESSAGE = 'Unable to load bounties. Please try again.';

function escapeHtml(value) {
return String(value)
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}

function renderLoading(message = 'Loading bounties...') {
return [
'<div class="bounty-list-loading" role="status" aria-live="polite">',
' <span class="bounty-list-spinner" aria-hidden="true"></span>',
` <span>${escapeHtml(message)}</span>`,
'</div>',
].join('');
}

function renderError(message = DEFAULT_ERROR_MESSAGE) {
return [
'<div class="bounty-list-error" role="alert">',
` ${escapeHtml(message)}`,
'</div>',
].join('');
}

function renderEmpty(message = 'No bounties available.') {
return `<div class="bounty-list-empty">${escapeHtml(message)}</div>`;
}

function normalizeBounties(response) {
if (Array.isArray(response)) {
return response;
}

if (response && Array.isArray(response.bounties)) {
return response.bounties;
}

if (response == null) {
return [];
}

throw new TypeError('Bounty API response must be an array or { bounties: [] }.');
}

function renderBounties(bounties) {
if (!bounties.length) {
return renderEmpty();
}

const items = bounties
.map((bounty) => {
const title = bounty.title || bounty.name || 'Untitled bounty';
const amount = bounty.amount || bounty.reward || '';
const amountMarkup = amount
? `<span class="bounty-list-item-amount">${escapeHtml(amount)}</span>`
: '';

return [
'<li class="bounty-list-item">',
` <span class="bounty-list-item-title">${escapeHtml(title)}</span>`,
` ${amountMarkup}`,
'</li>',
].join('');
})
.join('');

return `<ul class="bounty-list-items">${items}</ul>`;
}

async function loadBountyList(container, fetchBounties, options = {}) {
if (!container || typeof container !== 'object') {
throw new TypeError('A bounty list container is required.');
}

if (typeof fetchBounties !== 'function') {
throw new TypeError('fetchBounties must be a function.');
}

const setAttribute = container.setAttribute?.bind(container);
const removeAttribute = container.removeAttribute?.bind(container);

setAttribute?.('aria-busy', 'true');
setAttribute?.('data-state', 'loading');
container.innerHTML = renderLoading(options.loadingMessage);

try {
const response = await fetchBounties();
const bounties = normalizeBounties(response);

setAttribute?.('data-state', bounties.length ? 'loaded' : 'empty');
container.innerHTML = renderBounties(bounties);

return { status: 'loaded', bounties };
} catch (error) {
setAttribute?.('data-state', 'error');
container.innerHTML = renderError(options.errorMessage);

return { status: 'error', error };
} finally {
setAttribute?.('aria-busy', 'false');
removeAttribute?.('aria-describedby');
}
}

module.exports = {
DEFAULT_ERROR_MESSAGE,
escapeHtml,
loadBountyList,
normalizeBounties,
renderBounties,
renderEmpty,
renderError,
renderLoading,
};
102 changes: 102 additions & 0 deletions test/bounty-list.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
const assert = require('assert');
const {
DEFAULT_ERROR_MESSAGE,
loadBountyList,
normalizeBounties,
renderBounties,
} = require('../src/bounty-list');

function createContainer() {
const attributes = {};

return {
attributes,
innerHTML: '',
setAttribute(name, value) {
attributes[name] = value;
},
removeAttribute(name) {
delete attributes[name];
},
};
}

async function testShowsSpinnerWhileLoading() {
const container = createContainer();
let resolveFetch;
const pendingFetch = new Promise((resolve) => {
resolveFetch = resolve;
});

const resultPromise = loadBountyList(container, () => pendingFetch);

assert.equal(container.attributes['aria-busy'], 'true');
assert.equal(container.attributes['data-state'], 'loading');
assert.match(container.innerHTML, /role="status"/);
assert.match(container.innerHTML, /Loading bounties/);

resolveFetch([{ title: 'Fix parser bug', amount: '$50' }]);
const result = await resultPromise;

assert.equal(result.status, 'loaded');
assert.equal(container.attributes['aria-busy'], 'false');
assert.equal(container.attributes['data-state'], 'loaded');
assert.doesNotMatch(container.innerHTML, /bounty-list-spinner/);
assert.match(container.innerHTML, /Fix parser bug/);
}

async function testHandlesErrorGracefully() {
const container = createContainer();
const result = await loadBountyList(container, async () => {
throw new Error('database exploded');
});

assert.equal(result.status, 'error');
assert.equal(container.attributes['aria-busy'], 'false');
assert.equal(container.attributes['data-state'], 'error');
assert.match(container.innerHTML, /role="alert"/);
assert.match(container.innerHTML, new RegExp(DEFAULT_ERROR_MESSAGE));
assert.doesNotMatch(container.innerHTML, /database exploded/);
}

async function testHandlesEmptyResponses() {
const container = createContainer();
const result = await loadBountyList(container, async () => null);

assert.equal(result.status, 'loaded');
assert.deepEqual(result.bounties, []);
assert.equal(container.attributes['data-state'], 'empty');
assert.match(container.innerHTML, /No bounties available/);
}

function testNormalizesSupportedResponseShapes() {
assert.deepEqual(normalizeBounties([{ title: 'One' }]), [{ title: 'One' }]);
assert.deepEqual(normalizeBounties({ bounties: [{ title: 'Two' }] }), [
{ title: 'Two' },
]);
}

function testEscapesRenderedBountyContent() {
const html = renderBounties([
{ title: '<script>alert(1)</script>', amount: '"$50"' },
]);

assert.match(html, /&lt;script&gt;alert\(1\)&lt;\/script&gt;/);
assert.match(html, /&quot;\$50&quot;/);
assert.doesNotMatch(html, /<script>/);
}

async function run() {
await testShowsSpinnerWhileLoading();
await testHandlesErrorGracefully();
await testHandlesEmptyResponses();
testNormalizesSupportedResponseShapes();
testEscapesRenderedBountyContent();

console.log('bounty-list tests passed');
}

run().catch((error) => {
console.error(error);
process.exit(1);
});