forked from FrederikBaerentsen/BrickTracker
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
685857031f | ||
|
|
a7fd1fc4dd | ||
|
|
71d753bbb9 | ||
|
|
ef38ce32b8 | ||
|
|
8b0b85f29f |
@@ -9,6 +9,7 @@ from flask import Flask
|
|||||||
# - f: flag name (str, optional=None)
|
# - f: flag name (str, optional=None)
|
||||||
NAVBAR: Final[list[dict[str, Any]]] = [
|
NAVBAR: Final[list[dict[str, Any]]] = [
|
||||||
{'e': 'set.list', 't': 'Sets', 'i': 'grid-line', 'f': 'HIDE_ALL_SETS'}, # noqa: E501
|
{'e': 'set.list', 't': 'Sets', 'i': 'grid-line', 'f': 'HIDE_ALL_SETS'}, # noqa: E501
|
||||||
|
{'e': 'set.table', 't': 'Sets table', 'i': 'table-line', 'f': 'HIDE_ALL_SETS'}, # noqa: E501
|
||||||
{'e': 'add.add', 't': 'Add', 'i': 'add-circle-line', 'f': 'HIDE_ADD_SET'}, # noqa: E501
|
{'e': 'add.add', 't': 'Add', 'i': 'add-circle-line', 'f': 'HIDE_ADD_SET'}, # noqa: E501
|
||||||
{'e': 'part.list', 't': 'Parts', 'i': 'shapes-line', 'f': 'HIDE_ALL_PARTS'}, # noqa: E501
|
{'e': 'part.list', 't': 'Parts', 'i': 'shapes-line', 'f': 'HIDE_ALL_PARTS'}, # noqa: E501
|
||||||
{'e': 'part.problem', 't': 'Problems', 'i': 'error-warning-line', 'f': 'HIDE_ALL_PROBLEMS_PARTS'}, # noqa: E501
|
{'e': 'part.problem', 't': 'Problems', 'i': 'error-warning-line', 'f': 'HIDE_ALL_PROBLEMS_PARTS'}, # noqa: E501
|
||||||
|
|||||||
+22
-5
@@ -288,7 +288,14 @@ class BrickPart(RebrickablePart):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Update a problematic part
|
# Update a problematic part
|
||||||
def update_problem(self, problem: str, json: Any | None, /) -> int:
|
def update_problem(
|
||||||
|
self,
|
||||||
|
problem: str,
|
||||||
|
json: Any | None,
|
||||||
|
/,
|
||||||
|
*,
|
||||||
|
commit: bool = True,
|
||||||
|
) -> int:
|
||||||
amount: str | int = json.get('value', '') # type: ignore
|
amount: str | int = json.get('value', '') # type: ignore
|
||||||
|
|
||||||
# We need a positive integer
|
# We need a positive integer
|
||||||
@@ -310,10 +317,20 @@ class BrickPart(RebrickablePart):
|
|||||||
|
|
||||||
setattr(self.fields, problem, amount)
|
setattr(self.fields, problem, amount)
|
||||||
|
|
||||||
BrickSQL().execute_and_commit(
|
if commit:
|
||||||
'part/update/{problem}'.format(problem=problem),
|
BrickSQL().execute_and_commit(
|
||||||
parameters=self.sql_parameters()
|
'part/update/{problem}'.format(problem=problem),
|
||||||
)
|
parameters=self.sql_parameters()
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Queue the update without committing, so a caller updating many
|
||||||
|
# parts at once (e.g. a bulk operation) can run every update in
|
||||||
|
# a single transaction with one final commit.
|
||||||
|
BrickSQL().execute(
|
||||||
|
'part/update/{problem}'.format(problem=problem),
|
||||||
|
parameters=self.sql_parameters(),
|
||||||
|
defer=True,
|
||||||
|
)
|
||||||
|
|
||||||
return amount
|
return amount
|
||||||
|
|
||||||
|
|||||||
@@ -17,9 +17,9 @@ SELECT
|
|||||||
{% block total_sets %}
|
{% block total_sets %}
|
||||||
NULL AS "total_sets" -- dummy for order: total_sets
|
NULL AS "total_sets" -- dummy for order: total_sets
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
FROM "rebrickable_minifigures"
|
FROM "bricktracker_minifigures"
|
||||||
|
|
||||||
LEFT JOIN "bricktracker_minifigures"
|
INNER JOIN "rebrickable_minifigures"
|
||||||
ON "rebrickable_minifigures"."figure" IS NOT DISTINCT FROM "bricktracker_minifigures"."figure"
|
ON "rebrickable_minifigures"."figure" IS NOT DISTINCT FROM "bricktracker_minifigures"."figure"
|
||||||
|
|
||||||
{% block join %}{% endblock %}
|
{% block join %}{% endblock %}
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{% extends 'set/base/base.sql' %}
|
||||||
|
|
||||||
|
{% block id %}
|
||||||
|
"bricktracker_sets"."id",
|
||||||
|
{% endblock %}
|
||||||
@@ -49,4 +49,6 @@ class BrickSQLMigration(object):
|
|||||||
))
|
))
|
||||||
)
|
)
|
||||||
|
|
||||||
return relative
|
# Jinja2 template names always use forward slashes, regardless
|
||||||
|
# of the OS path separator (os.path.relpath uses '\' on Windows)
|
||||||
|
return relative.replace(os.sep, '/')
|
||||||
|
|||||||
+174
-1
@@ -1,4 +1,7 @@
|
|||||||
|
import builtins
|
||||||
import logging
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from flask import (
|
from flask import (
|
||||||
Blueprint,
|
Blueprint,
|
||||||
@@ -13,7 +16,7 @@ from flask_login import login_required
|
|||||||
from werkzeug.wrappers.response import Response
|
from werkzeug.wrappers.response import Response
|
||||||
|
|
||||||
from .exceptions import exception_handler
|
from .exceptions import exception_handler
|
||||||
from ..exceptions import ErrorException
|
from ..exceptions import ErrorException, NotFoundException
|
||||||
from ..minifigure import BrickMinifigure
|
from ..minifigure import BrickMinifigure
|
||||||
from ..pagination_helper import get_pagination_config, build_pagination_context, get_request_params
|
from ..pagination_helper import get_pagination_config, build_pagination_context, get_request_params
|
||||||
from ..part import BrickPart
|
from ..part import BrickPart
|
||||||
@@ -26,6 +29,8 @@ from ..set_status_list import BrickSetStatusList
|
|||||||
from ..set_storage_list import BrickSetStorageList
|
from ..set_storage_list import BrickSetStorageList
|
||||||
from ..set_tag_list import BrickSetTagList
|
from ..set_tag_list import BrickSetTagList
|
||||||
from ..socket import MESSAGES
|
from ..socket import MESSAGES
|
||||||
|
from ..sql import BrickSQL
|
||||||
|
from ..theme_list import BrickThemeList
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -115,6 +120,98 @@ def list() -> str:
|
|||||||
return render_template('sets.html', **template_context)
|
return render_template('sets.html', **template_context)
|
||||||
|
|
||||||
|
|
||||||
|
# Spreadsheet-style (Tabulator) view of all sets
|
||||||
|
@set_page.route('/table', methods=['GET'])
|
||||||
|
@exception_handler(__file__)
|
||||||
|
def table() -> str:
|
||||||
|
storages = [
|
||||||
|
{'id': item.fields.id, 'name': item.fields.name}
|
||||||
|
for item in BrickSetStorageList.list(as_class=False)
|
||||||
|
]
|
||||||
|
|
||||||
|
purchase_locations = [
|
||||||
|
{'id': item.fields.id, 'name': item.fields.name}
|
||||||
|
for item in BrickSetPurchaseLocationList.list(as_class=False)
|
||||||
|
]
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
'sets_table.html',
|
||||||
|
brickset_storages=storages,
|
||||||
|
brickset_purchase_locations=purchase_locations,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# JSON data backing the Tabulator view. Read-only listing: edits are saved
|
||||||
|
# through the existing per-field endpoints (update_storage,
|
||||||
|
# update_purchase_location, update_purchase_date, update_purchase_price,
|
||||||
|
# update_description), the same ones BrickChanger already uses elsewhere.
|
||||||
|
@set_page.route('/table/data', methods=['GET'])
|
||||||
|
@exception_handler(__file__, json=True)
|
||||||
|
def table_data() -> Response:
|
||||||
|
rows = BrickSQL().fetchall(
|
||||||
|
'set/list/tabulator',
|
||||||
|
order='"rebrickable_sets"."name"',
|
||||||
|
)
|
||||||
|
|
||||||
|
themes = BrickThemeList()
|
||||||
|
|
||||||
|
data: builtins.list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
storage_id = row['storage']
|
||||||
|
purchase_location_id = row['purchase_location']
|
||||||
|
theme_id = row['theme_id']
|
||||||
|
|
||||||
|
theme_name = ''
|
||||||
|
if theme_id is not None:
|
||||||
|
try:
|
||||||
|
theme_name = themes.get(int(theme_id)).name
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
theme_name = ''
|
||||||
|
|
||||||
|
storage_name = ''
|
||||||
|
if storage_id:
|
||||||
|
try:
|
||||||
|
storage_name = BrickSetStorageList.get(storage_id).fields.name # noqa: E501
|
||||||
|
except NotFoundException:
|
||||||
|
storage_name = ''
|
||||||
|
|
||||||
|
purchase_location_name = ''
|
||||||
|
if purchase_location_id:
|
||||||
|
try:
|
||||||
|
purchase_location_name = BrickSetPurchaseLocationList.get(
|
||||||
|
purchase_location_id
|
||||||
|
).fields.name
|
||||||
|
except NotFoundException:
|
||||||
|
purchase_location_name = ''
|
||||||
|
|
||||||
|
purchase_date = ''
|
||||||
|
if row['purchase_date']:
|
||||||
|
purchase_date = datetime.fromtimestamp(
|
||||||
|
row['purchase_date']
|
||||||
|
).strftime('%Y/%m/%d')
|
||||||
|
|
||||||
|
data.append({
|
||||||
|
'id': row['id'],
|
||||||
|
'set': row['set'],
|
||||||
|
'name': row['name'],
|
||||||
|
'year': row['year'],
|
||||||
|
'theme_id': theme_id,
|
||||||
|
'theme': theme_name,
|
||||||
|
'number_of_parts': row['number_of_parts'],
|
||||||
|
'image': row['image'],
|
||||||
|
'description': row['description'] or '',
|
||||||
|
'storage_id': storage_id or '',
|
||||||
|
'storage_name': storage_name,
|
||||||
|
'purchase_location_id': purchase_location_id or '',
|
||||||
|
'purchase_location_name': purchase_location_name,
|
||||||
|
'purchase_date': purchase_date,
|
||||||
|
'purchase_price': row['purchase_price'],
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify(data)
|
||||||
|
|
||||||
|
|
||||||
# Change the value of purchase date
|
# Change the value of purchase date
|
||||||
@set_page.route('/<id>/purchase_date', methods=['POST'])
|
@set_page.route('/<id>/purchase_date', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
@@ -357,6 +454,82 @@ def problem_part(
|
|||||||
return jsonify({problem: amount})
|
return jsonify({problem: amount})
|
||||||
|
|
||||||
|
|
||||||
|
# Bulk update problematic pieces of a set (e.g. "mark all missing")
|
||||||
|
# Body: {"items": [{"figure": str|None, "part": str, "color": int,
|
||||||
|
# "spare": int, "value": int}, ...]}
|
||||||
|
# All updates run in a single database transaction (one commit for the
|
||||||
|
# whole batch), instead of a fetch per part.
|
||||||
|
@set_page.route('/<id>/parts/bulk/<problem>', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
@exception_handler(__file__, json=True)
|
||||||
|
def bulk_problem_parts(*, id: str, problem: str) -> Response:
|
||||||
|
brickset = BrickSet().select_specific(id)
|
||||||
|
|
||||||
|
items = (request.json or {}).get('items', []) # type: ignore
|
||||||
|
|
||||||
|
if not isinstance(items, builtins.list):
|
||||||
|
raise ErrorException('"items" must be a list')
|
||||||
|
|
||||||
|
# Cache minifigure lookups so we don't re-select the same one per part
|
||||||
|
minifigure_cache: dict[str, BrickMinifigure] = {}
|
||||||
|
updated: builtins.list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
part = item.get('part')
|
||||||
|
color = item.get('color')
|
||||||
|
spare = item.get('spare')
|
||||||
|
figure = item.get('figure')
|
||||||
|
|
||||||
|
if part is None or color is None or spare is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if figure is not None:
|
||||||
|
if figure not in minifigure_cache:
|
||||||
|
minifigure_cache[figure] = BrickMinifigure().select_specific(
|
||||||
|
brickset, figure
|
||||||
|
)
|
||||||
|
|
||||||
|
brickminifigure = minifigure_cache[figure]
|
||||||
|
else:
|
||||||
|
brickminifigure = None
|
||||||
|
|
||||||
|
brickpart = BrickPart().select_specific(
|
||||||
|
brickset,
|
||||||
|
part,
|
||||||
|
int(color),
|
||||||
|
int(spare),
|
||||||
|
minifigure=brickminifigure,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Queue the update (commit=False) rather than committing per part
|
||||||
|
amount = brickpart.update_problem(
|
||||||
|
problem,
|
||||||
|
{'value': item.get('value')},
|
||||||
|
commit=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
updated.append({
|
||||||
|
'figure': figure,
|
||||||
|
'part': part,
|
||||||
|
'color': color,
|
||||||
|
'spare': spare,
|
||||||
|
'value': amount,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Commit every queued update in a single transaction
|
||||||
|
BrickSQL().commit()
|
||||||
|
|
||||||
|
# Info
|
||||||
|
logger.info('Set {set} ({id}): bulk updated {count} part(s) {problem}'.format( # noqa: E501
|
||||||
|
set=brickset.fields.set,
|
||||||
|
id=brickset.fields.id,
|
||||||
|
count=len(updated),
|
||||||
|
problem=problem,
|
||||||
|
))
|
||||||
|
|
||||||
|
return jsonify({'updated': updated})
|
||||||
|
|
||||||
|
|
||||||
# Update checked state of parts during walkthrough
|
# Update checked state of parts during walkthrough
|
||||||
@set_page.route('/<id>/parts/<part>/<int:color>/<int:spare>/checked', defaults={'figure': None}, methods=['POST']) # noqa: E501
|
@set_page.route('/<id>/parts/<part>/<int:color>/<int:spare>/checked', defaults={'figure': None}, methods=['POST']) # noqa: E501
|
||||||
@set_page.route('/<id>/minifigures/<figure>/parts/<part>/<int:color>/<int:spare>/checked', methods=['POST']) # noqa: E501
|
@set_page.route('/<id>/minifigures/<figure>/parts/<part>/<int:color>/<int:spare>/checked', methods=['POST']) # noqa: E501
|
||||||
|
|||||||
@@ -103,12 +103,66 @@ class PartsBulkOperations {
|
|||||||
modalInstance.show();
|
modalInstance.show();
|
||||||
}
|
}
|
||||||
|
|
||||||
markAllMissing() {
|
// Parses BrickChanger's per-row "missing" URL to recover the pieces
|
||||||
|
// needed to build a bulk request: the leading path prefix (for
|
||||||
|
// subpath deployments), the set id, and the part's identity
|
||||||
|
// (figure/part/color/spare). Returns null if the URL doesn't match.
|
||||||
|
//
|
||||||
|
// Matches URLs generated by url_for('set.problem_part', ...), e.g.
|
||||||
|
// /sets/<id>/parts/<part>/<color>/<spare>/missing
|
||||||
|
// /sets/<id>/minifigures/<figure>/parts/<part>/<color>/<spare>/missing
|
||||||
|
static MISSING_URL_PATTERN = /^(.*?)\/sets\/([^/]+)\/(?:minifigures\/([^/]+)\/)?parts\/([^/]+)\/([^/]+)\/([^/]+)\/missing$/;
|
||||||
|
|
||||||
|
// Send one bulk request updating every part's missing count in a
|
||||||
|
// single call, instead of one fetch per row. Reloads the page once
|
||||||
|
// the save is confirmed; shows an alert and does NOT reload on
|
||||||
|
// failure, so a failed save is never silently discarded.
|
||||||
|
_bulkUpdateMissing(items, urlPrefix, setId, actionLabel) {
|
||||||
|
if (!items.length || !setId) return;
|
||||||
|
|
||||||
|
fetch(`${urlPrefix}/sets/${setId}/parts/bulk/missing`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ items }),
|
||||||
|
})
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only refresh once the save is confirmed, so all derived
|
||||||
|
// totals (missing counts, etc.) reflect the new state.
|
||||||
|
window.location.reload();
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error(`Bulk ${actionLabel} failed:`, error);
|
||||||
|
alert(`Failed to save "${actionLabel}": ${error.message}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walks the rows of this accordion, calling collect(missingInput,
|
||||||
|
// quantityCell) for each visible row that has both a quantity cell
|
||||||
|
// and a "missing" input. collect should return the desired new value
|
||||||
|
// (a number) to set, or null/undefined to skip that row.
|
||||||
|
_collectMissingItems(collect) {
|
||||||
const accordionElement = document.getElementById(this.accordionId);
|
const accordionElement = document.getElementById(this.accordionId);
|
||||||
if (!accordionElement) return;
|
if (!accordionElement) return { items: [], urlPrefix: '', setId: null };
|
||||||
|
|
||||||
// Find all rows in this accordion
|
|
||||||
const rows = accordionElement.querySelectorAll('tbody tr');
|
const rows = accordionElement.querySelectorAll('tbody tr');
|
||||||
|
const items = [];
|
||||||
|
let setId = null;
|
||||||
|
let urlPrefix = '';
|
||||||
|
|
||||||
rows.forEach(row => {
|
rows.forEach(row => {
|
||||||
// Skip rows hidden by an active header filter
|
// Skip rows hidden by an active header filter
|
||||||
if (row.classList.contains('parts-filtered-out')) return;
|
if (row.classList.contains('parts-filtered-out')) return;
|
||||||
@@ -117,33 +171,64 @@ class PartsBulkOperations {
|
|||||||
const quantityCell = row.cells[3]; // Index 3 for quantity column
|
const quantityCell = row.cells[3]; // Index 3 for quantity column
|
||||||
const missingInput = row.querySelector('input[id*="-missing-"]');
|
const missingInput = row.querySelector('input[id*="-missing-"]');
|
||||||
|
|
||||||
if (quantityCell && missingInput) {
|
if (!quantityCell || !missingInput) return;
|
||||||
// Extract quantity from cell text content
|
|
||||||
const quantityText = quantityCell.textContent.trim();
|
|
||||||
const quantity = parseInt(quantityText) || 1; // Default to 1 if can't parse
|
|
||||||
|
|
||||||
if (missingInput.value !== quantity.toString()) {
|
const newValue = collect(missingInput, quantityCell);
|
||||||
missingInput.value = quantity.toString();
|
|
||||||
// Trigger change event to activate BrickChanger
|
if (newValue === null || newValue === undefined) return;
|
||||||
missingInput.dispatchEvent(new Event('change', { bubbles: true }));
|
|
||||||
}
|
// Pull (id, figure, part, color, spare) straight out of the
|
||||||
|
// per-row URL that BrickChanger already uses, so we don't need
|
||||||
|
// any new data attributes in the template.
|
||||||
|
const match = (missingInput.dataset.changerUrl || '').match(
|
||||||
|
PartsBulkOperations.MISSING_URL_PATTERN
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
console.warn('Could not parse changer URL for a row, skipping it', missingInput);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const [, prefix, id, figure, part, color, spare] = match;
|
||||||
|
setId = id;
|
||||||
|
urlPrefix = prefix;
|
||||||
|
|
||||||
|
// Reflect the new value in the UI immediately
|
||||||
|
missingInput.value = newValue.toString();
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
figure: figure || null,
|
||||||
|
part: part,
|
||||||
|
color: parseInt(color, 10),
|
||||||
|
spare: parseInt(spare, 10),
|
||||||
|
value: newValue,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return { items, urlPrefix, setId };
|
||||||
|
}
|
||||||
|
|
||||||
|
markAllMissing() {
|
||||||
|
const { items, urlPrefix, setId } = this._collectMissingItems((missingInput, quantityCell) => {
|
||||||
|
const quantityText = quantityCell.textContent.trim();
|
||||||
|
const quantity = parseInt(quantityText) || 1; // Default to 1 if can't parse
|
||||||
|
|
||||||
|
if (missingInput.value === quantity.toString()) return null;
|
||||||
|
|
||||||
|
return quantity;
|
||||||
|
});
|
||||||
|
|
||||||
|
this._bulkUpdateMissing(items, urlPrefix, setId, 'mark all missing');
|
||||||
}
|
}
|
||||||
|
|
||||||
clearAllMissing() {
|
clearAllMissing() {
|
||||||
const accordionElement = document.getElementById(this.accordionId);
|
const { items, urlPrefix, setId } = this._collectMissingItems((missingInput) => {
|
||||||
if (!accordionElement) return;
|
if (missingInput.value === '' || missingInput.value === '0') return null;
|
||||||
|
|
||||||
const missingInputs = accordionElement.querySelectorAll('input[id*="-missing-"]');
|
return 0;
|
||||||
missingInputs.forEach(input => {
|
|
||||||
if (input.closest('tr')?.classList.contains('parts-filtered-out')) return;
|
|
||||||
if (input.value !== '') {
|
|
||||||
input.value = '';
|
|
||||||
// Trigger change event to activate BrickChanger
|
|
||||||
input.dispatchEvent(new Event('change', { bubbles: true }));
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this._bulkUpdateMissing(items, urlPrefix, setId, 'clear all missing');
|
||||||
}
|
}
|
||||||
|
|
||||||
checkAll() {
|
checkAll() {
|
||||||
|
|||||||
@@ -0,0 +1,254 @@
|
|||||||
|
// 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());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
@@ -10,9 +10,11 @@
|
|||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/simple-datatables@9.2.1/dist/style.min.css">
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/simple-datatables@9.2.1/dist/style.min.css">
|
||||||
<link href="https://cdn.jsdelivr.net/npm/remixicon@4.6.0/fonts/remixicon.css" rel="stylesheet">
|
<link href="https://cdn.jsdelivr.net/npm/remixicon@4.6.0/fonts/remixicon.css" rel="stylesheet">
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/vanillajs-datepicker@1.3.4/dist/css/datepicker-bs5.min.css">
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/vanillajs-datepicker@1.3.4/dist/css/datepicker-bs5.min.css">
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/tabulator-tables@6.4.0/dist/css/tabulator.min.css" rel="stylesheet">
|
||||||
<link href="{{ url_for('static', filename='styles.css') }}" rel="stylesheet">
|
<link href="{{ url_for('static', filename='styles.css') }}" rel="stylesheet">
|
||||||
<link rel="icon" type="image/png" sizes="48x48" href="{{ url_for('static', filename='brick.png') }}">
|
<link rel="icon" type="image/png" sizes="48x48" href="{{ url_for('static', filename='brick.png') }}">
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.5.0/dist/chart.umd.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.5.0/dist/chart.umd.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/tabulator-tables@6.4.0/dist/js/tabulator.min.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark sticky-top">
|
<nav class="navbar navbar-expand-lg navbar-dark bg-dark sticky-top">
|
||||||
@@ -190,6 +192,9 @@
|
|||||||
{% if request.endpoint == 'set.list' %}
|
{% if request.endpoint == 'set.list' %}
|
||||||
<script src="{{ url_for('static', filename='scripts/sets.js') }}"></script>
|
<script src="{{ url_for('static', filename='scripts/sets.js') }}"></script>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if request.endpoint == 'set.table' %}
|
||||||
|
<script src="{{ url_for('static', filename='scripts/sets-table.js') }}"></script>
|
||||||
|
{% endif %}
|
||||||
{% if request.endpoint == 'set.details' %}
|
{% if request.endpoint == 'set.details' %}
|
||||||
<script src="{{ url_for('static', filename='scripts/parts-table-filter.js') }}"></script>
|
<script src="{{ url_for('static', filename='scripts/parts-table-filter.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='scripts/parts-bulk-operations.js') }}"></script>
|
<script src="{{ url_for('static', filename='scripts/parts-bulk-operations.js') }}"></script>
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block title %} - Sets table{% endblock %}
|
||||||
|
|
||||||
|
{% block main %}
|
||||||
|
<div class="container-fluid">
|
||||||
|
<h2 class="border-bottom lh-base pb-1">
|
||||||
|
<i class="ri-table-line"></i> Sets table
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary ms-1" id="sets-table-clear-filters">
|
||||||
|
<i class="ri-filter-off-line"></i> Clear filters
|
||||||
|
</button>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div id="sets-table"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Data for building the storage/purchase location select editors -->
|
||||||
|
<script type="application/json" id="sets-table-storages">
|
||||||
|
{{ brickset_storages | tojson }}
|
||||||
|
</script>
|
||||||
|
<script type="application/json" id="sets-table-purchase-locations">
|
||||||
|
{{ brickset_purchase_locations | tojson }}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- URL templates: the literal string ID_PLACEHOLDER is swapped for the -->
|
||||||
|
<!-- real set id client-side, so the JS never has to hardcode paths -->
|
||||||
|
<script type="application/json" id="sets-table-urls">
|
||||||
|
{{
|
||||||
|
{
|
||||||
|
'data': url_for('set.table_data'),
|
||||||
|
'description': url_for('set.update_description', id='ID_PLACEHOLDER'),
|
||||||
|
'storage': url_for('set.update_storage', id='ID_PLACEHOLDER'),
|
||||||
|
'purchase_location': url_for('set.update_purchase_location', id='ID_PLACEHOLDER'),
|
||||||
|
'purchase_date': url_for('set.update_purchase_date', id='ID_PLACEHOLDER'),
|
||||||
|
'purchase_price': url_for('set.update_purchase_price', id='ID_PLACEHOLDER'),
|
||||||
|
'details': url_for('set.details', id='ID_PLACEHOLDER'),
|
||||||
|
} | tojson
|
||||||
|
}}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user