Add component name formatters and grid-link navigation

Templates section:
- Define JS formatter functions per template (e.g. resistor, capacitor)
- First non-null result from any formatter is used as display name
- Live preview in template editor against first component
- Display names applied in component list, detail view, and inventory rows

Grid navigation:
- Grid-type inventory entries in component detail view show a '⊞' button
  to navigate directly to that grid's viewer

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-21 00:07:01 +00:00
parent 27970e74f9
commit 57c697cbfc
7 changed files with 338 additions and 5 deletions

View File

@@ -25,19 +25,21 @@ let grid_source_id = null;
let grid_draft = null; // { id?, name, rows, cols, panel_rows, panel_cols, panels[], edit_grid_id? }
let current_panel_idx = null;
let all_drafts = [];
let all_templates = [];
// ---------------------------------------------------------------------------
// Data loading
// ---------------------------------------------------------------------------
async function load_all() {
const [cf, ci, cmp, gr, dr, sr] = await Promise.all([
const [cf, ci, cmp, gr, dr, sr, ct] = await Promise.all([
api.get_fields(),
api.get_inventory(),
api.get_components(),
api.get_grids(),
api.get_grid_drafts(),
api.get_source_images(),
api.get_component_templates(),
]);
all_fields = cf.fields;
all_inventory = ci.entries;
@@ -45,6 +47,8 @@ async function load_all() {
all_grids = gr.grids;
all_drafts = dr.drafts;
all_sources = sr.sources;
all_templates = ct.templates;
compile_templates();
}
// ---------------------------------------------------------------------------
@@ -66,6 +70,40 @@ function grid_cell_label(entry) {
return `${grid.name} R${(entry.grid_row ?? 0) + 1}C${(entry.grid_col ?? 0) + 1}`;
}
// ---------------------------------------------------------------------------
// Component template engine
// ---------------------------------------------------------------------------
let compiled_formatters = []; // [{ id, name, fn }]
function compile_templates() {
compiled_formatters = [];
for (const tmpl of all_templates) {
if (!tmpl.formatter?.trim()) continue;
try {
// eslint-disable-next-line no-new-func
const fn = new Function('c', `"use strict"; return (${tmpl.formatter})(c);`);
compiled_formatters.push({ id: tmpl.id, name: tmpl.name, fn });
} catch (err) {
console.warn(`Template "${tmpl.name}" failed to compile:`, err);
}
}
}
function component_display_name(comp) {
for (const { fn } of compiled_formatters) {
try {
const result = fn(comp);
if (result != null && result !== '') return String(result);
} catch (_) {
// formatter threw — skip it
}
}
return comp.name;
}
// ---------------------------------------------------------------------------
function inventory_for_component(component_id) {
return all_inventory.filter(e => e.component_id === component_id);
}
@@ -184,7 +222,7 @@ function render_component_list() {
function build_component_row(comp) {
const row = clone('t-component-row');
set_text(row, '.component-name', comp.name);
set_text(row, '.component-name', component_display_name(comp));
const tags_el = qs(row, '.component-tags');
const field_entries = Object.entries(comp.fields ?? {});
@@ -229,11 +267,11 @@ function render_detail_panel() {
const content = clone('t-detail-content');
// Header
set_text(content, '.detail-name', comp.name);
set_text(content, '.detail-name', component_display_name(comp));
set_text(content, '.detail-description', comp.description || '');
qs(content, '.detail-edit-btn').addEventListener('click', () => open_component_dialog(comp));
qs(content, '.detail-delete-btn').addEventListener('click', () => confirm_delete(
`Delete component "${comp.name}"? Inventory entries will become orphaned.`,
`Delete component "${component_display_name(comp)}"? Inventory entries will become orphaned.`,
async () => {
await api.delete_component(comp.id);
all_components = all_components.filter(c => c.id !== comp.id);
@@ -308,6 +346,12 @@ function build_detail_inv_entry(entry) {
set_text(el, '.detail-inv-qty', entry.quantity ? `×${entry.quantity}` : '');
set_text(el, '.detail-inv-notes', entry.notes || '');
if (entry.location_type === 'grid' && entry.grid_id) {
const goto_btn = qs(el, '.detail-inv-goto-grid');
goto_btn.hidden = false;
goto_btn.addEventListener('click', () => navigate(`/grids/viewer/${entry.grid_id}`));
}
// Inventory entry images
build_image_grid(
qs(el, '.inv-image-grid'),
@@ -389,7 +433,7 @@ function render_inventory_list() {
function build_inventory_row(entry) {
const row = clone('t-inventory-row');
const comp = component_by_id(entry.component_id);
set_text(row, '.inv-component-name', comp ? comp.name : '(deleted component)');
set_text(row, '.inv-component-name', comp ? component_display_name(comp) : '(deleted component)');
const pill = document.createElement('span');
pill.className = `type-pill type-${entry.location_type}`;
@@ -462,6 +506,116 @@ function build_field_row(fdef) {
return row;
}
// ---------------------------------------------------------------------------
// Render: Templates section
// ---------------------------------------------------------------------------
let template_dialog = null;
let template_dialog_callback = null;
function render_templates() {
const main = document.getElementById('main');
let section_el = document.getElementById('section-templates');
if (!section_el) {
const frag = document.getElementById('t-section-templates').content.cloneNode(true);
main.replaceChildren(frag);
section_el = document.getElementById('section-templates');
qs(section_el, '#btn-add-template').addEventListener('click', () => open_template_dialog());
}
render_template_list();
}
function render_template_list() {
const list_el = document.getElementById('template-list');
if (!list_el) return;
if (all_templates.length === 0) {
const note = document.createElement('p');
note.className = 'section-empty-note';
note.textContent = 'No templates yet. Add one to automatically format component display names.';
list_el.replaceChildren(note);
return;
}
list_el.replaceChildren(...all_templates.map(tmpl => {
const card = clone('t-template-card');
set_text(card, '.template-card-name', tmpl.name);
qs(card, '.template-card-formatter').textContent = tmpl.formatter || '(empty)';
qs(card, '.btn-edit').addEventListener('click', () => open_template_dialog(tmpl));
qs(card, '.btn-delete').addEventListener('click', () => confirm_delete(
`Delete template "${tmpl.name}"?`,
async () => {
await api.delete_component_template(tmpl.id);
all_templates = all_templates.filter(t => t.id !== tmpl.id);
compile_templates();
render_template_list();
}
));
return card;
}));
}
function open_template_dialog(tmpl = null) {
if (!template_dialog) {
const frag = document.getElementById('t-dialog-template').content.cloneNode(true);
document.body.appendChild(frag);
template_dialog = document.getElementById('dialog-template');
qs(template_dialog, '#tmpl-cancel').addEventListener('click', () => template_dialog.close());
document.getElementById('form-template').addEventListener('submit', async (e) => {
e.preventDefault();
try {
await template_dialog_callback?.();
template_dialog.close();
compile_templates();
render_template_list();
render(); // refresh component names everywhere
} catch (err) { alert(`Error: ${err.message}`); }
});
// Live preview against first component
qs(template_dialog, '#tmpl-formatter').addEventListener('input', update_tmpl_preview);
}
qs(template_dialog, '.dialog-title').textContent = tmpl ? 'Edit template' : 'Add template';
qs(template_dialog, '#tmpl-name').value = tmpl?.name ?? '';
qs(template_dialog, '#tmpl-formatter').value = tmpl?.formatter ?? '';
update_tmpl_preview();
template_dialog_callback = async () => {
const body = {
name: qs(template_dialog, '#tmpl-name').value.trim(),
formatter: qs(template_dialog, '#tmpl-formatter').value.trim(),
};
if (tmpl) {
const result = await api.update_component_template(tmpl.id, body);
const idx = all_templates.findIndex(t => t.id === tmpl.id);
if (idx !== -1) all_templates[idx] = result.template;
} else {
const result = await api.create_component_template(body);
all_templates.push(result.template);
}
};
template_dialog.showModal();
qs(template_dialog, '#tmpl-name').focus();
}
function update_tmpl_preview() {
if (!template_dialog) return;
const preview_el = qs(template_dialog, '#tmpl-preview');
const formatter_str = qs(template_dialog, '#tmpl-formatter').value.trim();
if (!formatter_str) { preview_el.textContent = '—'; return; }
const sample = all_components[0];
if (!sample) { preview_el.textContent = '(no components to preview)'; return; }
try {
// eslint-disable-next-line no-new-func
const fn = new Function('c', `"use strict"; return (${formatter_str})(c);`);
const result = fn(sample);
preview_el.textContent = result != null ? String(result) : `null — falls back to "${sample.name}"`;
} catch (err) {
preview_el.textContent = `Error: ${err.message}`;
}
}
// ---------------------------------------------------------------------------
// Render: Grids section
// ---------------------------------------------------------------------------
@@ -1385,6 +1539,8 @@ function parse_url() {
section = 'inventory';
} else if (p0 === 'fields') {
section = 'fields';
} else if (p0 === 'templates') {
section = 'templates';
} else if (p0 === 'grids') {
section = 'grids';
if (p1 === 'sources') {
@@ -1464,6 +1620,7 @@ function render() {
else if (section === 'inventory') render_inventory();
else if (section === 'fields') render_fields();
else if (section === 'grids') render_grids();
else if (section === 'templates') render_templates();
}
// ---------------------------------------------------------------------------