forked from FrederikBaerentsen/BrickTracker
255 lines
9.3 KiB
JavaScript
255 lines
9.3 KiB
JavaScript
// Tabulator-based spreadsheet view for sets
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
const tableElement = document.getElementById('sets-table');
|
|
if (!tableElement) return;
|
|
|
|
const urls = JSON.parse(document.getElementById('sets-table-urls').textContent);
|
|
const storages = JSON.parse(document.getElementById('sets-table-storages').textContent);
|
|
const purchaseLocations = JSON.parse(document.getElementById('sets-table-purchase-locations').textContent);
|
|
|
|
// id -> name lookups, used by both the cell formatter and the select editor
|
|
const storageNames = Object.fromEntries(storages.map(s => [s.id, s.name]));
|
|
const purchaseLocationNames = Object.fromEntries(purchaseLocations.map(p => [p.id, p.name]));
|
|
|
|
// Tabulator's "list" editor wants {value, label} pairs. The actual
|
|
// "no storage"/"no purchase location" data value is '', but Tabulator's
|
|
// list HEADER FILTER treats an empty-string filter value as "cleared"
|
|
// (i.e. no filter applied), so that option would be indistinguishable
|
|
// from clearing the filter. Use a non-empty sentinel for filtering
|
|
// instead, paired with a custom headerFilterFunc below.
|
|
const NONE_SENTINEL = '__none__';
|
|
|
|
const storageOptions = [
|
|
{ value: '', label: 'No storage' },
|
|
...storages.map(s => ({ value: s.id, label: s.name })),
|
|
];
|
|
const purchaseLocationOptions = [
|
|
{ value: '', label: 'No purchase location' },
|
|
...purchaseLocations.map(p => ({ value: p.id, label: p.name })),
|
|
];
|
|
const storageFilterOptions = [
|
|
{ value: NONE_SENTINEL, label: 'No storage' },
|
|
...storages.map(s => ({ value: s.id, label: s.name })),
|
|
];
|
|
const purchaseLocationFilterOptions = [
|
|
{ value: NONE_SENTINEL, label: 'No purchase location' },
|
|
...purchaseLocations.map(p => ({ value: p.id, label: p.name })),
|
|
];
|
|
|
|
// Matches the sentinel against an empty row value, otherwise exact match
|
|
function noneAwareFilter(headerValue, rowValue) {
|
|
if (headerValue === NONE_SENTINEL) return !rowValue;
|
|
return rowValue === headerValue;
|
|
}
|
|
|
|
// Column field names (storage_id, purchase_location_id, ...) don't
|
|
// always match the URL keys (storage, purchase_location, ...), so map
|
|
// between them explicitly instead of assuming they're identical.
|
|
const fieldToUrlKey = {
|
|
description: 'description',
|
|
storage_id: 'storage',
|
|
purchase_location_id: 'purchase_location',
|
|
purchase_date: 'purchase_date',
|
|
purchase_price: 'purchase_price',
|
|
};
|
|
|
|
// Build the save URL for a field on a given row (swap in the real id)
|
|
function urlFor(urlKey, id) {
|
|
return urls[urlKey].replace('ID_PLACEHOLDER', id);
|
|
}
|
|
|
|
// Save one field for one row. On failure, revert the cell and alert -
|
|
// never silently keep an edit that didn't actually persist.
|
|
function saveField(cell) {
|
|
const field = cell.getColumn().getField();
|
|
const urlKey = fieldToUrlKey[field];
|
|
const row = cell.getRow().getData();
|
|
const id = row.id;
|
|
const value = cell.getValue();
|
|
|
|
if (!urlKey || !(urlKey in urls)) {
|
|
console.warn(`No save URL configured for field "${field}", skipping save`);
|
|
return;
|
|
}
|
|
|
|
fetch(urlFor(urlKey, id), {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({ value: value }),
|
|
})
|
|
.then(response => {
|
|
if (!response.ok) {
|
|
throw new Error(`Response status: ${response.status} (${response.statusText})`);
|
|
}
|
|
return response.json();
|
|
})
|
|
.then(json => {
|
|
// The app's error handler returns HTTP 200 with an "error"
|
|
// key for validation/application errors, not a 4xx status.
|
|
if (json && 'error' in json) {
|
|
throw new Error(json.error);
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error(`Failed to save "${field}":`, error);
|
|
alert(`Failed to save "${field}": ${error.message}`);
|
|
cell.restoreOldValue();
|
|
});
|
|
}
|
|
|
|
const table = new Tabulator('#sets-table', {
|
|
ajaxURL: urls.data,
|
|
layout: 'fitDataFill',
|
|
height: '75vh',
|
|
variableHeight: true,
|
|
pagination: true,
|
|
paginationSize: 50,
|
|
paginationSizeSelector: [25, 50, 100, 250, true],
|
|
movableColumns: true,
|
|
columns: [
|
|
{
|
|
title: '',
|
|
field: 'image',
|
|
width: 80,
|
|
minWidth: 40,
|
|
resizable: true,
|
|
hozAlign: 'center',
|
|
headerSort: false,
|
|
frozen: true,
|
|
formatter: (cell) => {
|
|
const url = cell.getValue();
|
|
if (!url) return '';
|
|
return `<img src="${url}" alt="" style="width: 100%; height: auto; display: block;">`;
|
|
},
|
|
},
|
|
{
|
|
title: 'Set',
|
|
field: 'set',
|
|
width: 100,
|
|
headerFilter: 'input',
|
|
formatter: (cell) => {
|
|
const row = cell.getRow().getData();
|
|
const url = urlFor('details', row.id);
|
|
const value = cell.getValue() ?? '';
|
|
return `<a href="${url}">${value}</a>`;
|
|
},
|
|
},
|
|
{
|
|
title: 'Name',
|
|
field: 'name',
|
|
width: 250,
|
|
headerFilter: 'input',
|
|
},
|
|
{
|
|
title: 'Year',
|
|
field: 'year',
|
|
width: 90,
|
|
hozAlign: 'right',
|
|
headerFilter: 'list',
|
|
headerFilterParams: { valuesLookup: true, clearable: true },
|
|
headerFilterFunc: '=',
|
|
},
|
|
{
|
|
title: 'Theme',
|
|
field: 'theme',
|
|
width: 150,
|
|
headerFilter: 'list',
|
|
headerFilterParams: { valuesLookup: true, clearable: true },
|
|
headerFilterFunc: '=',
|
|
},
|
|
{
|
|
title: 'Parts',
|
|
field: 'number_of_parts',
|
|
width: 90,
|
|
hozAlign: 'right',
|
|
headerFilter: 'input',
|
|
},
|
|
{
|
|
title: 'Description',
|
|
field: 'description',
|
|
width: 220,
|
|
editor: 'input',
|
|
headerFilter: 'input',
|
|
cellEdited: saveField,
|
|
},
|
|
{
|
|
title: 'Storage',
|
|
field: 'storage_id',
|
|
width: 160,
|
|
editor: 'list',
|
|
editorParams: { values: storageOptions },
|
|
headerFilter: 'list',
|
|
headerFilterParams: { values: storageFilterOptions, clearable: true },
|
|
headerFilterFunc: noneAwareFilter,
|
|
formatter: (cell) => storageNames[cell.getValue()] || '',
|
|
cellEdited: saveField,
|
|
},
|
|
{
|
|
title: 'Purchase location',
|
|
field: 'purchase_location_id',
|
|
width: 160,
|
|
editor: 'list',
|
|
editorParams: { values: purchaseLocationOptions },
|
|
headerFilter: 'list',
|
|
headerFilterParams: { values: purchaseLocationFilterOptions, clearable: true },
|
|
headerFilterFunc: noneAwareFilter,
|
|
formatter: (cell) => purchaseLocationNames[cell.getValue()] || '',
|
|
cellEdited: saveField,
|
|
},
|
|
{
|
|
title: 'Purchase date',
|
|
field: 'purchase_date',
|
|
width: 130,
|
|
editor: 'input',
|
|
editorParams: { elementAttributes: { placeholder: 'YYYY/MM/DD' } },
|
|
cellEdited: saveField,
|
|
},
|
|
{
|
|
title: 'Purchase price',
|
|
field: 'purchase_price',
|
|
width: 130,
|
|
hozAlign: 'right',
|
|
editor: 'number',
|
|
editorParams: { step: 0.01 },
|
|
cellEdited: saveField,
|
|
},
|
|
],
|
|
});
|
|
|
|
const clearFiltersButton = document.getElementById('sets-table-clear-filters');
|
|
if (clearFiltersButton) {
|
|
clearFiltersButton.addEventListener('click', () => {
|
|
table.clearHeaderFilter();
|
|
});
|
|
}
|
|
|
|
table.on('columnResized', (column) => {
|
|
if (column.getField() === 'image') {
|
|
table.getRows().forEach((row) => row.normalizeHeight());
|
|
}
|
|
});
|
|
|
|
table.on('renderComplete', () => {
|
|
const images = tableElement.querySelectorAll('.tabulator-cell img');
|
|
const pending = [];
|
|
|
|
images.forEach((img) => {
|
|
if (!img.complete) {
|
|
pending.push(new Promise((resolve) => {
|
|
img.addEventListener('load', resolve, { once: true });
|
|
img.addEventListener('error', resolve, { once: true }); // don't hang forever on a broken thumbnail
|
|
}));
|
|
}
|
|
});
|
|
|
|
if (pending.length) {
|
|
Promise.all(pending).then(() => {
|
|
table.getRows().forEach((row) => row.normalizeHeight());
|
|
});
|
|
}
|
|
});
|
|
|
|
});
|