const test = require('node:test');
const assert = require('node:assert/strict');
const SCRIPT = require.resolve('./mutation-submit.js');
const {
deletedKey,
pruneWire,
removedKeys,
submitTarget,
swapTargets,
tableRootFor,
} = require(SCRIPT);
const { listenerDocument } = require('./test-dom');
test('a row-delete action names its record key', () => {
assert.equal(
deletedKey('/admin/users/01a0cf6a-7885-75e6-8230-74b677786c1f/delete'),
'01a0cf6a-7885-75e6-8230-74b677786c1f',
);
});
test('a percent-encoded key is decoded to the wire spelling', () => {
assert.equal(deletedKey('/admin/users/a%20b%2Fc/delete'), 'a b/c');
});
test('a URL that is not a row-delete action names no record', () => {
assert.equal(deletedKey('/admin/users/bulk-delete'), null);
assert.equal(deletedKey('/admin/users'), null);
assert.equal(deletedKey(''), null);
});
const withAttributes = (attrs) => ({
getAttribute: (name) => (name in attrs ? attrs[name] : null),
});
test('a submit posts to the form action by default', () => {
const form = withAttributes({ action: '/admin/posts/bulk-delete' });
assert.equal(submitTarget(form, null), '/admin/posts/bulk-delete');
assert.equal(submitTarget(form, withAttributes({})), '/admin/posts/bulk-delete');
});
test("a bulk action button posts to its own formaction", () => {
const form = withAttributes({ action: '/admin/posts/bulk-delete' });
const button = withAttributes({ formaction: '/admin/posts/-/actions/publish' });
assert.equal(submitTarget(form, button), '/admin/posts/-/actions/publish');
});
const formWith = (fields) => ({
querySelector: (selector) => {
const name = /name="([^"]+)"/.exec(selector);
return name && name[1] in fields ? fields[name[1]] : null;
},
});
test('a bulk form removes the batch it carried', () => {
const form = formWith({ ids: { value: ',a,b,' } });
assert.deepEqual(removedKeys(form, '/admin/users/bulk-delete'), ['a', 'b']);
});
test('a bulk custom action removes nothing from the selection', () => {
const form = formWith({ ids: { value: ',a,b,' } });
assert.deepEqual(removedKeys(form, '/admin/users/-/actions/publish'), []);
});
test('a row form removes the one record its action names', () => {
const form = formWith({});
assert.deepEqual(removedKeys(form, '/admin/users/ada/delete'), ['ada']);
});
test('a form naming neither removes nothing', () => {
assert.deepEqual(removedKeys(formWith({}), '/admin/users/bulk-delete'), []);
});
const closestOf = (answers) => ({
closest: (selector) => answers[selector] || null,
});
test('a form inside a table belongs to that table', () => {
const own = closestOf({});
const form = closestOf({ '[data-table-root]': own });
assert.equal(tableRootFor(form, '/admin/users/ada/delete'), own);
});
test('a row confirm outside every table finds its table through its control', () => {
const root = closestOf({});
const trigger = {
getAttribute: (name) =>
name === 'data-row-delete-action' ? '/admin/users/ada/delete' : null,
closest: (selector) => (selector === '[data-table-root]' ? root : null),
};
global.document = { querySelectorAll: () => [trigger] };
try {
const form = closestOf({});
assert.equal(tableRootFor(form, '/admin/users/ada/delete'), root);
} finally {
delete global.document;
}
});
test('a form whose control is gone belongs to no table', () => {
global.document = { querySelectorAll: () => [] };
try {
assert.equal(tableRootFor(closestOf({}), '/admin/users/ada/delete'), null);
} finally {
delete global.document;
}
});
test('pruning drops the removed keys and keeps the rest', () => {
assert.equal(pruneWire(',a,b,c,', ['b']), ',a,c,');
assert.equal(pruneWire(',a,b,', ['a', 'b']), '');
assert.equal(pruneWire('', ['a']), '');
assert.equal(pruneWire(',ab,', ['b']), ',ab,');
});
test('pruning keeps keys selected on another page', () => {
assert.equal(pruneWire(',page2,page3,', ['page2']), ',page3,');
});
const nodeOf = (selectors) => ({
querySelector: (selector) => selectors[selector] || null,
});
const regionNode = (table) =>
nodeOf({
'[data-boundary="table"]': table,
'[data-boundary="table"]:not([aria-busy])': table,
});
const docOf = ({ templates = [], toasts = [], region = null } = {}) => ({
querySelectorAll: (selector) => {
if (selector === 'template[data-topcoat-swap]') return templates;
if (selector === '[data-sonner-toaster] > [data-sonner-toast]') return toasts;
return [];
},
querySelector: (selector) =>
selector === '[data-boundary="table"]:not([aria-busy])' ? region : null,
});
test('the streamed envelope wins over the skeleton the response also carries', () => {
const table = nodeOf({});
const skeleton = nodeOf({});
const targets = swapTargets(
docOf({
templates: [{ content: nodeOf({}) }, { content: regionNode(table) }],
region: skeleton,
}),
);
assert.equal(targets.table, table);
});
test('a response with no envelope hands over its own rendered region', () => {
const table = nodeOf({});
const targets = swapTargets(docOf({ region: table }));
assert.equal(targets.table, table);
});
test('a response offering no rendered region hands over nothing', () => {
assert.equal(swapTargets(docOf({})).table, null);
});
test('a named region takes the response region of its own name', () => {
const own = nodeOf({});
const doc = {
querySelectorAll: () => [],
querySelector: (selector) =>
selector === '[data-boundary="table"][data-table="/admin/tags"]:not([aria-busy])'
? own
: null,
};
assert.equal(swapTargets(doc, '/admin/tags').table, own);
assert.equal(swapTargets(doc, '/admin/comments').table, null);
});
test('every toast surface the response carries is handed over', () => {
const toasts = [nodeOf({}), nodeOf({})];
assert.deepEqual(swapTargets(docOf({ toasts })).toasts, toasts);
});
function standInDocument() {
return listenerDocument({
written: [],
querySelectorAll: () => [],
querySelector: () => null,
open() {},
write(html) {
this.written.push(html);
},
close() {},
});
}
const submitForm = ({
marked = true,
action = '/admin/users/ada/delete',
dialog = null,
} = {}) => {
const form = {
getAttribute: (name) => (name === 'action' ? action : null),
matches: () => marked,
closest: (selector) => {
if (selector === 'dialog') return dialog;
return marked && selector === 'form[data-mutation-submit]' ? form : null;
},
querySelector: (selector) => (selector === 'dialog' ? dialog : null),
};
return form;
};
function withGlobals(run) {
const document = standInDocument();
const calls = { reloaded: 0, assigned: [] };
global.document = document;
global.window = {
location: {
reload: () => calls.reloaded++,
assign: (url) => calls.assigned.push(url),
href: 'http://localhost/admin/users',
},
history: { state: null, replaceState() {} },
setTimeout: () => 0,
clearTimeout() {},
};
delete require.cache[SCRIPT];
const drop = () => {
delete global.document;
delete global.window;
};
let result;
try {
require(SCRIPT);
result = run({ document, calls });
} catch (error) {
drop();
throw error;
}
if (result && typeof result.then === 'function') {
return result.finally(drop);
}
drop();
return result;
}
function submitEvent(form) {
return {
prevented: false,
target: form,
submitter: null,
preventDefault() {
this.prevented = true;
},
};
}
test('a marked form with a target is answered in place', () => {
withGlobals(({ document }) => {
const form = submitForm();
const event = submitEvent(form);
document.listeners('submit').forEach((handler) => handler(event));
assert.equal(event.prevented, true, 'the navigation must not happen');
});
});
test('a form the page cannot serve keeps the browser submit', () => {
withGlobals(({ document }) => {
const form = submitForm({ action: null });
const event = submitEvent(form);
document.listeners('submit').forEach((handler) => handler(event));
assert.equal(event.prevented, false, 'the browser posts it');
});
});
test('a form without the marker is left alone', () => {
withGlobals(({ document }) => {
const form = submitForm({ marked: false });
const event = submitEvent(form);
document.listeners('submit').forEach((handler) => handler(event));
assert.equal(event.prevented, false, 'the browser posts it');
});
});
function recordableForm(options) {
const form = submitForm(options);
form.submits = 0;
form.submit = () => {
form.submits += 1;
};
return form;
}
async function withServerAnswer(answer, run) {
return withGlobals(async (context) => {
const realFormData = global.FormData;
const realFetch = global.fetch;
global.FormData = class {
constructor(form) {
this.form = form;
}
};
global.fetch = async () => answer;
try {
return await run(context);
} finally {
global.FormData = realFormData;
global.fetch = realFetch;
}
});
}
test('a server-answered failure is shown, not posted again', async () => {
await withServerAnswer(
{
redirected: false,
ok: false,
status: 500,
text: async () => '<!doctype html><p>the write failed</p>',
},
async ({ document }) => {
const form = recordableForm();
const event = submitEvent(form);
document.listeners('submit').forEach((handler) => handler(event));
await new Promise((resolve) => setImmediate(resolve));
assert.equal(form.submits, 0, 'the committed delete must not be re-sent');
assert.deepEqual(
document.written,
['<!doctype html><p>the write failed</p>'],
'the server answer is shown where the browser would have shown it',
);
},
);
});
test('a refused submit shows the response without repeating the request', async () => {
await withServerAnswer(
{
redirected: false,
ok: false,
status: 403,
text: async () => '<!doctype html><p>not allowed</p>',
},
async ({ document }) => {
const form = recordableForm();
const event = submitEvent(form);
document.listeners('submit').forEach((handler) => handler(event));
await new Promise((resolve) => setImmediate(resolve));
assert.equal(form.submits, 0, 'the refused delete is not posted again');
assert.deepEqual(document.written, ['<!doctype html><p>not allowed</p>']);
},
);
});
test('the confirm dialog is held while the mutation is in flight', async () => {
await withServerAnswer(
{
redirected: false,
ok: false,
status: 500,
text: async () => '<!doctype html><p>the write failed</p>',
},
async ({ document }) => {
const dialog = { dataset: {} };
const form = recordableForm({ dialog });
const event = submitEvent(form);
document.listeners('submit').forEach((handler) => handler(event));
assert.equal(
dialog.dataset.dialogBusy,
'true',
'the dialog belongs to the write while it is outstanding',
);
await new Promise((resolve) => setImmediate(resolve));
assert.equal(
dialog.dataset.dialogBusy,
undefined,
'the response hands the dialog back',
);
},
);
});