Add automation group management — rename, delete, bulk toggle, drag-drop

Full automation page upgrade with group management and drag-and-drop:

Backend: batch_update_group() and bulk_set_enabled() DB methods, new
PUT /api/automations/group and POST /api/automations/bulk-toggle endpoints.

Group headers: rename (inline edit), delete (choice dialog — keep
automations or delete all), bulk toggle (enable/disable all in group).
Actions appear on hover, styled as small icon buttons.

Drag and drop: non-system cards are draggable between group sections.
Drop zones show dashed accent border feedback. Collapsed sections
auto-expand on 500ms drag-hover. System/Hub sections dimmed during drag.
dragenter counter pattern handles child element bubbling.

Delete group dialog: glass card modal with three options — keep
automations (move to My Automations), delete everything, or cancel.
This commit is contained in:
Broque Thomas 2026-04-16 14:19:13 -07:00
parent 7317bd7c55
commit 85b470809e
4 changed files with 538 additions and 4 deletions

View file

@ -10794,6 +10794,44 @@ class MusicDatabase:
logger.error(f"Error deleting automation {automation_id}: {e}")
return False
def batch_update_group(self, automation_ids: list, group_name: str = None) -> int:
"""Batch update group_name for multiple automations. Excludes system automations."""
if not automation_ids:
return 0
try:
with self._get_connection() as conn:
cursor = conn.cursor()
placeholders = ','.join('?' for _ in automation_ids)
cursor.execute(
f"UPDATE automations SET group_name = ?, updated_at = CURRENT_TIMESTAMP "
f"WHERE id IN ({placeholders}) AND (is_system IS NULL OR is_system = 0)",
[group_name] + list(automation_ids)
)
conn.commit()
return cursor.rowcount
except Exception as e:
logger.error(f"Error batch updating group: {e}")
return 0
def bulk_set_enabled(self, automation_ids: list, enabled: bool) -> int:
"""Bulk enable/disable multiple automations. Excludes system automations."""
if not automation_ids:
return 0
try:
with self._get_connection() as conn:
cursor = conn.cursor()
placeholders = ','.join('?' for _ in automation_ids)
cursor.execute(
f"UPDATE automations SET enabled = ?, updated_at = CURRENT_TIMESTAMP "
f"WHERE id IN ({placeholders}) AND (is_system IS NULL OR is_system = 0)",
[1 if enabled else 0] + list(automation_ids)
)
conn.commit()
return cursor.rowcount
except Exception as e:
logger.error(f"Error bulk toggling automations: {e}")
return 0
def toggle_automation(self, automation_id: int) -> bool:
"""Toggle the enabled state of an automation. Returns True on success."""
try:

View file

@ -6103,6 +6103,67 @@ def update_automation_endpoint(automation_id):
logger.error(f"Error updating automation: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/automations/group', methods=['PUT'])
def batch_update_automation_group():
"""Batch update group_name for multiple automations (rename group, delete group, drag-drop)."""
try:
data = request.get_json()
automation_ids = data.get('automation_ids', [])
group_name = data.get('group_name') # None/null = ungroup
if not automation_ids or not isinstance(automation_ids, list):
return jsonify({"error": "automation_ids must be a non-empty list"}), 400
# Sanitize IDs to integers
try:
automation_ids = [int(aid) for aid in automation_ids]
except (ValueError, TypeError):
return jsonify({"error": "automation_ids must contain integers"}), 400
db = get_database()
updated = db.batch_update_group(automation_ids, group_name)
return jsonify({"success": True, "updated": updated})
except Exception as e:
logger.error(f"Error batch updating automation group: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/automations/bulk-toggle', methods=['POST'])
def bulk_toggle_automations():
"""Bulk enable/disable multiple automations."""
try:
data = request.get_json()
automation_ids = data.get('automation_ids', [])
enabled = data.get('enabled', True)
if not automation_ids or not isinstance(automation_ids, list):
return jsonify({"error": "automation_ids must be a non-empty list"}), 400
try:
automation_ids = [int(aid) for aid in automation_ids]
except (ValueError, TypeError):
return jsonify({"error": "automation_ids must contain integers"}), 400
db = get_database()
updated = db.bulk_set_enabled(automation_ids, bool(enabled))
# Reschedule/cancel affected automations
if automation_engine and updated > 0:
for aid in automation_ids:
auto = db.get_automation(aid)
if auto:
if auto.get('enabled'):
automation_engine.schedule_automation(auto)
else:
automation_engine.cancel_automation(aid)
return jsonify({"success": True, "updated": updated})
except Exception as e:
logger.error(f"Error bulk toggling automations: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/automations/<int:automation_id>', methods=['DELETE'])
def delete_automation_endpoint(automation_id):
"""Delete an automation. System automations cannot be deleted."""

View file

@ -68895,26 +68895,113 @@ const AUTO_HUB_REFERENCE = {
// --- Load & Render List ---
function _buildAutomationSection(id, label, automations, useGrid) {
// Drag-and-drop state
let _autoDragState = null;
let _autoDragEnterCount = 0;
let _autoDragExpandTimer = null;
function _buildAutomationSection(id, label, automations, useGrid, options = {}) {
const groupName = options.groupName || null;
const isProtected = options.isProtected || false; // System, Hub sections
const section = document.createElement('div');
section.className = 'automations-section';
if (isProtected) section.classList.add('section-protected');
section.id = id;
if (groupName) section.dataset.groupName = groupName;
const collapsed = localStorage.getItem('auto_section_' + id) === '1';
if (collapsed) section.classList.add('collapsed');
const header = document.createElement('div');
header.className = 'automations-section-header';
// Group header actions (rename, bulk toggle, delete) — only for user groups
let actionsHtml = '';
if (groupName && !isProtected) {
const enabledCount = automations.filter(a => a.enabled).length;
const allEnabled = enabledCount === automations.length;
actionsHtml = `
<div class="section-actions" onclick="event.stopPropagation();">
<button class="section-action-btn" title="${allEnabled ? 'Disable all' : 'Enable all'}" onclick="_bulkToggleGroup('${_escAttr(groupName)}', ${allEnabled})">
${allEnabled ? '⏸' : '▶'}
</button>
<button class="section-action-btn" title="Rename group" onclick="_startRenameGroup('${_escAttr(groupName)}', this)">
</button>
<button class="section-action-btn section-action-danger" title="Delete group" onclick="_deleteGroup('${_escAttr(groupName)}')">
🗑
</button>
</div>
`;
}
header.innerHTML = `
<span class="section-chevron">&#9660;</span>
<span class="section-label">${label}</span>
<span class="section-count">${automations.length}</span>
${actionsHtml}
<span class="section-line"></span>
`;
header.onclick = () => {
header.onclick = (e) => {
if (e.target.closest('.section-actions')) return;
section.classList.toggle('collapsed');
localStorage.setItem('auto_section_' + id, section.classList.contains('collapsed') ? '1' : '0');
};
const body = document.createElement('div');
body.className = 'automations-section-body';
// Drop zone setup (not for protected sections)
if (!isProtected) {
const dropGroupName = groupName; // null for "My Automations"
body.addEventListener('dragover', (e) => {
if (!_autoDragState) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
body.classList.add('drop-target');
});
body.addEventListener('dragenter', (e) => {
if (!_autoDragState) return;
_autoDragEnterCount++;
body.classList.add('drop-target');
// Expand collapsed sections on drag-hover
if (section.classList.contains('collapsed')) {
_autoDragExpandTimer = setTimeout(() => {
section.classList.remove('collapsed');
}, 500);
}
});
body.addEventListener('dragleave', (e) => {
if (!_autoDragState) return;
_autoDragEnterCount--;
if (_autoDragEnterCount <= 0) {
_autoDragEnterCount = 0;
body.classList.remove('drop-target');
if (_autoDragExpandTimer) { clearTimeout(_autoDragExpandTimer); _autoDragExpandTimer = null; }
}
});
body.addEventListener('drop', async (e) => {
e.preventDefault();
body.classList.remove('drop-target');
_autoDragEnterCount = 0;
if (!_autoDragState) return;
const draggedId = _autoDragState.id;
const fromGroup = _autoDragState.groupName;
if (fromGroup === dropGroupName) return; // Same group, no-op
try {
const res = await fetch('/api/automations/' + draggedId, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ group_name: dropGroupName })
});
const data = await res.json();
if (data.error) throw new Error(data.error);
showToast(dropGroupName ? `Moved to "${dropGroupName}"` : 'Moved to My Automations', 'success');
await loadAutomations();
} catch (err) { showToast('Error: ' + err.message, 'error'); }
});
}
const container = document.createElement('div');
container.className = useGrid ? 'automations-grid' : 'automations-user-list';
automations.forEach(a => container.appendChild(renderAutomationCard(a)));
@ -68924,6 +69011,167 @@ function _buildAutomationSection(id, label, automations, useGrid) {
return section;
}
/**
* Delete a group ungroups all automations (moves to My Automations).
*/
async function _deleteGroup(groupName) {
// Collect automation IDs in this group
const ids = [];
document.querySelectorAll(`.automations-section[data-group-name="${groupName}"] .automation-card`).forEach(card => {
if (card.dataset.id) ids.push(parseInt(card.dataset.id));
});
if (ids.length === 0) { await loadAutomations(); return; }
// Show choice dialog — ungroup or delete all
const choice = await _showDeleteGroupDialog(groupName, ids.length);
if (!choice) return;
try {
if (choice === 'ungroup') {
const res = await fetch('/api/automations/group', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ automation_ids: ids, group_name: null })
});
const data = await res.json();
if (data.error) throw new Error(data.error);
showToast(`Dissolved group "${groupName}" — ${data.updated} automations moved to My Automations`, 'success');
} else if (choice === 'delete_all') {
// Delete each automation
let deleted = 0;
for (const id of ids) {
try {
const res = await fetch('/api/automations/' + id, { method: 'DELETE' });
const data = await res.json();
if (data.success) deleted++;
} catch (e) {}
}
showToast(`Deleted group "${groupName}" and ${deleted} automation${deleted !== 1 ? 's' : ''}`, 'success');
}
await loadAutomations();
} catch (err) { showToast('Error: ' + err.message, 'error'); }
}
function _showDeleteGroupDialog(groupName, count) {
return new Promise((resolve) => {
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.style.display = 'flex';
overlay.onclick = (e) => { if (e.target === overlay) { overlay.remove(); resolve(null); } };
overlay.innerHTML = `
<div class="delete-group-dialog">
<div class="delete-group-icon">🗑</div>
<h3 class="delete-group-title">Delete Group "${groupName}"</h3>
<p class="delete-group-message">This group contains ${count} automation${count !== 1 ? 's' : ''}. What would you like to do?</p>
<div class="delete-group-actions">
<button class="delete-group-btn delete-group-keep" id="dg-ungroup">
Keep Automations move to My Automations
</button>
<button class="delete-group-btn delete-group-remove" id="dg-delete">
Delete Everything remove group and all ${count} automation${count !== 1 ? 's' : ''}
</button>
<button class="delete-group-btn delete-group-cancel" id="dg-cancel">
Cancel
</button>
</div>
</div>
`;
overlay.querySelector('#dg-ungroup').onclick = () => { overlay.remove(); resolve('ungroup'); };
overlay.querySelector('#dg-delete').onclick = () => { overlay.remove(); resolve('delete_all'); };
overlay.querySelector('#dg-cancel').onclick = () => { overlay.remove(); resolve(null); };
document.addEventListener('keydown', function esc(e) {
if (e.key === 'Escape') { overlay.remove(); resolve(null); document.removeEventListener('keydown', esc); }
});
document.body.appendChild(overlay);
});
}
/**
* Rename a group inline edit on the section header label.
*/
function _startRenameGroup(groupName, btnEl) {
const section = btnEl.closest('.automations-section');
const labelEl = section?.querySelector('.section-label');
if (!labelEl) return;
const input = document.createElement('input');
input.className = 'section-rename-input';
input.value = groupName;
input.onclick = (e) => e.stopPropagation();
const originalText = labelEl.textContent;
labelEl.textContent = '';
labelEl.appendChild(input);
input.focus();
input.select();
const finish = async (save) => {
const newName = input.value.trim();
input.removeEventListener('blur', blurHandler);
if (!save || !newName || newName === groupName) {
labelEl.textContent = originalText;
return;
}
const ids = [];
section.querySelectorAll('.automation-card').forEach(card => {
if (card.dataset.id) ids.push(parseInt(card.dataset.id));
});
try {
const res = await fetch('/api/automations/group', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ automation_ids: ids, group_name: newName })
});
const data = await res.json();
if (data.error) throw new Error(data.error);
showToast(`Renamed to "${newName}"`, 'success');
await loadAutomations();
} catch (err) {
showToast('Error: ' + err.message, 'error');
labelEl.textContent = originalText;
}
};
input.addEventListener('keydown', (e) => {
e.stopPropagation();
if (e.key === 'Enter') { e.preventDefault(); finish(true); }
if (e.key === 'Escape') { finish(false); }
});
const blurHandler = () => finish(true);
input.addEventListener('blur', blurHandler);
}
/**
* Bulk toggle all automations in a group.
*/
async function _bulkToggleGroup(groupName, currentlyAllEnabled) {
const ids = [];
document.querySelectorAll(`.automations-section[data-group-name="${groupName}"] .automation-card`).forEach(card => {
if (card.dataset.id) ids.push(parseInt(card.dataset.id));
});
if (ids.length === 0) return;
const targetEnabled = !currentlyAllEnabled;
try {
const res = await fetch('/api/automations/bulk-toggle', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ automation_ids: ids, enabled: targetEnabled })
});
const data = await res.json();
if (data.error) throw new Error(data.error);
showToast(`${targetEnabled ? 'Enabled' : 'Disabled'} ${data.updated} automations`, 'success');
await loadAutomations();
} catch (err) { showToast('Error: ' + err.message, 'error'); }
}
async function loadAutomations() {
const list = document.getElementById('automations-list');
const empty = document.getElementById('automations-empty');
@ -68945,7 +69193,7 @@ async function loadAutomations() {
const userAutos = automations.filter(a => !a.is_system);
if (systemAutos.length) {
list.appendChild(_buildAutomationSection('auto-section-system', 'System', systemAutos, true));
list.appendChild(_buildAutomationSection('auto-section-system', 'System', systemAutos, true, { isProtected: true }));
}
// Automation Hub section
@ -68957,7 +69205,7 @@ async function loadAutomations() {
groups.forEach(g => {
const groupAutos = userAutos.filter(a => a.group_name === g);
if (groupAutos.length) {
list.appendChild(_buildAutomationSection('auto-section-group-' + g.replace(/\W+/g, '_'), '\uD83D\uDCC1 ' + g, groupAutos, true));
list.appendChild(_buildAutomationSection('auto-section-group-' + g.replace(/\W+/g, '_'), '\uD83D\uDCC1 ' + g, groupAutos, true, { groupName: g }));
}
});
if (ungrouped.length) {
@ -69705,6 +69953,27 @@ function renderAutomationCard(a) {
card.dataset.id = a.id;
card.dataset.triggerType = a.trigger_type || '';
card.dataset.actionType = a.action_type || '';
// Drag-and-drop (non-system only)
if (!a.is_system) {
card.draggable = true;
card.addEventListener('dragstart', (e) => {
_autoDragState = { id: a.id, groupName: a.group_name || null };
e.dataTransfer.setData('text/plain', String(a.id));
e.dataTransfer.effectAllowed = 'move';
card.classList.add('dragging');
// Dim protected sections during drag
document.querySelectorAll('.section-protected').forEach(s => s.classList.add('no-drop'));
});
card.addEventListener('dragend', () => {
card.classList.remove('dragging');
_autoDragState = null;
_autoDragEnterCount = 0;
document.querySelectorAll('.drop-target').forEach(el => el.classList.remove('drop-target'));
document.querySelectorAll('.no-drop').forEach(el => el.classList.remove('no-drop'));
if (_autoDragExpandTimer) { clearTimeout(_autoDragExpandTimer); _autoDragExpandTimer = null; }
});
}
const tIcon = _autoIcons[a.trigger_type] || '\u2699\uFE0F';
const aIcon = _autoIcons[a.action_type] || '\u2699\uFE0F';
const tl = tIcon + ' ' + _autoFormatTrigger(a.trigger_type, a.trigger_config);

View file

@ -41129,6 +41129,172 @@ body.downloads-disabled [onclick*="DownloadMissing"]:not([onclick*="close"]) {
opacity: 0;
}
/* --- Group header actions --- */
.section-actions {
display: flex;
gap: 4px;
align-items: center;
opacity: 0;
transition: opacity 0.2s;
margin-left: 4px;
}
.automations-section-header:hover .section-actions {
opacity: 1;
}
.section-action-btn {
width: 26px;
height: 26px;
border: none;
background: rgba(255, 255, 255, 0.05);
border-radius: 6px;
cursor: pointer;
font-size: 12px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
color: rgba(255, 255, 255, 0.5);
}
.section-action-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
.section-action-danger:hover {
background: rgba(239, 68, 68, 0.15);
color: #f87171;
}
.section-rename-input {
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(var(--accent-rgb), 0.4);
border-radius: 4px;
color: #fff;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 1px;
padding: 2px 8px;
width: 180px;
outline: none;
}
.section-rename-input:focus {
border-color: rgba(var(--accent-rgb), 0.6);
box-shadow: 0 0 8px rgba(var(--accent-rgb), 0.15);
}
/* --- Drag and drop --- */
.automation-card[draggable="true"] {
cursor: grab;
}
.automation-card[draggable="true"]:active {
cursor: grabbing;
}
.automation-card.dragging {
opacity: 0.35;
transform: scale(0.97);
box-shadow: none;
}
.automations-section-body.drop-target {
background: rgba(var(--accent-rgb), 0.04);
border: 2px dashed rgba(var(--accent-rgb), 0.25);
border-radius: 10px;
min-height: 50px;
transition: background 0.2s, border-color 0.2s;
}
.section-protected.no-drop {
opacity: 0.4;
pointer-events: none;
}
/* --- Delete group dialog --- */
.delete-group-dialog {
background: linear-gradient(135deg, rgba(26, 26, 26, 0.98) 0%, rgba(16, 16, 16, 0.99) 100%);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 16px;
padding: 28px 24px;
max-width: 420px;
width: 90vw;
text-align: center;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5), inset 0 1px 0 rgba(255, 255, 255, 0.08);
}
.delete-group-icon {
font-size: 32px;
margin-bottom: 8px;
}
.delete-group-title {
font-size: 17px;
font-weight: 700;
color: #fff;
margin: 0 0 8px 0;
}
.delete-group-message {
font-size: 13px;
color: rgba(255, 255, 255, 0.5);
margin: 0 0 18px 0;
line-height: 1.5;
}
.delete-group-actions {
display: flex;
flex-direction: column;
gap: 8px;
}
.delete-group-btn {
padding: 10px 16px;
border-radius: 8px;
border: 1px solid transparent;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.delete-group-keep {
background: rgba(var(--accent-rgb), 0.12);
color: rgb(var(--accent-light-rgb));
border-color: rgba(var(--accent-rgb), 0.25);
}
.delete-group-keep:hover {
background: rgba(var(--accent-rgb), 0.2);
border-color: rgba(var(--accent-rgb), 0.4);
}
.delete-group-remove {
background: rgba(239, 68, 68, 0.12);
color: #f87171;
border-color: rgba(239, 68, 68, 0.25);
}
.delete-group-remove:hover {
background: rgba(239, 68, 68, 0.2);
border-color: rgba(239, 68, 68, 0.4);
}
.delete-group-cancel {
background: rgba(255, 255, 255, 0.04);
color: rgba(255, 255, 255, 0.5);
border-color: rgba(255, 255, 255, 0.06);
}
.delete-group-cancel:hover {
background: rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.7);
}
/* --- System Automations 2-Column Grid --- */
.automations-grid {
display: grid;