feat: add links section to contact create/edit form
Links can now be attached to contacts directly from the create and edit forms with dynamic add/remove rows and role suggestions. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -32,10 +32,14 @@ async def list_contacts(request: Request, db: AsyncSession = Depends(get_db)):
|
|||||||
@router.get("/create")
|
@router.get("/create")
|
||||||
async def create_form(request: Request, db: AsyncSession = Depends(get_db)):
|
async def create_form(request: Request, db: AsyncSession = Depends(get_db)):
|
||||||
sidebar = await get_sidebar_data(db)
|
sidebar = await get_sidebar_data(db)
|
||||||
|
result = await db.execute(text(
|
||||||
|
"SELECT id, label, url FROM links WHERE is_deleted = false ORDER BY label"
|
||||||
|
))
|
||||||
|
all_links = [dict(r._mapping) for r in result]
|
||||||
return templates.TemplateResponse("contact_form.html", {
|
return templates.TemplateResponse("contact_form.html", {
|
||||||
"request": request, "sidebar": sidebar,
|
"request": request, "sidebar": sidebar,
|
||||||
"page_title": "New Contact", "active_nav": "contacts",
|
"page_title": "New Contact", "active_nav": "contacts",
|
||||||
"item": None,
|
"item": None, "all_links": all_links,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -60,8 +64,22 @@ async def create_contact(
|
|||||||
}
|
}
|
||||||
if tags and tags.strip():
|
if tags and tags.strip():
|
||||||
data["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
|
data["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
|
||||||
await repo.create(data)
|
contact = await repo.create(data)
|
||||||
return RedirectResponse(url="/contacts", status_code=303)
|
|
||||||
|
# Process link attachments from form
|
||||||
|
form_data = await request.form()
|
||||||
|
link_ids = form_data.getlist("link_ids")
|
||||||
|
link_roles = form_data.getlist("link_roles")
|
||||||
|
for i, lid in enumerate(link_ids):
|
||||||
|
if lid and lid.strip():
|
||||||
|
lr = link_roles[i] if i < len(link_roles) else None
|
||||||
|
await db.execute(text("""
|
||||||
|
INSERT INTO contact_links (contact_id, link_id, role)
|
||||||
|
VALUES (:cid, :lid, :role) ON CONFLICT DO NOTHING
|
||||||
|
"""), {"cid": contact["id"], "lid": lid, "role": lr if lr and lr.strip() else None})
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return RedirectResponse(url=f"/contacts/{contact['id']}", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{contact_id}")
|
@router.get("/{contact_id}")
|
||||||
@@ -104,16 +122,28 @@ async def edit_form(contact_id: str, request: Request, db: AsyncSession = Depend
|
|||||||
item = await repo.get(contact_id)
|
item = await repo.get(contact_id)
|
||||||
if not item:
|
if not item:
|
||||||
return RedirectResponse(url="/contacts", status_code=303)
|
return RedirectResponse(url="/contacts", status_code=303)
|
||||||
|
result = await db.execute(text(
|
||||||
|
"SELECT id, label, url FROM links WHERE is_deleted = false ORDER BY label"
|
||||||
|
))
|
||||||
|
all_links = [dict(r._mapping) for r in result]
|
||||||
|
# Existing linked links
|
||||||
|
result = await db.execute(text("""
|
||||||
|
SELECT l.id, l.label, l.url, cl.role
|
||||||
|
FROM links l JOIN contact_links cl ON cl.link_id = l.id
|
||||||
|
WHERE cl.contact_id = :cid AND l.is_deleted = false
|
||||||
|
"""), {"cid": contact_id})
|
||||||
|
linked_links = [dict(r._mapping) for r in result]
|
||||||
return templates.TemplateResponse("contact_form.html", {
|
return templates.TemplateResponse("contact_form.html", {
|
||||||
"request": request, "sidebar": sidebar,
|
"request": request, "sidebar": sidebar,
|
||||||
"page_title": "Edit Contact", "active_nav": "contacts",
|
"page_title": "Edit Contact", "active_nav": "contacts",
|
||||||
"item": item,
|
"item": item, "all_links": all_links, "linked_links": linked_links,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{contact_id}/edit")
|
@router.post("/{contact_id}/edit")
|
||||||
async def update_contact(
|
async def update_contact(
|
||||||
contact_id: str,
|
contact_id: str,
|
||||||
|
request: Request,
|
||||||
first_name: str = Form(...),
|
first_name: str = Form(...),
|
||||||
last_name: Optional[str] = Form(None),
|
last_name: Optional[str] = Form(None),
|
||||||
company: Optional[str] = Form(None),
|
company: Optional[str] = Form(None),
|
||||||
@@ -135,6 +165,22 @@ async def update_contact(
|
|||||||
else:
|
else:
|
||||||
data["tags"] = None
|
data["tags"] = None
|
||||||
await repo.update(contact_id, data)
|
await repo.update(contact_id, data)
|
||||||
|
|
||||||
|
# Sync link attachments
|
||||||
|
form_data = await request.form()
|
||||||
|
link_ids = form_data.getlist("link_ids")
|
||||||
|
link_roles = form_data.getlist("link_roles")
|
||||||
|
# Clear existing and re-insert
|
||||||
|
await db.execute(text("DELETE FROM contact_links WHERE contact_id = :cid"), {"cid": contact_id})
|
||||||
|
for i, lid in enumerate(link_ids):
|
||||||
|
if lid and lid.strip():
|
||||||
|
lr = link_roles[i] if i < len(link_roles) else None
|
||||||
|
await db.execute(text("""
|
||||||
|
INSERT INTO contact_links (contact_id, link_id, role)
|
||||||
|
VALUES (:cid, :lid, :role) ON CONFLICT DO NOTHING
|
||||||
|
"""), {"cid": contact_id, "lid": lid, "role": lr if lr and lr.strip() else None})
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
return RedirectResponse(url=f"/contacts/{contact_id}", status_code=303)
|
return RedirectResponse(url=f"/contacts/{contact_id}", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,58 @@
|
|||||||
<div class="form-group"><label class="form-label">Phone</label><input type="tel" name="phone" class="form-input" value="{{ item.phone if item and item.phone else '' }}"></div>
|
<div class="form-group"><label class="form-label">Phone</label><input type="tel" name="phone" class="form-input" value="{{ item.phone if item and item.phone else '' }}"></div>
|
||||||
<div class="form-group full-width"><label class="form-label">Notes</label><textarea name="notes" class="form-textarea" rows="4">{{ item.notes if item and item.notes else '' }}</textarea></div>
|
<div class="form-group full-width"><label class="form-label">Notes</label><textarea name="notes" class="form-textarea" rows="4">{{ item.notes if item and item.notes else '' }}</textarea></div>
|
||||||
<div class="form-group full-width"><label class="form-label">Tags</label><input type="text" name="tags" class="form-input" value="{{ item.tags|join(', ') if item and item.tags else '' }}"></div>
|
<div class="form-group full-width"><label class="form-label">Tags</label><input type="text" name="tags" class="form-input" value="{{ item.tags|join(', ') if item and item.tags else '' }}"></div>
|
||||||
<div class="form-actions"><button type="submit" class="btn btn-primary">{{ 'Save' if item else 'Create' }}</button><a href="/contacts" class="btn btn-secondary">Cancel</a></div>
|
|
||||||
|
<!-- Links -->
|
||||||
|
<div class="form-group full-width">
|
||||||
|
<label class="form-label">Links</label>
|
||||||
|
<div id="contact-links-list">
|
||||||
|
{% if linked_links is defined and linked_links %}
|
||||||
|
{% for ll in linked_links %}
|
||||||
|
<div class="flex gap-2 items-center mb-2 contact-link-row">
|
||||||
|
<select name="link_ids" class="form-select" style="flex:2;">
|
||||||
|
<option value="">Select link...</option>
|
||||||
|
{% for l in all_links %}
|
||||||
|
<option value="{{ l.id }}" {{ 'selected' if l.id|string == ll.id|string }}>{{ l.label }} — {{ l.url[:40] }}{% if l.url|length > 40 %}...{% endif %}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<input type="text" name="link_roles" class="form-input" list="link-roles" placeholder="Role..." value="{{ ll.role or '' }}" style="flex:1;">
|
||||||
|
<button type="button" class="btn btn-ghost btn-xs" style="color:var(--red);" onclick="this.closest('.contact-link-row').remove()">×</button>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-ghost btn-sm" id="add-link-btn">+ Add Link</button>
|
||||||
|
<datalist id="link-roles">
|
||||||
|
<option value="website">
|
||||||
|
<option value="portfolio">
|
||||||
|
<option value="social">
|
||||||
|
<option value="documentation">
|
||||||
|
<option value="reference">
|
||||||
|
<option value="profile">
|
||||||
|
<option value="repository">
|
||||||
|
</datalist>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions"><button type="submit" class="btn btn-primary">{{ 'Save' if item else 'Create' }}</button><a href="{{ '/contacts/' ~ item.id if item else '/contacts' }}" class="btn btn-secondary">Cancel</a></div>
|
||||||
</div>
|
</div>
|
||||||
</form></div>
|
</form></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var linkOpts = '';
|
||||||
|
{% if all_links is defined %}
|
||||||
|
linkOpts = '<option value="">Select link...</option>' +
|
||||||
|
{% for l in all_links %}'<option value="{{ l.id }}">{{ l.label }} — {{ l.url[:40] }}{% if l.url|length > 40 %}...{% endif %}</option>' +{% endfor %}
|
||||||
|
'';
|
||||||
|
{% endif %}
|
||||||
|
document.getElementById('add-link-btn').addEventListener('click', function() {
|
||||||
|
var row = document.createElement('div');
|
||||||
|
row.className = 'flex gap-2 items-center mb-2 contact-link-row';
|
||||||
|
row.innerHTML = '<select name="link_ids" class="form-select" style="flex:2;">' + linkOpts + '</select>' +
|
||||||
|
'<input type="text" name="link_roles" class="form-input" list="link-roles" placeholder="Role..." style="flex:1;">' +
|
||||||
|
'<button type="button" class="btn btn-ghost btn-xs" style="color:var(--red);" onclick="this.closest(\'.contact-link-row\').remove()">×</button>';
|
||||||
|
document.getElementById('contact-links-list').appendChild(row);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user