Cloud Connect: clickable rows with shared modal (Inbox-style UX)

- Remove per-row Link/Create Job button and inline modals
- Make unmatched rows clickable to open a single shared modal
- Create new job tab: customer via datalist (auto-complete),
  job name and backup type shown read-only (derived from user/section)
- Route: derive job_name and backup_type server-side, not from form

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ivo Oskamp 2026-03-20 08:58:18 +01:00
parent 4eac589625
commit e3c7a5ddac
3 changed files with 167 additions and 96 deletions

View File

@ -117,8 +117,8 @@ def cloud_connect_account_link(cc_account_db_id: int):
customer = Customer.query.get_or_404(customer_id)
job_name = (request.form.get("job_name") or acc.user).strip()
backup_type = (request.form.get("backup_type") or acc.derived_backup_type).strip()
job_name = acc.user.strip()
backup_type = "Cloud Connect Agent" if acc.section == "Agent" else "Cloud Connect Backup"
job = Job(
customer_id=customer.id,

View File

@ -10,7 +10,7 @@
{# ── Unmatched accounts ─────────────────────────────────────────────────── #}
{% if unmatched %}
<h4 class="mb-2">Unmatched <span class="badge bg-warning text-dark">{{ unmatched|length }}</span></h4>
<p class="text-muted small mb-3">These accounts have no linked job yet. Create a new job or link to an existing one.</p>
<p class="text-muted small mb-3">Click a row to create a new job or link to an existing one.</p>
<div class="table-responsive mb-4">
<table class="table table-sm table-hover align-middle">
@ -24,12 +24,16 @@
<th>Last active</th>
<th>Status</th>
<th>First seen</th>
<th></th>
</tr>
</thead>
<tbody>
{% for acc in unmatched %}
<tr>
<tr class="cc-unmatched-row"
style="cursor: pointer;"
data-id="{{ acc.id }}"
data-user="{{ acc.user | e }}"
data-section="{{ acc.section | e }}"
data-backup-type="{{ acc.derived_backup_type | e }}">
<td class="fw-semibold">{{ acc.user }}</td>
<td><span class="badge bg-secondary">{{ acc.section }}</span></td>
<td class="text-muted small">{{ acc.repo_name or '—' }}<br><span class="text-muted" style="font-size:11px;">{{ acc.repo_type or '' }}</span></td>
@ -46,97 +50,7 @@
{% endif %}
</td>
<td class="text-muted small">{{ acc.first_seen_at|local_datetime }}</td>
<td>
<button class="btn btn-sm btn-primary"
data-bs-toggle="modal"
data-bs-target="#link-modal-{{ acc.id }}">
Link / Create job
</button>
</td>
</tr>
{# Link modal #}
<div class="modal fade" id="link-modal-{{ acc.id }}" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Link: {{ acc.user }} ({{ acc.section }})</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<ul class="nav nav-tabs mb-3" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" data-bs-toggle="tab"
data-bs-target="#create-{{ acc.id }}" type="button">
Create new job
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab"
data-bs-target="#existing-{{ acc.id }}" type="button">
Link to existing job
</button>
</li>
</ul>
<div class="tab-content">
{# Tab 1: Create new job #}
<div class="tab-pane fade show active" id="create-{{ acc.id }}">
<form method="post" action="{{ url_for('main.cloud_connect_account_link', cc_account_db_id=acc.id) }}">
<input type="hidden" name="action" value="create" />
<div class="mb-3">
<label class="form-label">Customer <span class="text-danger">*</span></label>
<select class="form-select" name="customer_id" required>
<option value="">Select customer…</option>
{% for c in customers %}
<option value="{{ c.id }}">{{ c.name }}</option>
{% endfor %}
</select>
</div>
<div class="mb-3">
<label class="form-label">Job name</label>
<input type="text" class="form-control" name="job_name" value="{{ acc.user }}" />
<div class="form-text">Defaults to the user name from the report.</div>
</div>
<div class="mb-3">
<label class="form-label">Backup type</label>
<input type="text" class="form-control" name="backup_type"
value="{{ acc.derived_backup_type }}" />
<div class="form-text">Cloud Connect Backup or Cloud Connect Agent.</div>
</div>
<div class="d-flex justify-content-end gap-2">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Create job &amp; link</button>
</div>
</form>
</div>
{# Tab 2: Link to existing job #}
<div class="tab-pane fade" id="existing-{{ acc.id }}">
<form method="post" action="{{ url_for('main.cloud_connect_account_link', cc_account_db_id=acc.id) }}">
<input type="hidden" name="action" value="link" />
<div class="mb-3">
<label class="form-label">Job <span class="text-danger">*</span></label>
<select class="form-select" name="job_id" required>
<option value="">Select job…</option>
{% for j in jobs %}
<option value="{{ j.id }}">
{{ j.customer.name ~ ' ' if j.customer else '' }}{{ j.backup_software }} / {{ j.job_name }}
</option>
{% endfor %}
</select>
</div>
<div class="d-flex justify-content-end gap-2">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Link to job</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
{% endfor %}
</tbody>
</table>
@ -193,7 +107,7 @@
<td>
<form method="post"
action="{{ url_for('main.cloud_connect_account_unlink', cc_account_db_id=acc.id) }}"
onsubmit="return confirm('Remove link for {{ acc.user }} ({{ acc.section }})?');"
onsubmit="return confirm('Remove link for {{ acc.user | e }} ({{ acc.section | e }})?');"
class="mb-0">
<button type="submit" class="btn btn-sm btn-outline-secondary">Unlink</button>
</form>
@ -211,4 +125,152 @@
</div>
{% endif %}
{# ── Shared link/create modal ───────────────────────────────────────────── #}
<div class="modal fade" id="ccLinkModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="ccLinkModalTitle">Link account</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<ul class="nav nav-tabs mb-3" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" data-bs-toggle="tab"
data-bs-target="#ccTabCreate" type="button">
Create new job
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab"
data-bs-target="#ccTabExisting" type="button">
Link to existing job
</button>
</li>
</ul>
<div class="tab-content">
{# Tab 1: Create new job #}
<div class="tab-pane fade show active" id="ccTabCreate">
<form id="ccCreateForm" method="post" action="">
<input type="hidden" name="action" value="create" />
<input type="hidden" id="ccCustomerId" name="customer_id" />
<div class="mb-3">
<label class="form-label">Customer <span class="text-danger">*</span></label>
<input type="text" id="ccCustomerInput" class="form-control"
list="ccCustomerList" placeholder="Select customer…"
autocomplete="off" />
<datalist id="ccCustomerList">
{% for c in customers %}
<option value="{{ c.name }}"></option>
{% endfor %}
</datalist>
</div>
<dl class="row mb-3">
<dt class="col-5">Backup software</dt>
<dd class="col-7">Veeam</dd>
<dt class="col-5">Backup type</dt>
<dd class="col-7" id="ccDisplayBackupType"></dd>
<dt class="col-5">Job name</dt>
<dd class="col-7" id="ccDisplayJobName"></dd>
</dl>
<div class="d-flex justify-content-end gap-2">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Create job &amp; link</button>
</div>
</form>
</div>
{# Tab 2: Link to existing job #}
<div class="tab-pane fade" id="ccTabExisting">
<form id="ccLinkExistingForm" method="post" action="">
<input type="hidden" name="action" value="link" />
<div class="mb-3">
<label class="form-label">Job <span class="text-danger">*</span></label>
<select class="form-select" name="job_id" required>
<option value="">Select job…</option>
{% for j in jobs %}
<option value="{{ j.id }}">
{{ j.customer.name ~ ' ' if j.customer else '' }}{{ j.backup_software }} / {{ j.job_name }}
</option>
{% endfor %}
</select>
</div>
<div class="d-flex justify-content-end gap-2">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Link to job</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
(function () {
var customers = {{ customers | tojson | safe }};
function findCustomerIdByName(name) {
for (var i = 0; i < customers.length; i++) {
if (customers[i].name === name) return customers[i].id;
}
return null;
}
function attachHandlers() {
var rows = document.querySelectorAll('.cc-unmatched-row');
var modalEl = document.getElementById('ccLinkModal');
if (!modalEl) return;
var modal = new bootstrap.Modal(modalEl);
var linkUrlTpl = "{{ url_for('main.cloud_connect_account_link', cc_account_db_id=0) }}";
rows.forEach(function (row) {
row.addEventListener('click', function () {
var id = row.getAttribute('data-id');
var user = row.getAttribute('data-user');
var section = row.getAttribute('data-section');
var backupType = row.getAttribute('data-backup-type');
var linkUrl = linkUrlTpl.replace('0', id);
document.getElementById('ccLinkModalTitle').textContent = user + ' (' + section + ')';
document.getElementById('ccDisplayBackupType').textContent = backupType;
document.getElementById('ccDisplayJobName').textContent = user;
var customerInput = document.getElementById('ccCustomerInput');
var customerIdField = document.getElementById('ccCustomerId');
if (customerInput) customerInput.value = '';
if (customerIdField) customerIdField.value = '';
var createForm = document.getElementById('ccCreateForm');
var linkForm = document.getElementById('ccLinkExistingForm');
if (createForm) {
createForm.action = linkUrl;
createForm.onsubmit = function (ev) {
var cid = findCustomerIdByName(customerInput ? customerInput.value : '');
if (!cid) {
ev.preventDefault();
alert('Please select an existing customer name from the list.');
return false;
}
if (customerIdField) customerIdField.value = String(cid);
};
}
if (linkForm) linkForm.action = linkUrl;
modal.show();
});
});
}
document.addEventListener('DOMContentLoaded', attachHandlers);
})();
</script>
{% endblock %}

View File

@ -2,6 +2,15 @@
This file documents all changes made to this project via Claude Code.
## [2026-03-20]
### Changed
- Cloud Connect Accounts page: replaced per-row "Link / Create Job" button and inline modals with clickable rows and a single shared modal (mirrors Inbox UX):
- Clicking an unmatched row opens a modal pre-filled with the account's user and section
- "Create new job" tab: customer via datalist input (auto-complete); Job name (from User) and Backup type (from Section) shown as read-only — not editable
- "Link to existing job" tab: unchanged
- `routes_cloud_connect.py`: job name and backup type are now derived server-side from `acc.user` and `acc.section` instead of reading from form fields
## [2026-03-19] (2)
### Added