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()
|
state = str(contact.get('state') or '').strip()
|
||||||
country = str(contact.get('country') or '').strip()
|
country = str(contact.get('country') or '').strip()
|
||||||
location_query = str(contact.get('location_query') 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:
|
if not full_name and not email:
|
||||||
return None
|
return None
|
||||||
first_name, last_name = _split_full_name(full_name)
|
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_state = state or str(existing['state'] or '')
|
||||||
next_country = country or str(existing['country'] or '')
|
next_country = country or str(existing['country'] or '')
|
||||||
next_location_query = location_query or str(existing['location_query'] 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']
|
next_org = org_id or existing['organization_id']
|
||||||
conn.execute("""
|
conn.execute("""
|
||||||
UPDATE contacts
|
UPDATE contacts
|
||||||
SET first_name = ?, last_name = ?, email = ?, title = ?,
|
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 = ?
|
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']
|
return existing['id']
|
||||||
|
|
||||||
contact_id = generate_id()
|
contact_id = generate_id()
|
||||||
conn.execute("""
|
conn.execute("""
|
||||||
INSERT INTO contacts (
|
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
|
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', ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, 'investor', 'active', ?, ?, ?, ?, ?, ?, ?)
|
||||||
""", (
|
""", (
|
||||||
contact_id,
|
contact_id,
|
||||||
first_name or 'Unknown',
|
first_name or 'Unknown',
|
||||||
@@ -856,6 +858,7 @@ def _upsert_contact_from_fundraising(conn, investor_name, contact, actor_user_id
|
|||||||
state,
|
state,
|
||||||
country,
|
country,
|
||||||
location_query,
|
location_query,
|
||||||
|
linkedin_url,
|
||||||
actor_user_id,
|
actor_user_id,
|
||||||
now()
|
now()
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -44,7 +44,8 @@ def main():
|
|||||||
],
|
],
|
||||||
"rows": [
|
"rows": [
|
||||||
{"id": "row-test-1", "investor_name": "Testco Capital",
|
{"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), [])
|
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})")
|
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"]:
|
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"),
|
check(bool(ct and ct["email"] == "jane@testco.com"),
|
||||||
f"link points to the correct contacts row (got {dict(ct) if ct else None})")
|
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.
|
# Re-sync is idempotent: still exactly one linked contact for Jane.
|
||||||
server.sync_fundraising_relational(conn, server.sanitize_fundraising_grid(grid), [])
|
server.sync_fundraising_relational(conn, server.sanitize_fundraising_grid(grid), [])
|
||||||
|
|||||||
+15
-4
@@ -6946,9 +6946,9 @@
|
|||||||
const contacts = Array.isArray(value) ? [...value] : [];
|
const contacts = Array.isArray(value) ? [...value] : [];
|
||||||
const updateContacts = (next) => updateCell(row.id, col.id, next);
|
const updateContacts = (next) => updateCell(row.id, col.id, next);
|
||||||
return (
|
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) => (
|
{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) => {
|
<input className="text-input" placeholder="Name" value={c.name || ''} onChange={(e) => {
|
||||||
const next = [...contacts];
|
const next = [...contacts];
|
||||||
next[idx] = { ...next[idx], name: e.target.value };
|
next[idx] = { ...next[idx], name: e.target.value };
|
||||||
@@ -6982,6 +6982,17 @@
|
|||||||
moveByKey(rowIndex, colIndex, 'Enter');
|
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
|
<input
|
||||||
className="text-input"
|
className="text-input"
|
||||||
list="location-suggestions"
|
list="location-suggestions"
|
||||||
@@ -7012,13 +7023,13 @@
|
|||||||
const next = contacts.filter((_, i) => i !== idx);
|
const next = contacts.filter((_, i) => i !== idx);
|
||||||
updateContacts(next);
|
updateContacts(next);
|
||||||
}}>×</button>
|
}}>×</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'}
|
{c.city || c.state || c.country ? `${c.city || '-'}, ${c.state || '-'}, ${c.country || '-'}` : 'No location set'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
<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>
|
<button type="button" onClick={() => { setEditing(null); moveFocus(rowIndex + 1, colIndex); }}>Done</button>
|
||||||
</div>
|
</div>
|
||||||
<datalist id="location-suggestions">
|
<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: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:51 (entity-resolution fix: people double-count + duplicate queue)
|
||||||
// * 0.1.0:52 (grid/contacts unification: contact_id link + grid as front door)
|
// * 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)
|
// * 0.1.0:53 (seed v5 thesis into the Architect Workshop)
|
||||||
export const PACKAGE_VERSION = '0.1.0:53'
|
// * 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 DATA_MOUNT_PATH = '/data'
|
||||||
export const WEB_PORT = 8080
|
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_51 } from './v0.1.0.51'
|
||||||
import { v_0_1_0_52 } from './v0.1.0.52'
|
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_53 } from './v0.1.0.53'
|
||||||
|
import { v_0_1_0_54 } from './v0.1.0.54'
|
||||||
|
|
||||||
export const versionGraph = VersionGraph.of({
|
export const versionGraph = VersionGraph.of({
|
||||||
current: v_0_1_0_53,
|
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],
|
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