feat(set): mass-edit metadata across selected sets (#146)
This commit is contained in:
@@ -53,6 +53,7 @@ class BrickSetList(BrickRecordList[BrickSet]):
|
||||
def all(self, /) -> Self:
|
||||
# Load the sets from the database with metadata context for filtering
|
||||
filter_context = {
|
||||
'custom_fields': BrickSetCustomFieldList.as_columns(),
|
||||
'owners': BrickSetOwnerList.as_columns(),
|
||||
'statuses': BrickSetStatusList.as_columns(),
|
||||
'tags': BrickSetTagList.as_columns(),
|
||||
@@ -65,6 +66,7 @@ class BrickSetList(BrickRecordList[BrickSet]):
|
||||
def all_consolidated(self, /) -> Self:
|
||||
# Load the sets from the database using consolidated query with metadata context
|
||||
filter_context = {
|
||||
'custom_fields_dict': BrickSetCustomFieldList.as_column_mapping(),
|
||||
'owners_dict': BrickSetOwnerList.as_column_mapping(),
|
||||
'statuses_dict': BrickSetStatusList.as_column_mapping(),
|
||||
'tags_dict': BrickSetTagList.as_column_mapping(),
|
||||
@@ -118,9 +120,11 @@ class BrickSetList(BrickRecordList[BrickSet]):
|
||||
'tag_filter': tag_filter,
|
||||
'year_filter': year_filter,
|
||||
'duplicate_filter': duplicate_filter,
|
||||
'custom_fields': BrickSetCustomFieldList.as_columns(),
|
||||
'owners': BrickSetOwnerList.as_columns(),
|
||||
'statuses': BrickSetStatusList.as_columns(),
|
||||
'tags': BrickSetTagList.as_columns(),
|
||||
'custom_fields_dict': BrickSetCustomFieldList.as_column_mapping(),
|
||||
'owners_dict': BrickSetOwnerList.as_column_mapping(),
|
||||
'statuses_dict': BrickSetStatusList.as_column_mapping(),
|
||||
'tags_dict': BrickSetTagList.as_column_mapping(),
|
||||
|
||||
@@ -43,6 +43,15 @@ SELECT
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
{% block custom_fields %}
|
||||
{% if custom_fields_dict %}
|
||||
{% for column, uuid in custom_fields_dict.items() %}
|
||||
-- Representative value for the group (shared value when all
|
||||
-- instances agree; used for bulk-edit read across instances).
|
||||
, MAX("bricktracker_set_custom_fields"."{{ column }}") AS "{{ column }}"
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
FROM "bricktracker_sets"
|
||||
|
||||
INNER JOIN "rebrickable_sets"
|
||||
@@ -84,6 +93,11 @@ LEFT JOIN "bricktracker_set_tags"
|
||||
ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "bricktracker_set_tags"."id"
|
||||
{% endif %}
|
||||
|
||||
{% if custom_fields_dict %}
|
||||
LEFT JOIN "bricktracker_set_custom_fields"
|
||||
ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "bricktracker_set_custom_fields"."id"
|
||||
{% endif %}
|
||||
|
||||
{% block where %}
|
||||
WHERE 1=1
|
||||
{% if search_query %}
|
||||
|
||||
@@ -31,6 +31,7 @@ from ..set_status_list import BrickSetStatusList
|
||||
from ..set_storage_list import BrickSetStorageList
|
||||
from ..set_tag_list import BrickSetTagList
|
||||
from ..socket import MESSAGES
|
||||
from ..sql import BrickSQL
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -243,6 +244,88 @@ def update_custom_field(*, id: str, metadata_id: str) -> Response:
|
||||
return jsonify({'value': value})
|
||||
|
||||
|
||||
# Mass-edit metadata across several selected sets in one request. Only the
|
||||
# fields the user actually touched are present in "changes"; tri-state booleans
|
||||
# arrive as explicit true/false, value fields as a string ('' clears).
|
||||
@set_page.route('/bulk/edit', methods=['POST'])
|
||||
@login_required
|
||||
@exception_handler(__file__, json=True)
|
||||
def bulk_edit() -> Response:
|
||||
payload = request.json or {}
|
||||
set_ids = payload.get('set_ids', [])
|
||||
changes = payload.get('changes', {})
|
||||
|
||||
if not set_ids:
|
||||
raise ErrorException('No sets were selected')
|
||||
|
||||
tags = changes.get('tags', {})
|
||||
statuses = changes.get('statuses', {})
|
||||
owners = changes.get('owners', {})
|
||||
custom_fields = changes.get('custom_fields', {})
|
||||
|
||||
has_storage = 'storage' in changes
|
||||
has_purchase_location = 'purchase_location' in changes
|
||||
has_purchase_date = 'purchase_date' in changes
|
||||
has_purchase_price = 'purchase_price' in changes
|
||||
|
||||
for set_id in set_ids:
|
||||
brickset = BrickSet().select_light(set_id)
|
||||
|
||||
# Boolean metadata: defer so the batch is grouped (committed below).
|
||||
for metadata_id, state in tags.items():
|
||||
BrickSetTagList.get(metadata_id).update_set_state(
|
||||
brickset, state=state, commit=False
|
||||
)
|
||||
for metadata_id, state in statuses.items():
|
||||
BrickSetStatusList.get(metadata_id).update_set_state(
|
||||
brickset, state=state, commit=False
|
||||
)
|
||||
for metadata_id, state in owners.items():
|
||||
BrickSetOwnerList.get(metadata_id).update_set_state(
|
||||
brickset, state=state, commit=False
|
||||
)
|
||||
|
||||
# Value metadata (each commits on its own).
|
||||
if has_storage:
|
||||
storage = BrickSetStorageList.get(
|
||||
changes.get('storage', ''), allow_none=True
|
||||
)
|
||||
storage.update_set_value(brickset, value=storage.fields.id)
|
||||
|
||||
if has_purchase_location:
|
||||
purchase_location = BrickSetPurchaseLocationList.get(
|
||||
changes.get('purchase_location', ''), allow_none=True
|
||||
)
|
||||
purchase_location.update_set_value(
|
||||
brickset, value=purchase_location.fields.id
|
||||
)
|
||||
|
||||
if has_purchase_date:
|
||||
brickset.update_purchase_date(
|
||||
{'value': changes.get('purchase_date', '')}
|
||||
)
|
||||
|
||||
if has_purchase_price:
|
||||
brickset.update_purchase_price(
|
||||
{'value': changes.get('purchase_price', '')}
|
||||
)
|
||||
|
||||
for metadata_id, value in custom_fields.items():
|
||||
BrickSetCustomFieldList.get(metadata_id).update_value(
|
||||
brickset, value=value
|
||||
)
|
||||
|
||||
# Flush any deferred boolean updates that did not ride along with a
|
||||
# value update's commit.
|
||||
BrickSQL().commit()
|
||||
|
||||
logger.info('Bulk edit applied to {count} set(s)'.format(
|
||||
count=len(set_ids),
|
||||
))
|
||||
|
||||
return jsonify({'updated': len(set_ids)})
|
||||
|
||||
|
||||
# Ask for deletion of a set
|
||||
@set_page.route('/<id>/delete', methods=['GET'])
|
||||
@login_required
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
// Mass-edit of sets from the grid (Feature 4).
|
||||
//
|
||||
// Lets an authenticated user select several cards on the sets grid and edit
|
||||
// their metadata in one pass. Each editable control is pre-filled from the
|
||||
// selected cards: a shared value is shown and editable, differing values show
|
||||
// "varies" and only overwrite when actually touched. Boolean metadata
|
||||
// (tags/statuses/owners) is tri-state: all-on / all-off / mixed.
|
||||
(function () {
|
||||
// Convert a stored purchase-date timestamp (epoch seconds) to the value
|
||||
// expected by <input type="date"> (yyyy-mm-dd), using local time since the
|
||||
// stored timestamp was built from a local midnight date.
|
||||
function tsToDateInput(ts) {
|
||||
if (!ts) return "";
|
||||
const d = new Date(parseFloat(ts) * 1000);
|
||||
if (isNaN(d.getTime())) return "";
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${m}-${day}`;
|
||||
}
|
||||
|
||||
// Custom date fields are stored as yyyy/mm/dd text (matching the single-set
|
||||
// editor), so just swap the separators for the date input.
|
||||
function slashToDateInput(v) {
|
||||
return v ? v.replaceAll("/", "-") : "";
|
||||
}
|
||||
|
||||
// Reverse of the above: the backend (purchase date + custom date fields)
|
||||
// expects yyyy/mm/dd.
|
||||
function dateInputToSlash(v) {
|
||||
return v ? v.replaceAll("-", "/") : "";
|
||||
}
|
||||
|
||||
function removeVariesOption(sel) {
|
||||
if (sel.tagName !== "SELECT") return;
|
||||
const opt = sel.querySelector('option[data-varies="true"]');
|
||||
if (opt) opt.remove();
|
||||
}
|
||||
|
||||
function addVariesOption(sel) {
|
||||
removeVariesOption(sel);
|
||||
const opt = document.createElement("option");
|
||||
opt.value = "__varies__";
|
||||
opt.textContent = "— varies —";
|
||||
opt.dataset.varies = "true";
|
||||
opt.disabled = true;
|
||||
opt.selected = true;
|
||||
sel.insertBefore(opt, sel.firstChild);
|
||||
sel.value = "__varies__";
|
||||
}
|
||||
|
||||
function initBulkEdit() {
|
||||
const toggle = document.getElementById("bulk-select-toggle");
|
||||
const grid = document.getElementById("grid");
|
||||
const panel = document.getElementById("bulk-edit-panel");
|
||||
const bar = document.getElementById("bulk-select-bar");
|
||||
|
||||
// Only on the authenticated sets grid
|
||||
if (!toggle || !grid || !panel || !bar) return;
|
||||
|
||||
const countEl = document.getElementById("bulk-select-count");
|
||||
const editCountEl = document.getElementById("bulk-edit-count");
|
||||
const openBtn = document.getElementById("bulk-edit-open");
|
||||
const applyBtn = document.getElementById("bulk-edit-apply");
|
||||
const bulkUrl = bar.dataset.bulkEditUrl;
|
||||
|
||||
const selected = new Set();
|
||||
|
||||
const cards = () => Array.from(grid.querySelectorAll(".card[data-set-id]"));
|
||||
|
||||
// Inject a checkbox overlay into every card (revealed by CSS in select
|
||||
// mode).
|
||||
cards().forEach((card) => {
|
||||
if (card.querySelector(".bulk-select-check")) return;
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "bulk-select-check";
|
||||
overlay.innerHTML =
|
||||
'<input type="checkbox" class="form-check-input" tabindex="-1">';
|
||||
card.appendChild(overlay);
|
||||
});
|
||||
|
||||
const isSelected = (card) => card.classList.contains("bulk-selected");
|
||||
|
||||
function setSelected(card, on) {
|
||||
card.classList.toggle("bulk-selected", on);
|
||||
const cb = card.querySelector(".bulk-select-check input");
|
||||
if (cb) cb.checked = on;
|
||||
if (on) selected.add(card);
|
||||
else selected.delete(card);
|
||||
}
|
||||
|
||||
function refreshCount() {
|
||||
const n = selected.size;
|
||||
countEl.textContent = n;
|
||||
editCountEl.textContent = n;
|
||||
openBtn.disabled = n === 0;
|
||||
}
|
||||
|
||||
function enterMode() {
|
||||
document.body.classList.add("bulk-select-mode");
|
||||
bar.classList.add("show");
|
||||
toggle.classList.add("active");
|
||||
}
|
||||
|
||||
function exitMode() {
|
||||
document.body.classList.remove("bulk-select-mode");
|
||||
bar.classList.remove("show");
|
||||
toggle.classList.remove("active");
|
||||
cards().forEach((c) => setSelected(c, false));
|
||||
refreshCount();
|
||||
}
|
||||
|
||||
toggle.addEventListener("click", () => {
|
||||
if (document.body.classList.contains("bulk-select-mode")) exitMode();
|
||||
else enterMode();
|
||||
});
|
||||
|
||||
document
|
||||
.getElementById("bulk-select-exit")
|
||||
.addEventListener("click", exitMode);
|
||||
|
||||
document
|
||||
.getElementById("bulk-select-clear")
|
||||
.addEventListener("click", () => {
|
||||
cards().forEach((c) => setSelected(c, false));
|
||||
refreshCount();
|
||||
});
|
||||
|
||||
document.getElementById("bulk-select-all").addEventListener("click", () => {
|
||||
cards().forEach((c) => {
|
||||
// Skip cards hidden by client-side search/filter
|
||||
if (c.offsetParent === null) return;
|
||||
setSelected(c, true);
|
||||
});
|
||||
refreshCount();
|
||||
});
|
||||
|
||||
// Clicking a card while in select mode toggles it (capture phase so it
|
||||
// wins over the card's own links).
|
||||
grid.addEventListener(
|
||||
"click",
|
||||
(e) => {
|
||||
if (!document.body.classList.contains("bulk-select-mode")) return;
|
||||
const card = e.target.closest(".card[data-set-id]");
|
||||
if (!card || !grid.contains(card)) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setSelected(card, !isSelected(card));
|
||||
refreshCount();
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
// --- Field computation ----------------------------------------------
|
||||
|
||||
function computeTristate(ctrl, cardEls) {
|
||||
const attr = "data-" + ctrl.dataset.cardAttr;
|
||||
let on = 0;
|
||||
cardEls.forEach((c) => {
|
||||
if (c.getAttribute(attr) !== null) on++;
|
||||
});
|
||||
if (on === 0) {
|
||||
ctrl.checked = false;
|
||||
ctrl.indeterminate = false;
|
||||
} else if (on === cardEls.length) {
|
||||
ctrl.checked = true;
|
||||
ctrl.indeterminate = false;
|
||||
} else {
|
||||
ctrl.checked = false;
|
||||
ctrl.indeterminate = true;
|
||||
}
|
||||
}
|
||||
|
||||
function computeValue(ctrl, cardEls) {
|
||||
const attr = "data-" + ctrl.dataset.cardAttr;
|
||||
const raw = cardEls.map((c) => {
|
||||
const v = c.getAttribute(attr);
|
||||
return v === null ? "" : v;
|
||||
});
|
||||
const allEqual = raw.every((v) => v === raw[0]);
|
||||
removeVariesOption(ctrl);
|
||||
|
||||
if (allEqual) {
|
||||
let v = raw[0];
|
||||
if (ctrl.type === "date") {
|
||||
v =
|
||||
ctrl.dataset.bulkField === "purchase_date"
|
||||
? tsToDateInput(v)
|
||||
: slashToDateInput(v);
|
||||
}
|
||||
if (ctrl.tagName === "SELECT") ctrl.value = v == null ? "" : v;
|
||||
else ctrl.value = v;
|
||||
ctrl.placeholder = "";
|
||||
} else if (ctrl.tagName === "SELECT") {
|
||||
addVariesOption(ctrl);
|
||||
} else {
|
||||
ctrl.value = "";
|
||||
ctrl.placeholder = "— varies —";
|
||||
}
|
||||
}
|
||||
|
||||
function computePanel(cardEls) {
|
||||
panel.querySelectorAll(".bulk-control").forEach((ctrl) => {
|
||||
if (ctrl.classList.contains("bulk-tristate")) {
|
||||
computeTristate(ctrl, cardEls);
|
||||
} else {
|
||||
computeValue(ctrl, cardEls);
|
||||
}
|
||||
ctrl.dataset.pristine = "true";
|
||||
ctrl.classList.remove("bulk-touched");
|
||||
});
|
||||
}
|
||||
|
||||
// Touched tracking: only fields the user actually changes are applied.
|
||||
panel.querySelectorAll(".bulk-control").forEach((ctrl) => {
|
||||
const onTouch = () => {
|
||||
ctrl.dataset.pristine = "false";
|
||||
ctrl.classList.add("bulk-touched");
|
||||
removeVariesOption(ctrl);
|
||||
if (ctrl.classList.contains("bulk-tristate"))
|
||||
ctrl.indeterminate = false;
|
||||
};
|
||||
ctrl.addEventListener("change", onTouch);
|
||||
ctrl.addEventListener("input", onTouch);
|
||||
});
|
||||
|
||||
openBtn.addEventListener("click", () => {
|
||||
if (selected.size === 0) return;
|
||||
computePanel(Array.from(selected));
|
||||
bootstrap.Offcanvas.getOrCreateInstance(panel).show();
|
||||
});
|
||||
|
||||
// --- Apply ----------------------------------------------------------
|
||||
|
||||
applyBtn.addEventListener("click", async () => {
|
||||
const cardEls = Array.from(selected);
|
||||
if (cardEls.length === 0) return;
|
||||
|
||||
// Expand consolidated cards into their real instance ids.
|
||||
const setIds = [];
|
||||
const seen = new Set();
|
||||
cardEls.forEach((c) => {
|
||||
const inst = c.getAttribute("data-instance-ids");
|
||||
const ids = inst
|
||||
? inst.split(/[\s,|]+/).filter(Boolean)
|
||||
: [c.getAttribute("data-set-id")].filter(Boolean);
|
||||
ids.forEach((id) => {
|
||||
if (!seen.has(id)) {
|
||||
seen.add(id);
|
||||
setIds.push(id);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const changes = { tags: {}, statuses: {}, owners: {}, custom_fields: {} };
|
||||
|
||||
panel.querySelectorAll(".bulk-control").forEach((ctrl) => {
|
||||
if (ctrl.dataset.pristine !== "false") return; // untouched
|
||||
const field = ctrl.dataset.bulkField;
|
||||
|
||||
if (ctrl.classList.contains("bulk-tristate")) {
|
||||
const id = ctrl.dataset.metaId;
|
||||
const map =
|
||||
field === "tag"
|
||||
? changes.tags
|
||||
: field === "status"
|
||||
? changes.statuses
|
||||
: changes.owners;
|
||||
map[id] = ctrl.checked;
|
||||
} else if (field === "custom_field") {
|
||||
let v = ctrl.value;
|
||||
if (ctrl.type === "date") v = dateInputToSlash(v);
|
||||
changes.custom_fields[ctrl.dataset.cfId] = v;
|
||||
} else if (field === "storage") {
|
||||
changes.storage = ctrl.value;
|
||||
} else if (field === "purchase_location") {
|
||||
changes.purchase_location = ctrl.value;
|
||||
} else if (field === "purchase_price") {
|
||||
changes.purchase_price = ctrl.value;
|
||||
} else if (field === "purchase_date") {
|
||||
changes.purchase_date = dateInputToSlash(ctrl.value);
|
||||
}
|
||||
});
|
||||
|
||||
// Drop empty boolean/custom maps so the backend skips them.
|
||||
["tags", "statuses", "owners", "custom_fields"].forEach((k) => {
|
||||
if (Object.keys(changes[k]).length === 0) delete changes[k];
|
||||
});
|
||||
|
||||
applyBtn.disabled = true;
|
||||
try {
|
||||
const resp = await fetch(bulkUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ set_ids: setIds, changes: changes }),
|
||||
});
|
||||
if (!resp.ok)
|
||||
throw new Error(`Response status: ${resp.status} (${resp.statusText})`);
|
||||
const json = await resp.json();
|
||||
if ("error" in json) throw new Error(json.error);
|
||||
window.location.reload();
|
||||
} catch (err) {
|
||||
applyBtn.disabled = false;
|
||||
alert("Bulk edit failed: " + err.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", initBulkEdit);
|
||||
})();
|
||||
@@ -353,3 +353,54 @@
|
||||
tr.parts-filtered-out {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* --- Mass-edit (Feature 4) --- */
|
||||
.bulk-select-bar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1040;
|
||||
display: none;
|
||||
background-color: var(--bs-body-bg);
|
||||
border-top: 1px solid var(--bs-border-color);
|
||||
}
|
||||
|
||||
.bulk-select-bar.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Keep the sticky bar from covering the last row of cards */
|
||||
body.bulk-select-mode {
|
||||
padding-bottom: 4.5rem;
|
||||
}
|
||||
|
||||
body.bulk-select-mode #grid .card {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.bulk-select-check {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
left: 0.5rem;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.bulk-select-check .form-check-input {
|
||||
width: 1.4rem;
|
||||
height: 1.4rem;
|
||||
}
|
||||
|
||||
body.bulk-select-mode .bulk-select-check {
|
||||
display: block;
|
||||
}
|
||||
|
||||
#grid .card.bulk-selected {
|
||||
outline: 3px solid var(--bs-primary);
|
||||
outline-offset: -3px;
|
||||
}
|
||||
|
||||
.bulk-control.bulk-touched {
|
||||
border-color: var(--bs-primary);
|
||||
}
|
||||
|
||||
@@ -190,6 +190,9 @@
|
||||
{% endif %}
|
||||
{% if request.endpoint == 'set.list' %}
|
||||
<script src="{{ url_for('static', filename='scripts/sets.js') }}"></script>
|
||||
{% if g.login.is_authenticated() %}
|
||||
<script src="{{ url_for('static', filename='scripts/sets-bulk-edit.js') }}"></script>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if request.endpoint == 'set.details' %}
|
||||
<script src="{{ url_for('static', filename='scripts/parts-table-filter.js') }}"></script>
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
{# Mass-edit UI (Feature 4). Rendered server-side with the full metadata lists
|
||||
so the offcanvas form has every editable field. All logic (selection,
|
||||
shared-vs-varies detection, tri-state, apply) lives in sets-bulk-edit.js. #}
|
||||
|
||||
<!-- Sticky bottom bar shown while in bulk-select mode -->
|
||||
<div id="bulk-select-bar" class="bulk-select-bar shadow-lg" data-bulk-edit-url="{{ url_for('set.bulk_edit') }}">
|
||||
<div class="container-fluid d-flex flex-wrap align-items-center gap-2 py-2">
|
||||
<span class="fw-bold"><span id="bulk-select-count">0</span> selected</span>
|
||||
<div class="ms-auto d-flex flex-wrap gap-2">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="bulk-select-all"><i class="ri-checkbox-multiple-line"></i> Select all visible</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="bulk-select-clear"><i class="ri-close-circle-line"></i> Clear</button>
|
||||
<button type="button" class="btn btn-sm btn-primary" id="bulk-edit-open" disabled><i class="ri-edit-line"></i> Edit selected</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger" id="bulk-select-exit"><i class="ri-logout-box-line"></i> Done</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bulk edit panel -->
|
||||
<div class="offcanvas offcanvas-end" tabindex="-1" id="bulk-edit-panel" aria-labelledby="bulk-edit-panel-label" data-bs-scroll="true" data-bs-backdrop="true">
|
||||
<div class="offcanvas-header border-bottom">
|
||||
<h5 class="offcanvas-title" id="bulk-edit-panel-label">Edit <span id="bulk-edit-count">0</span> sets</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="offcanvas-body">
|
||||
<p class="text-muted small">
|
||||
Only fields you actually change are applied. Fields where the selected sets
|
||||
differ show <em>varies</em> and stay untouched unless you edit them.
|
||||
</p>
|
||||
|
||||
{# --- Value fields --- #}
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="bulk-purchase-price">Purchase price</label>
|
||||
<input type="number" step="0.01" class="form-control bulk-control" id="bulk-purchase-price" data-bulk-field="purchase_price" data-card-attr="purchase-price">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="bulk-purchase-date">Purchase date</label>
|
||||
<input type="date" class="form-control bulk-control" id="bulk-purchase-date" data-bulk-field="purchase_date" data-card-attr="purchase-date">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="bulk-storage">Storage</label>
|
||||
<select class="form-select bulk-control" id="bulk-storage" data-bulk-field="storage" data-card-attr="storage">
|
||||
<option value="">-- No storage --</option>
|
||||
{% for storage in brickset_storages %}
|
||||
<option value="{{ storage.fields.id }}">{{ storage.fields.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="bulk-purchase-location">Purchase location</label>
|
||||
<select class="form-select bulk-control" id="bulk-purchase-location" data-bulk-field="purchase_location" data-card-attr="purchase-location">
|
||||
<option value="">-- No purchase location --</option>
|
||||
{% for location in brickset_purchase_locations %}
|
||||
<option value="{{ location.fields.id }}">{{ location.fields.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{# --- Custom fields --- #}
|
||||
{% if brickset_custom_fields | length %}
|
||||
<hr>
|
||||
<h6 class="text-muted">Custom fields</h6>
|
||||
{% for custom_field in brickset_custom_fields %}
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="bulk-cf-{{ custom_field.fields.id }}">{{ custom_field.fields.name }}</label>
|
||||
{% if custom_field.fields.type == 'number' %}
|
||||
<input type="number" step="any" class="form-control bulk-control" id="bulk-cf-{{ custom_field.fields.id }}" data-bulk-field="custom_field" data-cf-id="{{ custom_field.fields.id }}" data-card-attr="{{ custom_field.as_dataset() }}">
|
||||
{% elif custom_field.fields.type == 'date' %}
|
||||
<input type="date" class="form-control bulk-control" id="bulk-cf-{{ custom_field.fields.id }}" data-bulk-field="custom_field" data-cf-id="{{ custom_field.fields.id }}" data-cf-date="true" data-card-attr="{{ custom_field.as_dataset() }}">
|
||||
{% else %}
|
||||
<input type="text" class="form-control bulk-control" id="bulk-cf-{{ custom_field.fields.id }}" data-bulk-field="custom_field" data-cf-id="{{ custom_field.fields.id }}" data-card-attr="{{ custom_field.as_dataset() }}">
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{# --- Tri-state boolean metadata --- #}
|
||||
{% if brickset_tags | length %}
|
||||
<hr>
|
||||
<h6 class="text-muted">Tags</h6>
|
||||
{% for tag in brickset_tags %}
|
||||
<div class="form-check">
|
||||
<input class="form-check-input bulk-control bulk-tristate" type="checkbox" id="bulk-tag-{{ tag.fields.id }}" data-bulk-field="tag" data-meta-id="{{ tag.fields.id }}" data-card-attr="{{ tag.as_dataset() }}">
|
||||
<label class="form-check-label" for="bulk-tag-{{ tag.fields.id }}">{{ tag.fields.name }}</label>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% if brickset_statuses | length %}
|
||||
<hr>
|
||||
<h6 class="text-muted">Statuses</h6>
|
||||
{% for status in brickset_statuses %}
|
||||
<div class="form-check">
|
||||
<input class="form-check-input bulk-control bulk-tristate" type="checkbox" id="bulk-status-{{ status.fields.id }}" data-bulk-field="status" data-meta-id="{{ status.fields.id }}" data-card-attr="{{ status.as_dataset() }}">
|
||||
<label class="form-check-label" for="bulk-status-{{ status.fields.id }}">{{ status.fields.name }}</label>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% if brickset_owners | length %}
|
||||
<hr>
|
||||
<h6 class="text-muted">Owners</h6>
|
||||
{% for owner in brickset_owners %}
|
||||
<div class="form-check">
|
||||
<input class="form-check-input bulk-control bulk-tristate" type="checkbox" id="bulk-owner-{{ owner.fields.id }}" data-bulk-field="owner" data-meta-id="{{ owner.fields.id }}" data-card-attr="{{ owner.as_dataset() }}">
|
||||
<label class="form-check-label" for="bulk-owner-{{ owner.fields.id }}">{{ owner.fields.name }}</label>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="offcanvas-footer border-top p-3 d-flex gap-2">
|
||||
<button type="button" class="btn btn-secondary flex-fill" data-bs-dismiss="offcanvas">Cancel</button>
|
||||
<button type="button" class="btn btn-primary flex-fill" id="bulk-edit-apply"><i class="ri-save-line"></i> Apply changes</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -52,6 +52,15 @@
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
{# Custom field values for bulk-edit read (only emitted when set). The attr
|
||||
absence means "empty/varies" to the bulk editor. #}
|
||||
{% for custom_field in brickset_custom_fields %}
|
||||
{% with value=item.fields[custom_field.as_column()] %}
|
||||
{% if value is not none and value != '' %}
|
||||
data-{{ custom_field.as_dataset() }}="{{ value }}"
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
>
|
||||
{{ card.header(item, item.fields.name, solo=solo, identifier=item.fields.set) }}
|
||||
|
||||
@@ -27,6 +27,11 @@
|
||||
<i class="ri-stack-line"></i> Duplicates
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if g.login.is_authenticated() %}
|
||||
<button class="btn btn-outline-primary" type="button" id="bulk-select-toggle" title="Select multiple sets to edit at once">
|
||||
<i class="ri-checkbox-multiple-line"></i> Select
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -168,6 +173,10 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if g.login.is_authenticated() %}
|
||||
{% include 'set/bulk_edit.html' %}
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
{% else %}
|
||||
{% include 'set/empty.html' %}
|
||||
|
||||
Reference in New Issue
Block a user