Unification polish: LinkedIn in the grid inline contact editor (v0.1.0:54)
The fundraising grid's per-contact editor now has a LinkedIn URL field next to name, email, title, and location. It threads through the grid contact object and sanitize (which preserves contact-object fields), and _upsert_contact_from_fundraising now reads and persists linkedin_url on both the update and insert paths — so a LinkedIn entered in the grid lands on the linked contact record. Test: test_grid_contact_link.py extended to assert LinkedIn entered in the grid persists to the contact (idempotent). Frontend html.parser clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+7
-4
@@ -787,6 +787,7 @@ def _upsert_contact_from_fundraising(conn, investor_name, contact, actor_user_id
|
||||
state = str(contact.get('state') or '').strip()
|
||||
country = str(contact.get('country') or '').strip()
|
||||
location_query = str(contact.get('location_query') or '').strip()
|
||||
linkedin_url = str(contact.get('linkedin_url') or '').strip()
|
||||
if not full_name and not email:
|
||||
return None
|
||||
first_name, last_name = _split_full_name(full_name)
|
||||
@@ -830,20 +831,21 @@ def _upsert_contact_from_fundraising(conn, investor_name, contact, actor_user_id
|
||||
next_state = state or str(existing['state'] or '')
|
||||
next_country = country or str(existing['country'] or '')
|
||||
next_location_query = location_query or str(existing['location_query'] or '')
|
||||
next_linkedin = linkedin_url or str(existing['linkedin_url'] or '')
|
||||
next_org = org_id or existing['organization_id']
|
||||
conn.execute("""
|
||||
UPDATE contacts
|
||||
SET first_name = ?, last_name = ?, email = ?, title = ?,
|
||||
organization_id = ?, source = ?, contact_type = 'investor', city = ?, state = ?, country = ?, location_query = ?, updated_at = ?
|
||||
organization_id = ?, source = ?, contact_type = 'investor', city = ?, state = ?, country = ?, location_query = ?, linkedin_url = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
""", (next_first, next_last, next_email, next_title, next_org, next_source, next_city, next_state, next_country, next_location_query, now(), existing['id']))
|
||||
""", (next_first, next_last, next_email, next_title, next_org, next_source, next_city, next_state, next_country, next_location_query, next_linkedin, now(), existing['id']))
|
||||
return existing['id']
|
||||
|
||||
contact_id = generate_id()
|
||||
conn.execute("""
|
||||
INSERT INTO contacts (
|
||||
id, first_name, last_name, email, title, organization_id, source, contact_type, status, city, state, country, location_query, created_by, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, 'investor', 'active', ?, ?, ?, ?, ?, ?)
|
||||
id, first_name, last_name, email, title, organization_id, source, contact_type, status, city, state, country, location_query, linkedin_url, created_by, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, 'investor', 'active', ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (
|
||||
contact_id,
|
||||
first_name or 'Unknown',
|
||||
@@ -856,6 +858,7 @@ def _upsert_contact_from_fundraising(conn, investor_name, contact, actor_user_id
|
||||
state,
|
||||
country,
|
||||
location_query,
|
||||
linkedin_url,
|
||||
actor_user_id,
|
||||
now()
|
||||
))
|
||||
|
||||
@@ -44,7 +44,8 @@ def main():
|
||||
],
|
||||
"rows": [
|
||||
{"id": "row-test-1", "investor_name": "Testco Capital",
|
||||
"contacts": [{"name": "Jane Doe", "email": "jane@testco.com", "title": "Partner"}]},
|
||||
"contacts": [{"name": "Jane Doe", "email": "jane@testco.com", "title": "Partner",
|
||||
"linkedin_url": "https://linkedin.com/in/janedoe"}]},
|
||||
],
|
||||
}
|
||||
server.sync_fundraising_relational(conn, server.sanitize_fundraising_grid(grid), [])
|
||||
@@ -54,9 +55,11 @@ def main():
|
||||
check(bool(fc and fc["contact_id"]), f"grid contact_id populated by sync (got {dict(fc) if fc else None})")
|
||||
|
||||
if fc and fc["contact_id"]:
|
||||
ct = conn.execute("SELECT id, email FROM contacts WHERE id=?", (fc["contact_id"],)).fetchone()
|
||||
ct = conn.execute("SELECT id, email, linkedin_url FROM contacts WHERE id=?", (fc["contact_id"],)).fetchone()
|
||||
check(bool(ct and ct["email"] == "jane@testco.com"),
|
||||
f"link points to the correct contacts row (got {dict(ct) if ct else None})")
|
||||
check(bool(ct and ct["linkedin_url"] == "https://linkedin.com/in/janedoe"),
|
||||
f"LinkedIn entered in the grid persists to the contact (got {ct['linkedin_url'] if ct else None})")
|
||||
|
||||
# Re-sync is idempotent: still exactly one linked contact for Jane.
|
||||
server.sync_fundraising_relational(conn, server.sanitize_fundraising_grid(grid), [])
|
||||
|
||||
+15
-4
@@ -6946,9 +6946,9 @@
|
||||
const contacts = Array.isArray(value) ? [...value] : [];
|
||||
const updateContacts = (next) => updateCell(row.id, col.id, next);
|
||||
return (
|
||||
<div style={{ minWidth: '380px', background: '#0b1118', border: '1px solid #263548', borderRadius: '8px', padding: '8px' }}>
|
||||
<div style={{ minWidth: '480px', background: '#0b1118', border: '1px solid #263548', borderRadius: '8px', padding: '8px' }}>
|
||||
{contacts.map((c, idx) => (
|
||||
<div key={idx} style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr 1fr auto', gap: '6px', marginBottom: '6px' }}>
|
||||
<div key={idx} style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr 1fr 1fr auto', gap: '6px', marginBottom: '6px' }}>
|
||||
<input className="text-input" placeholder="Name" value={c.name || ''} onChange={(e) => {
|
||||
const next = [...contacts];
|
||||
next[idx] = { ...next[idx], name: e.target.value };
|
||||
@@ -6982,6 +6982,17 @@
|
||||
moveByKey(rowIndex, colIndex, 'Enter');
|
||||
}
|
||||
}} />
|
||||
<input className="text-input" placeholder="LinkedIn URL" value={c.linkedin_url || ''} onChange={(e) => {
|
||||
const next = [...contacts];
|
||||
next[idx] = { ...next[idx], linkedin_url: e.target.value };
|
||||
updateContacts(next);
|
||||
}} onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
setEditing(null);
|
||||
moveByKey(rowIndex, colIndex, 'Enter');
|
||||
}
|
||||
}} />
|
||||
<input
|
||||
className="text-input"
|
||||
list="location-suggestions"
|
||||
@@ -7012,13 +7023,13 @@
|
||||
const next = contacts.filter((_, i) => i !== idx);
|
||||
updateContacts(next);
|
||||
}}>×</button>
|
||||
<div style={{ gridColumn: '1 / span 5', fontSize: '11px', color: '#8ea2b7' }}>
|
||||
<div style={{ gridColumn: '1 / span 6', fontSize: '11px', color: '#8ea2b7' }}>
|
||||
{c.city || c.state || c.country ? `${c.city || '-'}, ${c.state || '-'}, ${c.country || '-'}` : 'No location set'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<button type="button" className="button-secondary" onClick={() => updateContacts([...contacts, { name: '', email: '', title: '', city: '', state: '', country: '', location_query: '' }])}>+ Contact</button>
|
||||
<button type="button" className="button-secondary" onClick={() => updateContacts([...contacts, { name: '', email: '', title: '', linkedin_url: '', city: '', state: '', country: '', location_query: '' }])}>+ Contact</button>
|
||||
<button type="button" onClick={() => { setEditing(null); moveFocus(rowIndex + 1, colIndex); }}>Done</button>
|
||||
</div>
|
||||
<datalist id="location-suggestions">
|
||||
|
||||
@@ -18,8 +18,9 @@ export const PACKAGE_TITLE = 'Ten31 Database'
|
||||
// * 0.1.0:50 (Set Anthropic API Key UI action — no terminal needed)
|
||||
// * 0.1.0:51 (entity-resolution fix: people double-count + duplicate queue)
|
||||
// * 0.1.0:52 (grid/contacts unification: contact_id link + grid as front door)
|
||||
// * Current: 0.1.0:53 (seed v5 thesis into the Architect Workshop)
|
||||
export const PACKAGE_VERSION = '0.1.0:53'
|
||||
// * 0.1.0:53 (seed v5 thesis into the Architect Workshop)
|
||||
// * Current: 0.1.0:54 (unification polish: LinkedIn in grid inline contact editor)
|
||||
export const PACKAGE_VERSION = '0.1.0:54'
|
||||
|
||||
export const DATA_MOUNT_PATH = '/data'
|
||||
export const WEB_PORT = 8080
|
||||
|
||||
@@ -14,8 +14,9 @@ import { v_0_1_0_50 } from './v0.1.0.50'
|
||||
import { v_0_1_0_51 } from './v0.1.0.51'
|
||||
import { v_0_1_0_52 } from './v0.1.0.52'
|
||||
import { v_0_1_0_53 } from './v0.1.0.53'
|
||||
import { v_0_1_0_54 } from './v0.1.0.54'
|
||||
|
||||
export const versionGraph = VersionGraph.of({
|
||||
current: v_0_1_0_53,
|
||||
other: [v_0_1_0_39, v_0_1_0_40, v_0_1_0_41, v_0_1_0_42, v_0_1_0_43, v_0_1_0_44, v_0_1_0_45, v_0_1_0_46, v_0_1_0_47, v_0_1_0_48, v_0_1_0_49, v_0_1_0_50, v_0_1_0_51, v_0_1_0_52],
|
||||
current: v_0_1_0_54,
|
||||
other: [v_0_1_0_39, v_0_1_0_40, v_0_1_0_41, v_0_1_0_42, v_0_1_0_43, v_0_1_0_44, v_0_1_0_45, v_0_1_0_46, v_0_1_0_47, v_0_1_0_48, v_0_1_0_49, v_0_1_0_50, v_0_1_0_51, v_0_1_0_52, v_0_1_0_53],
|
||||
})
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { VersionInfo } from '@start9labs/start-sdk'
|
||||
|
||||
// Grid/contacts unification polish: richer inline contact entry. The fundraising
|
||||
// grid's per-contact editor now includes a LinkedIn field alongside name, email,
|
||||
// title, and location; it persists to the contact record through the existing
|
||||
// grid→contacts sync. No schema migration.
|
||||
export const v_0_1_0_54 = VersionInfo.of({
|
||||
version: '0.1.0:54',
|
||||
releaseNotes: {
|
||||
en_US: [
|
||||
'The fundraising grid can now capture a LinkedIn URL right in the inline contact',
|
||||
'editor (next to name, email, title, and location), saved to the contact record.',
|
||||
'Richer entry without leaving the grid.',
|
||||
].join(' '),
|
||||
},
|
||||
migrations: { up: async () => {}, down: async () => {} },
|
||||
})
|
||||
Reference in New Issue
Block a user