generated from Labyricorn/labyricorn-project-template
Post Step 4 Completion
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
import { ExhibitHost } from './host.js';
|
||||
import { argumentSchema } from './validation.js';
|
||||
import { postMessageTransport } from './transport/post-message.js';
|
||||
|
||||
const host = new ExhibitHost();
|
||||
const byId = id => document.getElementById(id);
|
||||
const json = value => JSON.stringify(value, null, 2);
|
||||
const rows = new Map();
|
||||
let frame = null, renderedCatalog = null;
|
||||
function element(tag, text, parent) {
|
||||
const node = document.createElement(tag); if (text !== undefined) node.textContent = text;
|
||||
parent?.append(node); return node;
|
||||
}
|
||||
function editor(spec, label, parent) {
|
||||
const wrapper = element('label', label, parent);
|
||||
const options = spec.enum;
|
||||
const input = element(options ? 'select' : 'input', undefined, wrapper);
|
||||
input.setAttribute('aria-label', label);
|
||||
if (options) {
|
||||
for (const [index, value] of options.entries()) {
|
||||
const option = element('option', spec.optionLabels?.[index] ?? String(value), input); option.value = String(index);
|
||||
}
|
||||
} else {
|
||||
input.type = spec.type === 'boolean' ? 'checkbox' : ['number', 'integer'].includes(spec.type) ? 'number' : 'text';
|
||||
for (const key of ['min', 'max', 'step', 'minLength', 'maxLength']) if (spec[key] !== undefined) input[key] = spec[key];
|
||||
if (input.type === 'number' && spec.step === undefined) input.step = spec.type === 'integer' ? '1' : 'any';
|
||||
}
|
||||
return {
|
||||
input,
|
||||
read() { return options ? options[Number(input.value)] : spec.type === 'boolean' ? input.checked
|
||||
: ['number', 'integer'].includes(spec.type) ? (input.value === '' ? NaN : Number(input.value)) : input.value; },
|
||||
write(value) {
|
||||
if (options) input.value = String(options.findIndex(v => Object.is(v, value)));
|
||||
else if (spec.type === 'boolean') input.checked = value === true;
|
||||
else input.value = value ?? '';
|
||||
}
|
||||
};
|
||||
}
|
||||
function buildCatalog() {
|
||||
rows.clear(); byId('catalog').replaceChildren();
|
||||
for (const target of host.catalog) {
|
||||
const section = element('article', undefined, byId('catalog')); section.className = 'target';
|
||||
element('h3', target.label ?? target.id, section); element('code', target.id, section);
|
||||
element('p', `${target.kind} · ${target.readable ? 'readable' : 'not readable'} · ${target.writable ? 'writable' : 'not writable'}`, section);
|
||||
const current = element('output', '', section);
|
||||
const detail = element('details', undefined, section); element('summary', 'Target descriptor', detail); element('pre', json(target), detail);
|
||||
const notice = element('p', '', section); notice.className = 'notice';
|
||||
const form = element('form', undefined, section); form.className = 'controls'; form.noValidate = true;
|
||||
let valueEditor, schemaError = '', argumentEditors = [];
|
||||
if (target.kind === 'impulse') {
|
||||
try {
|
||||
for (const arg of argumentSchema(target)) {
|
||||
const group = element('div', undefined, form);
|
||||
let include = null;
|
||||
if (!arg.required) { const l = element('label', `Include ${arg.label ?? arg.name}`, group); include = element('input', undefined, l); include.type = 'checkbox'; }
|
||||
const field = editor(arg, arg.label ?? arg.name, group);
|
||||
if (arg.description) element('p', arg.description, group);
|
||||
argumentEditors.push({ arg, include, field });
|
||||
}
|
||||
} catch (error) { schemaError = error.message; }
|
||||
} else if (target.writable) {
|
||||
if (target.kind === 'selection') valueEditor = editor({ enum: target.options.map(o => o.value), optionLabels: target.options.map(o => o.label) }, 'New value', form);
|
||||
else if (target.kind === 'range' || ['boolean', 'number', 'integer', 'string'].includes(target.valueType)) {
|
||||
valueEditor = editor({ ...target, type: target.kind === 'range' ? 'number' : target.valueType }, 'New value', form);
|
||||
} else schemaError = 'Unsupported state value type.';
|
||||
}
|
||||
let submit = null;
|
||||
let row;
|
||||
if (target.kind === 'impulse' || target.writable) {
|
||||
submit = element('button', target.kind === 'impulse' ? 'Invoke' : 'Set', form);
|
||||
form.addEventListener('submit', async event => {
|
||||
event.preventDefault(); if (row.busy) return; row.busy = true; submit.disabled = true;
|
||||
try {
|
||||
if (target.kind === 'impulse') {
|
||||
const args = Object.create(null);
|
||||
for (const { arg, include, field } of argumentEditors) if (!include || include.checked) args[arg.name] = field.read();
|
||||
await host.invoke(target.id, args);
|
||||
} else await host.set(target.id, valueEditor.read());
|
||||
} catch { /* Host reports every control failure in the visible console. */ }
|
||||
row.busy = false; render();
|
||||
});
|
||||
}
|
||||
row = { target, current, notice, submit, schemaError, valueEditor, initialized: false, busy: false };
|
||||
rows.set(target.id, row);
|
||||
}
|
||||
}
|
||||
function render() {
|
||||
byId('status').textContent = `${host.status} · ${host.sync}`;
|
||||
byId('metadata').textContent = json({ sessionId: host.sessionId, contract: host.contract, exhibit: host.exhibit,
|
||||
registryRevision: host.registryRevision, stateRevision: host.stateRevision, sequence: host.sequence });
|
||||
byId('capabilities').textContent = json(host.capabilities);
|
||||
byId('state').textContent = json(Object.fromEntries(host.values));
|
||||
byId('events').textContent = json(host.eventLog);
|
||||
byId('errors').textContent = json(host.logs);
|
||||
byId('target-count').textContent = `(${host.catalog.length})`;
|
||||
byId('refresh').disabled = host.status !== 'connected';
|
||||
byId('reconnect').disabled = !frame;
|
||||
if (host.catalog !== renderedCatalog) { renderedCatalog = host.catalog; buildCatalog(); }
|
||||
for (const row of rows.values()) {
|
||||
row.current.textContent = host.values.has(row.target.id) ? `Reported value: ${json(host.values.get(row.target.id))}` : 'No persistent value reported.';
|
||||
if (!row.initialized && row.valueEditor && host.values.has(row.target.id)) {
|
||||
row.valueEditor.write(host.values.get(row.target.id)); row.initialized = true;
|
||||
}
|
||||
const unavailable = host.unavailable(row.target);
|
||||
row.notice.textContent = row.schemaError || (unavailable.length ? `Unavailable: ${unavailable.join(', ')}` : '');
|
||||
if (row.submit) row.submit.disabled = row.busy || host.status !== 'connected' || !!row.schemaError || unavailable.length > 0;
|
||||
}
|
||||
}
|
||||
async function attach() {
|
||||
if (!frame) return;
|
||||
try { await host.connect(postMessageTransport(frame)); } catch (error) { if (!error.code) host.report(error); }
|
||||
}
|
||||
byId('connection').addEventListener('submit', event => {
|
||||
event.preventDefault();
|
||||
try {
|
||||
const url = new URL(byId('exhibit-url').value, location.href);
|
||||
if (url.origin !== location.origin || !['http:', 'https:'].includes(url.protocol)) throw new Error('Enter a same-origin HTTP exhibit URL.');
|
||||
host.disconnect(); frame?.remove();
|
||||
frame = document.createElement('iframe'); frame.title = 'Connected exhibit';
|
||||
frame.addEventListener('load', attach); frame.src = url.href;
|
||||
byId('frame-container').append(frame); render();
|
||||
} catch (error) { host.report(error); }
|
||||
});
|
||||
byId('reconnect').addEventListener('click', attach);
|
||||
byId('disconnect').addEventListener('click', () => { host.disconnect(); frame?.remove(); frame = null; render(); });
|
||||
byId('refresh').addEventListener('click', () => host.refresh().catch(error => host.report(error)));
|
||||
host.subscribe(render); render();
|
||||
Reference in New Issue
Block a user