Compare commits

...

8 Commits

12 changed files with 176 additions and 87 deletions
+19 -6
View File
@@ -179,6 +179,19 @@
# Default: false
# BK_HIDE_WISHES=true
# Optional: Hide the 'Individual Minifigures' entry from the menu. Does not disable the route.
# Default: false
# BK_HIDE_INDIVIDUAL_MINIFIGURES=true
# Optional: Hide the 'Individual Parts' entry from the menu. Does not disable the route.
# Default: false
# BK_HIDE_INDIVIDUAL_PARTS=true
# Optional: Hide the 'Add to individual parts' quick-add buttons in parts tables.
# The column header with menu options (mark all missing, check all, etc.) remains visible.
# Default: false
# BK_HIDE_QUICK_ADD_INDIVIDUAL_PARTS=true
# Optional: Change the default order of minifigures. By default ordered by insertion order.
# Useful column names for this option are:
# - "rebrickable_minifigures"."figure": minifigure ID (e.g., "fig-001234")
@@ -207,11 +220,11 @@
# Optional: Change the default order of parts. By default ordered by insertion order.
# Useful column names for this option are:
# - "bricktracker_parts"."part": part number (e.g., "3001")
# - "bricktracker_parts"."spare": part is a spare part (0 or 1)
# - "bricktracker_parts"."quantity": quantity of this part
# - "bricktracker_parts"."missing": number of missing parts
# - "bricktracker_parts"."damaged": number of damaged parts
# - "combined"."part": part number (e.g., "3001")
# - "combined"."spare": part is a spare part (0 or 1)
# - "combined"."quantity": quantity of this part
# - "combined"."missing": number of missing parts
# - "combined"."damaged": number of damaged parts
# - "rebrickable_parts"."name": part name
# - "rebrickable_parts"."color_name": part color name
# - "total_missing": total missing across all sets (composite field)
@@ -219,7 +232,7 @@
# - "total_quantity": total quantity across all sets (composite field)
# - "total_sets": number of sets containing this part (composite field)
# - "total_minifigures": number of minifigures with this part (composite field)
# Default: "rebrickable_parts"."name" ASC, "rebrickable_parts"."color_name" ASC, "bricktracker_parts"."spare" ASC
# Default: "rebrickable_parts"."name" ASC, "rebrickable_parts"."color_name" ASC, "combined"."spare" ASC
# Examples:
# BK_PARTS_DEFAULT_ORDER="total_missing" DESC, "rebrickable_parts"."name" ASC
# BK_PARTS_DEFAULT_ORDER="rebrickable_parts"."color_name" ASC, "rebrickable_parts"."name" ASC
+12
View File
@@ -4,6 +4,10 @@
### Bug Fixes
- **Fixed client-side table sorting corruption** (Issue #136): Resolved data corruption when using sort buttons with DataTables header sorting in client-side pagination mode
- Sort buttons now trigger actual table header clicks instead of using separate `columns.sort()`
- Header clicks sync button states to match current sort
- Prevents misaligned images, colors, and links when mixing sorting methods
- **Fixed storage deletion error handling**: Added proper validation and user-friendly error messages when attempting to delete storage locations that are still in use
- Shows detailed count of items using the storage (sets, individual minifigures, individual parts, part lots)
- Provides clickable link to storage details page for easy navigation
@@ -35,6 +39,14 @@
### New Features
- **Sortable Checked column** (Issue #137): The "Checked" column in set inventory tables can now be sorted
- Click the "Checked" header to sort by checked/unchecked status
- Works in both parts table and part lots table
- **Quick-add individual parts toggle**: New `BK_HIDE_QUICK_ADD_INDIVIDUAL_PARTS` setting to hide the quick-add menu in set parts tables
- Hides the "Add to individual parts" option in the row menu dropdown
- Useful when you want individual parts tracking enabled but don't need quick-add from set inventory
- **Individual Minifigures Tracking**
- Track loose/individual minifigures outside of sets
- Part-level tracking for individual minifigures with problem states (missing/damaged/checked)
+1
View File
@@ -22,6 +22,7 @@ CONFIG: Final[list[dict[str, Any]]] = [
{'n': 'DISABLE_INDIVIDUAL_MINIFIGURES', 'c': bool},
{'n': 'DISABLE_INDIVIDUAL_PARTS', 'c': bool},
{'n': 'DISABLE_QUICK_ADD_INDIVIDUAL_PARTS', 'c': bool},
{'n': 'HIDE_QUICK_ADD_INDIVIDUAL_PARTS', 'c': bool},
{'n': 'DOMAIN_NAME', 'e': 'DOMAIN_NAME', 'd': ''},
{'n': 'FILE_DATETIME_FORMAT', 'd': '%d/%m/%Y, %H:%M:%S'},
{'n': 'HOST', 'd': '0.0.0.0'},
+31 -44
View File
@@ -6,7 +6,10 @@ from typing import Any, Self, TYPE_CHECKING
from urllib.parse import urlparse
from uuid import uuid4
from flask import url_for
from flask import (
current_app,
url_for,
)
from .exceptions import NotFoundException, DatabaseException, ErrorException
from .individual_part import IndividualPart
@@ -222,54 +225,38 @@ class IndividualPartLot(BrickRecord):
# Create individual part with lot_id
part_uuid = str(uuid4())
# Use the add method but with lot_id
# We need to insert the part with the lot_id
sql = BrickSQL()
# First ensure the part exists in rebrickable_parts (BEFORE inserting individual part)
# Ensure color and part/color combination exist in rebrickable tables
IndividualPart.get_or_fetch_color(color_id)
part_name = cart_item.get('part_name', '')
color_name = cart_item.get('color_name', '')
image_url = color_info.get('part_img_url', '')
# Ensure part/color combination exists in rebrickable_parts
try:
# Check if part exists
result = sql.fetchone('rebrickable_parts/check_exists', parameters={'part': part_num, 'color_id': color_id})
exists = result[0] > 0
# Extract image_id from element_ids or URL
element_ids = color_info.get('elements', [])
if element_ids and len(element_ids) > 0:
image_id = str(element_ids[0])
elif image_url:
image_id, _ = os.path.splitext(os.path.basename(urlparse(image_url).path))
else:
image_id = None
if not exists:
# Insert part data
part_name = cart_item.get('part_name', '')
color_name = cart_item.get('color_name', '')
image_url = color_info.get('part_img_url', '')
# Extract image_id from element_ids or URL
element_ids = color_info.get('elements', [])
if element_ids and len(element_ids) > 0:
image_id = str(element_ids[0])
elif image_url:
image_id, _ = os.path.splitext(os.path.basename(urlparse(image_url).path))
else:
image_id = None
sql.execute('rebrickable_parts/insert_part_color', parameters={
'part': part_num,
'name': part_name,
'color_id': color_id,
'color_name': color_name,
'color_rgb': color_info.get('rgb', ''),
'color_transparent': color_info.get('is_trans', False),
'image': image_url,
'image_id': image_id,
'url': current_app.config['REBRICKABLE_LINK_PART_PATTERN'].format(part=part_num, color=color_id),
'bricklink_color_id': color_info.get('bricklink_color_id', None),
'bricklink_color_name': color_info.get('bricklink_color_name', None)
})
except Exception as e:
logger.warning('Could not ensure part data for {part_num}/{color_id}: {error}'.format(
part_num=part_num,
color_id=color_id,
error=e
))
sql.execute('rebrickable_parts/insert_part_color', parameters={
'part': part_num,
'name': part_name,
'color_id': color_id,
'color_name': color_name,
'color_rgb': color_info.get('rgb', ''),
'color_transparent': color_info.get('is_trans', False),
'image': image_url,
'image_id': image_id,
'url': current_app.config['REBRICKABLE_LINK_PART_PATTERN'].format(part=part_num, color=color_id),
'bricklink_color_id': color_info.get('bricklink_color_id', None),
'bricklink_color_name': color_info.get('bricklink_color_name', None)
})
# Commit so the foreign key constraint can be satisfied
sql.commit()
# Now insert the part with lot_id (NO individual metadata - inherited from lot)
sql.execute('individual_part/insert_with_lot', parameters={
+6 -5
View File
@@ -39,13 +39,14 @@
| `BK_INDEPENDENT_ACCORDIONS` | Make accordions independent | `false` | No |
| `BK_DISABLE_INDIVIDUAL_MINIFIGURES` | Block write operations for individual minifigures | `false` | No |
| `BK_DISABLE_INDIVIDUAL_PARTS` | Block write operations for individual parts | `false` | No |
| `BK_HIDE_QUICK_ADD_INDIVIDUAL_PARTS` | Hide quick-add buttons in parts tables | `false` | No |
## Sort Order Configuration
| Variable | Purpose | Default | Required |
|----------|---------|----------|-----------|
| `BK_SETS_DEFAULT_ORDER` | Default set sorting | `"rebrickable_sets"."number" DESC` | No |
| `BK_PARTS_DEFAULT_ORDER` | Default part sorting | `"inventory"."name" ASC` | No |
| `BK_MINIFIGURES_DEFAULT_ORDER` | Default minifig sorting | `"minifigures"."name" ASC` | No |
| `BK_PARTS_DEFAULT_ORDER` | Default part sorting | `"rebrickable_parts"."name" ASC, "rebrickable_parts"."color_name" ASC, "combined"."spare" ASC` | No |
| `BK_MINIFIGURES_DEFAULT_ORDER` | Default minifig sorting | `"rebrickable_minifigures"."name" ASC` | No |
| `BK_WISHES_DEFAULT_ORDER` | Default wishlist sorting | `"bricktracker_wishes"."rowid" DESC` | No |
| `BK_STORAGE_DEFAULT_ORDER` | Default storage sorting | `"bricktracker_metadata_storages"."name" ASC` | No |
| `BK_PURCHASE_LOCATION_DEFAULT_ORDER` | Default purchase location sorting | `"bricktracker_metadata_purchase_locations"."name" ASC` | No |
@@ -111,10 +112,10 @@
BK_SETS_DEFAULT_ORDER="rebrickable_sets"."year" ASC
# Sort parts by missing count descending
BK_PARTS_DEFAULT_ORDER="total_missing" DESC, "inventory"."name" ASC
BK_PARTS_DEFAULT_ORDER="total_missing" DESC, "rebrickable_parts"."name" ASC
# Sort minifigures by ID
BK_MINIFIGURES_DEFAULT_ORDER="minifigures"."fig_num" ASC
# Sort minifigures by figure number
BK_MINIFIGURES_DEFAULT_ORDER="rebrickable_minifigures"."figure" ASC
# Sort wishlist by set number
BK_WISHES_DEFAULT_ORDER="bricktracker_wishes"."set" ASC
+8
View File
@@ -176,6 +176,14 @@ class BrickChanger {
// Not going through dataset to avoid converting
this.html_parent.setAttribute(this.parent_dataset, value);
}
// Update the data-sort attribute on the parent TD for sortable tables
if (this.html_type == "checkbox") {
const parentTd = this.html_element.closest('td');
if (parentTd && parentTd.hasAttribute('data-sort')) {
parentTd.setAttribute('data-sort', Number(this.html_element.checked));
}
}
} catch (error) {
console.log(error.message);
+84 -22
View File
@@ -175,6 +175,53 @@ window.setupSharedSortButtons = function(tableId, tableInstanceGlobal, columnMap
const clearButton = document.querySelector('[data-sort-clear]');
const isPaginationMode = isPaginationModeForTable(tableId);
// Listen for table header clicks to sync button states when user sorts via headers
if (!isPaginationMode) {
const tableElement = document.querySelector(`#${tableId}`);
if (tableElement) {
tableElement.addEventListener('click', (e) => {
// Check if a sortable header was clicked
const header = e.target.closest('th');
if (header && !header.classList.contains('no-sort')) {
// Find the column index of the clicked header
const headers = Array.from(tableElement.querySelectorAll('thead th'));
const clickedIndex = headers.indexOf(header);
// Find which attribute maps to this column index
let clickedAttribute = null;
for (const [attr, idx] of Object.entries(columnMap)) {
if (idx === clickedIndex) {
clickedAttribute = attr;
break;
}
}
// Update button states to match the header sort
sortButtons.forEach(btn => {
if (btn.dataset.sortAttribute === clickedAttribute) {
// Activate this button
btn.classList.remove('btn-outline-primary');
btn.classList.add('btn-primary');
// Determine direction from header class (after click is processed)
setTimeout(() => {
if (header.classList.contains('datatable-ascending')) {
btn.dataset.currentDirection = 'asc';
} else if (header.classList.contains('datatable-descending')) {
btn.dataset.currentDirection = 'desc';
}
}, 10);
} else {
// Deactivate other buttons
btn.classList.remove('btn-primary');
btn.classList.add('btn-outline-primary');
btn.removeAttribute('data-current-direction');
}
});
}
});
}
}
sortButtons.forEach(button => {
button.addEventListener('click', () => {
const attribute = button.dataset.sortAttribute;
@@ -206,34 +253,49 @@ window.setupSharedSortButtons = function(tableId, tableInstanceGlobal, columnMap
} else {
// ORIGINAL MODE - Client-side sorting via Simple DataTables
// Instead of using columns.sort() API, we click the actual table header
// This ensures DataTables handles everything correctly and avoids data corruption
const columnIndex = columnMap[attribute];
const tableInstance = window[tableInstanceGlobal];
const tableElement = document.querySelector(`#${tableId}`);
if (columnIndex !== undefined && tableInstance) {
// Determine sort direction
const isCurrentlyActive = button.classList.contains('btn-primary');
const currentDirection = button.dataset.currentDirection || (isDesc ? 'desc' : 'asc');
const newDirection = isCurrentlyActive ?
(currentDirection === 'asc' ? 'desc' : 'asc') :
(isDesc ? 'desc' : 'asc');
if (columnIndex !== undefined && tableElement) {
const headers = tableElement.querySelectorAll('thead th');
const targetHeader = headers[columnIndex];
// Clear other active buttons
sortButtons.forEach(btn => {
btn.classList.remove('btn-primary');
btn.classList.add('btn-outline-primary');
btn.removeAttribute('data-current-direction');
});
if (targetHeader) {
// Determine desired sort direction
const isCurrentlyActive = button.classList.contains('btn-primary');
const currentDirection = button.dataset.currentDirection || (isDesc ? 'desc' : 'asc');
const newDirection = isCurrentlyActive ?
(currentDirection === 'asc' ? 'desc' : 'asc') :
(isDesc ? 'desc' : 'asc');
// Mark this button as active
button.classList.remove('btn-outline-primary');
button.classList.add('btn-primary');
button.dataset.currentDirection = newDirection;
// Check current header sort state
const headerIsAsc = targetHeader.classList.contains('datatable-ascending');
const headerIsDesc = targetHeader.classList.contains('datatable-descending');
// Apply sort using Simple DataTables API
tableInstance.table.columns.sort(columnIndex, newDirection);
// Click header to achieve desired sort direction
if (newDirection === 'asc') {
if (!headerIsAsc) {
targetHeader.click();
// If it went to desc, click again to get asc
if (targetHeader.classList.contains('datatable-descending')) {
targetHeader.click();
}
}
} else { // desc
if (!headerIsDesc) {
targetHeader.click();
// If it went to asc, click again to get desc
if (targetHeader.classList.contains('datatable-ascending')) {
targetHeader.click();
}
}
}
// Update sort icon to reflect new direction
updateSortIcon(newDirection);
// Update sort icon to reflect new direction
updateSortIcon(newDirection);
}
}
}
});
+5 -5
View File
@@ -15,11 +15,11 @@ document.addEventListener("DOMContentLoaded", () => {
sortColumnMap: {
'name': 1,
'color': 2,
'quantity': 3,
'missing': 4,
'damaged': 5,
'sets': 6,
'minifigures': 7
//'quantity': 3,
'missing': 3,
'damaged': 4,
'sets': 5,
'minifigures': 6
},
hasColorDropdown: true
});
+5
View File
@@ -42,8 +42,13 @@ window.BrickTable = class BrickTable {
const hasCustomInterface = isMinifiguresTable || isPartsTablePaginationMode || isProblemsTablePaginationMode;
const hasCustomSearch = isPartsTable || isProblemsTable || isMinifiguresTable; // Parts, problems, and minifigures always have custom search
// Disable sorting for parts/problems/minifigures tables to prevent data corruption
// These tables should use server-side pagination for sorting, or no sorting in client-side mode
const disableSorting = isPartsTable || isProblemsTable || isMinifiguresTable;
this.table = new simpleDatatables.DataTable(`#${table.id}`, {
columns: columns,
sortable: !disableSorting, // Disable header sorting for tables that have issues with it
paging: !(isPartsTablePaginationMode || isProblemsTablePaginationMode), // Disable built-in pagination for tables in pagination mode
pagerDelta: 1,
perPage: per_page,
+1 -1
View File
@@ -27,7 +27,7 @@
<th data-table-number="true" scope="col"><i class="ri-group-line fw-normal"></i> Minifigures</th>
{% endif %}
{% if checked and not config['HIDE_TABLE_CHECKED_PARTS'] %}
<th data-table-no-sort-and-search="true" class="no-sort" scope="col"><i class="ri-checkbox-line fw-normal"></i> Checked</th>
<th data-table-number="true" scope="col"><i class="ri-checkbox-line fw-normal"></i> Checked</th>
{% endif %}
{% if hamburger_menu and g.login.is_authenticated() %}
{% set show_missing_menu = not config['HIDE_TABLE_MISSING_PARTS'] %}
+2 -2
View File
@@ -15,7 +15,7 @@
{% if item.fields.color_rgb %}<span class="color-rgb color-rgb-table {% if item.fields.color == 9999 %}color-any{% endif %} align-middle border border-black" {% if item.fields.color != 9999 %}style="background-color: #{{ item.fields.color_rgb }};"{% endif %}></span>{% endif %}
<span class="align-middle">{{ item.fields.color_name }}</span>
</td>
<td>{{ item.fields.quantity }}</td>
<td data-sort="{{ item.fields.quantity }}">{{ item.fields.quantity }}</td>
{% if not config['HIDE_TABLE_MISSING_PARTS'] %}
<td data-sort="{{ item.fields.missing }}" class="table-td-input">
{{ form.input('Missing', item.fields.id, item.html_id('missing'), item.url_for_problem('missing'), item.fields.missing, all=false, read_only=read_only) }}
@@ -27,7 +27,7 @@
</td>
{% endif %}
{% if not config['HIDE_TABLE_CHECKED_PARTS'] and not read_only %}
<td class="table-td-input">
<td data-sort="{{ item.fields.checked | default(0) | int }}" class="table-td-input">
<center>{{ form.checkbox('', item.fields.id, item.html_id('checked'), item.url_for_checked(), item.fields.checked | default(false), parent='part', delete=read_only) }}</center>
</td>
{% endif %}
+2 -2
View File
@@ -42,14 +42,14 @@
<td>{{ item.fields.total_minifigures }}</td>
{% else %}
{% if not config['HIDE_TABLE_CHECKED_PARTS'] and not read_only %}
<td class="table-td-input">
<td data-sort="{{ item.fields.checked | default(0) | int }}" class="table-td-input">
<center>{{ form.checkbox('', item.fields.id, item.html_id('checked'), item.url_for_checked(), item.fields.checked | default(false), parent='part', delete=read_only) }}</center>
</td>
{% endif %}
{% if g.login.is_authenticated() and not read_only %}
{% set show_missing_menu = not config['HIDE_TABLE_MISSING_PARTS'] %}
{% set show_checked_menu = not config['HIDE_TABLE_CHECKED_PARTS'] %}
{% set show_quick_add = not config['DISABLE_QUICK_ADD_INDIVIDUAL_PARTS'] and not config['HIDE_INDIVIDUAL_PARTS'] %}
{% set show_quick_add = not config['DISABLE_QUICK_ADD_INDIVIDUAL_PARTS'] and not config['HIDE_INDIVIDUAL_PARTS'] and not config['HIDE_QUICK_ADD_INDIVIDUAL_PARTS'] %}
{% if show_missing_menu or show_checked_menu or show_quick_add %}
<td class="text-end">
{% if show_quick_add %}