Mobile P3b: investor name + contact-pill editing (update-row)
Adds the editable half of BRIEF §3a's mobile grid set: rename an investor and add/edit/remove its contact pills from the mobile detail sheet. New POST /api/fundraising/update-row is the one-row read-fresh-modify-write twin of log-communication: it mutates only the target row's name/contacts in the canonical grid blob server-side, then bumps the version + re-syncs the relational tables. It never accepts a whole-grid payload, so a stale mobile client can't clobber concurrent edits to other rows (the reason mobile avoids the whole-grid PUT). _sanitize_fundraising_contacts whitelists the known pill fields as the trust boundary; removing a pill is soft on the classic contacts directory (only the grid pill + fundraising_contacts row drop). Frontend: MobileFundraisingGrid gains an Edit bottom-sheet (name input + pill editor with client-side dedup); money stays desktop-only. New CSS is theme-var-only so it flips in light mode. Verified: test_fundraising_update_row.py (24 assertions, real HTTP), full suite 37/37, render-smoke + a 375px jsdom interaction harness green.
This commit is contained in:
@@ -107,27 +107,12 @@ Subsystem rules live in `docs/guides/` and lazy-load in Claude Code via `.claude
|
||||
|
||||
## Current state
|
||||
|
||||
_**Box live at v0.1.0:94**; `main` (pushed through `e6a8945`) ahead by mobile Phases 0–6 + drag-reorder views — **all deploy-pending** (no s9pk built yet). **The fundraising grid + email capture is the canonical system of record.** Active thread: **mobile-first redesign** — **all 4 surfaces + the light theme done (P0–P6)**. **Plan (Grant, 2026-06-19): finish features first — P3b name/pill edit → Phase 7 design-conformance pass, in that order next session — then Grant does device testing + deploy** (NOT before; everything is unverified on a real phone). Per-phase detail + backlog: `ROADMAP.md` / `EVALUATION.md`; history: git log + `start9/0.4/startos/versions/`._
|
||||
_**Box live at v0.1.0:94**; `main` ahead by mobile Phases 0–6 + P3b name/pill edit + drag-reorder views — **all deploy-pending** (no s9pk built yet). **The fundraising grid + email capture is the canonical system of record.** Active thread: **mobile-first redesign** — **all 4 surfaces + light theme + P3b (name/pill edit) done; BRIEF §3a editable set complete**. **Plan (Grant, 2026-06-19): finish features first — P3b done → Phase 7 design-conformance pass next — then Grant does device testing + deploy** (NOT before; everything is unverified on a real phone). Per-phase detail + backlog: `ROADMAP.md` / `EVALUATION.md`; history: git log + `start9/0.4/startos/versions/`._
|
||||
|
||||
- **Mobile redesign — all 4 core surfaces built + committed (Grid · Contacts · Pipeline · Reminders).** Each is a rules-of-hooks-safe `useIsMobile()` wrapper → `Mobile*`/`Desktop*` pair (**desktop untouched**), re-authored against the real API on shared primitives `<BottomSheet>`/`useIsMobile()`/`StageChip`/`MobileDetailRow`. Foundation: bottom-tab bar + `:root` mobile vars (P1); 4-stage enum + read-only derived grid signals (`existing_investor`/`last_activity_at`/`staleness`/`opportunity_id`) injected on GET, **stripped on write at both points** (P0/P3a `_computed_row_values` + `stripComputedRows`). **Mobile writes use one-row endpoints only — never whole-grid PUT** (BRIEF §3a). Per-phase detail in `ROADMAP.md`.
|
||||
- **This session — P6 light theme (committed `e6a8945`, frontend-only).** App-wide light theme behind
|
||||
`:root[data-theme="light"]`; **dark stays default** (pre-paint boot script in `<head>` reads
|
||||
`localStorage.venture_crm_theme`; no `prefers-color-scheme`). **App-wide toggle:** labeled control
|
||||
in the desktop sidebar footer + sun/moon icon in the mobile top bar, both off one `theme` state in
|
||||
`App`. Color pairs taken from the **full Claude Design export** (`design/_imports/2026-06-19_zip-file/`,
|
||||
with the previously-missing `store.js` + `*App.dc.html` `DCLogic` palettes) — exact dark+light for
|
||||
every stage/recency/note/priority/reminder/money tint. **Method = zero dark regression by
|
||||
construction:** grew `:root` to 44 themed slots whose **dark values == the originals byte-for-byte**
|
||||
(verified), then migrated **319 hex→`var()`** (script for structural; targeted edits for semantic/
|
||||
chip helpers — `StageChip` now className-based, `PIPELINE_STAGE_CHIP` removed). Mobile surfaces +
|
||||
chrome are fully var-based → **mobile light complete**. **Desktop light rough edges** (bespoke
|
||||
`<style>` shades: login glow/scrollbar/table-hover/KPI green, the legacy off-palette `.badge-*`
|
||||
family, dark-tuned shadows) deferred to the new **Phase 7 conformance pass** (per Grant: conform
|
||||
*everything* — buttons/colors/functionality — to the Claude Design export). Verified: render-smoke
|
||||
green; jsdom interaction harness on the **authed shell** (boot-dark → toggle→light+persist+relabel →
|
||||
toggle→dark, 7/7); var parity + dark-identity + no-undefined-var checks green. **No real-phone check.**
|
||||
- **Prior session (`ee9db64`, no backend change):** **P4 Pipeline** (`MobilePipeline` = CSS scroll-snap swipe-between-stages + segmented control + dots, per-card ‹/› move + tap→detail w/ stage sheet; shares `PATCH /api/opportunities/{id}/stage`; view+advance-only) + **P5 Reminders** (`MobileReminders` = urgency-grouped list over `/api/reminders` + Active/Done/All filter; `ReminderRow` pointer-drag **swipe-left=done / swipe-right=snooze +7d**; tap→create/edit sheet). Verified: render-smoke + jsdom-375px harness (12/12 each); reviewer-passed (notably **P5 `pointercancel` no longer fires a spurious mark-done**). Reusable swipe-test technique noted in memory.
|
||||
- **Mobile redesign — all 4 core surfaces built + committed (Grid · Contacts · Pipeline · Reminders).** Each is a rules-of-hooks-safe `useIsMobile()` wrapper → `Mobile*`/`Desktop*` pair (**desktop untouched**), re-authored against the real API on shared primitives `<BottomSheet>`/`useIsMobile()`/`StageChip`/`MobileDetailRow`. Foundation: bottom-tab bar + `:root` mobile vars (P1); 4-stage enum + read-only derived grid signals (`existing_investor`/`last_activity_at`/`staleness`/`opportunity_id`) injected on GET, **stripped on write at both points** (P0/P3a `_computed_row_values` + `stripComputedRows`). **Mobile writes use one-row endpoints only — never whole-grid PUT** (BRIEF §3a): log-communication, pipeline link/stage, reminders, and now **`POST /api/fundraising/update-row`** (P3b name/pill edit). Per-phase detail in `ROADMAP.md`.
|
||||
- **This session — P3b investor name + contact-pill edit (frontend + backend; not yet committed at write time → see git log).** New **version-safe `POST /api/fundraising/update-row`** (`handle_update_fundraising_row`) — the one-row read-fresh-modify-write twin of `handle_log_fundraising_communication`: reads the canonical grid blob server-side, mutates ONLY the target row's `investor_name`/`contacts`, writes back with a version bump + `sync_fundraising_relational`. Never a whole-grid payload → can't clobber concurrent edits to other rows (BRIEF §3a). Server-side `_sanitize_fundraising_contacts` (9-field whitelist; drops name+email-empty pills) is the trust boundary. Frontend: `MobileFundraisingGrid` detail gains an **Edit** bottom-sheet (investor-name input + pill add/edit/remove of name·email·title, preserving each pill's location/LinkedIn fields, **client-side dedup** by email→name) → saves through update-row → reloads; money stays desktop-only. Pill removal is **soft** on the classic `contacts` directory (only the grid pill + `fundraising_contacts` row drop). New CSS is theme-var-only (`.pill-edit*`/`.sheet-addbtn`/`.fs-detail-edit`). Verified: **`backend/test_fundraising_update_row.py`** (24 assertions over real HTTP — rename/add/edit/remove, other-row-untouched, relational + classic-contacts sync, soft-remove, name-only-pill kept, 5 guards); full suite **37/37**; render-smoke green; throwaway jsdom-375px harness drove the real edit flow (14/14: open→edit→rename→remove→add→save, asserted `POST update-row` + **no whole-grid PUT** + preserved fields). Reviewer-passed (nits were deliberate sibling-pattern parity). **No real-phone check.**
|
||||
- **Prior session (`e6a8945`) — P6 light theme (frontend-only).** App-wide light theme behind `:root[data-theme="light"]` (dark default; pre-paint boot script reads `localStorage.venture_crm_theme`); toggle in desktop sidebar footer + mobile top bar off one `App` `theme` state. 44 themed `:root` slots (dark == originals byte-for-byte), 319 hex→`var()` migrated, `StageChip` className-based. **Mobile light complete; desktop-light rough edges** (bespoke `<style>` shades: login glow/scrollbar/table-hover/KPI green, legacy off-palette `.badge-*`, dark shadows) → **Phase 7**. Verified render-smoke + jsdom toggle harness (7/7). **No real-phone check.** Earlier (`ee9db64`): **P4 Pipeline** + **P5 Reminders** (swipe-done/snooze) — detail in `ROADMAP.md`.
|
||||
- **Live (deployed):** W2 NL query (v94; remaining: in-room smoke + web "Ask" box); W1 reminders (v93); grid Pipeline (v88); Matrix intake + Gmail capture (DWD) + daily digest; Thesis/Architect (dual-approval); outreach — all draft-only.
|
||||
- **Tests:** **36/36 backend green** (`python3 backend/run_tests.py`), `py_compile` clean, render-smoke green, fresh-DB migrate clean.
|
||||
- **Next (next session = finish features, in order):** 1) **P3b** name/pill edit — new version-safe `POST /api/fundraising/update-row` (single-row name/contacts; **never** whole-grid PUT) + a bottom-sheet pill editor (add/edit/remove, client-side dedup) + test; completes BRIEF §3a's editable set. 2) **Phase 7 design-conformance pass** against the full Claude Design export (`design/_imports/2026-06-19_zip-file/`) — buttons/colors/spacing/functionality across all surfaces, incl. finishing the P6 desktop-light rough edges (run `design-checker`). **Then (Grant, after feature-complete):** deploy P0–P7 + view-reorder in one s9pk (**authorize + version-bump first**) and device-test light/dark on a real phone. Later backlog: W2 web Ask box + smoke; W3 bot grid-mutations; W1b nurture-gap.
|
||||
- **Open / risks:** all mobile work + **P6 light theme** **built but never deployed or tested on a real phone/browser** (render-smoke + jsdom only — verify on a device, both themes); **P6 desktop-light has known rough edges** (bespoke `<style>` shades, legacy `.badge-*` family, dark shadows → Phase 7); **P3b deferred**; W2 happy-path only; **Claude/Architect path unverified live on the box**; v2.0 reserve-asset spine **not canonical** (needs dual sign-off); doc drift — `crm-overview.md`/`EVALUATION.md` still call `lp_profiles` live.
|
||||
- **Tests:** **37/37 backend green** (`python3 backend/run_tests.py`; +`test_fundraising_update_row.py`), `py_compile` clean, render-smoke green, fresh-DB migrate clean.
|
||||
- **Next (next session = finish features):** 1) **Phase 7 design-conformance pass** against the full Claude Design export (`design/_imports/2026-06-19_zip-file/`) — buttons/colors/spacing/functionality across all surfaces, incl. finishing the P6 desktop-light rough edges (run `design-checker`). **Then (Grant, after feature-complete):** deploy P0–P6 + P3b + view-reorder in one s9pk (**authorize + version-bump first**) and device-test light/dark on a real phone. Later backlog: W2 web Ask box + smoke; W3 bot grid-mutations; W1b nurture-gap.
|
||||
- **Open / risks:** all mobile work + **P6 light theme + P3b edit** **built but never deployed or tested on a real phone/browser** (render-smoke + jsdom only — verify on a device, both themes); **P6 desktop-light has known rough edges** (bespoke `<style>` shades, legacy `.badge-*` family, dark shadows → Phase 7); W2 happy-path only; **Claude/Architect path unverified live on the box**; v2.0 reserve-asset spine **not canonical** (needs dual sign-off); doc drift — `crm-overview.md`/`EVALUATION.md` still call `lp_profiles` live.
|
||||
|
||||
@@ -1621,6 +1621,32 @@ def sanitize_fundraising_grid(grid):
|
||||
|
||||
return {"columns": clean_columns, "rows": clean_rows}
|
||||
|
||||
# The full set of fields a fundraising contact "pill" carries (what the desktop contacts-cell
|
||||
# editor produces). The mobile pill editor edits name/email/title and carries the rest through
|
||||
# untouched; this whitelist sanitizes an inbound contacts array on the single-row update path.
|
||||
_FUNDRAISING_CONTACT_FIELDS = ('name', 'email', 'title', 'source', 'city', 'state',
|
||||
'country', 'location_query', 'linkedin_url')
|
||||
|
||||
def _sanitize_fundraising_contacts(raw):
|
||||
"""Coerce a client-supplied contacts array to clean pill dicts: keep only the known pill
|
||||
fields, trim strings, and drop entries with neither a name nor an email (the same emptiness
|
||||
rule sync_fundraising_relational uses, so a sanitized list round-trips through sync without
|
||||
surprise drops). Dedup is the client's job (BRIEF §3a); this only guards blanks + stray keys."""
|
||||
out = []
|
||||
if not isinstance(raw, list):
|
||||
return out
|
||||
for c in raw:
|
||||
if not isinstance(c, dict):
|
||||
continue
|
||||
pill = {}
|
||||
for k in _FUNDRAISING_CONTACT_FIELDS:
|
||||
if k in c and c.get(k) is not None:
|
||||
pill[k] = str(c.get(k)).strip()
|
||||
if not pill.get('name') and not pill.get('email'):
|
||||
continue
|
||||
out.append(pill)
|
||||
return out
|
||||
|
||||
# ─── Grid ↔ Pipeline link (Adopt the Pipeline) ────────────────────────────────
|
||||
# The fundraising grid is canonical; the Pipeline board is a view of the deals it
|
||||
# drives. opportunities.fundraising_investor_id is the durable join. Two ownership
|
||||
@@ -2355,6 +2381,8 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
return self.handle_create_feature_request(user, body)
|
||||
if path == '/api/fundraising/log-communication':
|
||||
return self.handle_log_fundraising_communication(user, body)
|
||||
if path == '/api/fundraising/update-row':
|
||||
return self.handle_update_fundraising_row(user, body)
|
||||
if path == '/api/fundraising/pipeline/link':
|
||||
return self.handle_pipeline_link(user, body)
|
||||
if path == '/api/fundraising/pipeline/unlink':
|
||||
@@ -3381,6 +3409,96 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
conn.close()
|
||||
return self.send_json({"data": {"communication": comm, "row": target_row, "version": next_version}}, 201)
|
||||
|
||||
def handle_update_fundraising_row(self, user, body):
|
||||
"""Version-safe single-row edit of an investor's NAME and/or contact pills (P3b).
|
||||
|
||||
The one-row twin of handle_log_fundraising_communication: the canonical grid blob is read
|
||||
FRESH server-side, ONLY the target row's investor_name / contacts are mutated, then the
|
||||
blob is written back with a version bump + relational re-sync. It never accepts a
|
||||
whole-grid payload, so a stale mobile client can't clobber concurrent edits to OTHER rows
|
||||
the way the grid PUT (handle_update_fundraising_state) would (BRIEF §3a). Same-row
|
||||
name/contacts is last-write-wins, exactly like the notes append in log-communication.
|
||||
|
||||
Body: { row_id (required), investor_name?, contacts? } — at least one of investor_name /
|
||||
contacts must be present. `contacts` REPLACES the row's full pill list (the client owns
|
||||
add/edit/remove + dedup). Removing a pill drops it from the row + its fundraising_contacts
|
||||
row on re-sync, but never hard-deletes the classic contacts directory entry
|
||||
(soft-delete-only convention — _upsert_contact_from_fundraising only ever upserts)."""
|
||||
row_id = str(body.get('row_id') or '').strip()
|
||||
if not row_id:
|
||||
return self.send_error_json("row_id is required")
|
||||
|
||||
has_name = 'investor_name' in body
|
||||
has_contacts = 'contacts' in body
|
||||
if not has_name and not has_contacts:
|
||||
return self.send_error_json("investor_name or contacts is required")
|
||||
|
||||
new_name = None
|
||||
if has_name:
|
||||
new_name = str(body.get('investor_name') or '').strip()
|
||||
if not new_name:
|
||||
return self.send_error_json("investor_name cannot be blank")
|
||||
|
||||
new_contacts = None
|
||||
if has_contacts:
|
||||
if not isinstance(body.get('contacts'), list):
|
||||
return self.send_error_json("contacts must be an array")
|
||||
new_contacts = _sanitize_fundraising_contacts(body.get('contacts'))
|
||||
|
||||
conn = get_db()
|
||||
ensure_fundraising_state_row(conn)
|
||||
state = conn.execute("SELECT * FROM fundraising_state WHERE id = 'main'").fetchone()
|
||||
if not state:
|
||||
conn.close()
|
||||
return self.send_error_json("Fundraising state not found", 404)
|
||||
|
||||
try:
|
||||
grid = json.loads(state['grid_json']) if state['grid_json'] else {}
|
||||
except Exception:
|
||||
grid = {}
|
||||
grid = sanitize_fundraising_grid(grid)
|
||||
rows = grid.get('rows', [])
|
||||
if not isinstance(rows, list):
|
||||
rows = []
|
||||
|
||||
target_index = -1
|
||||
target_row = None
|
||||
for idx, row in enumerate(rows):
|
||||
if isinstance(row, dict) and str(row.get('id') or '').strip() == row_id:
|
||||
target_index = idx
|
||||
target_row = row
|
||||
break
|
||||
if target_row is None:
|
||||
conn.close()
|
||||
return self.send_error_json("Fundraising row not found", 404)
|
||||
|
||||
if new_name is not None:
|
||||
target_row['investor_name'] = new_name
|
||||
if new_contacts is not None:
|
||||
target_row['contacts'] = new_contacts
|
||||
rows[target_index] = target_row
|
||||
|
||||
try:
|
||||
views = json.loads(state['views_json']) if state['views_json'] else []
|
||||
except Exception:
|
||||
views = []
|
||||
views = sanitize_grid_views(views)
|
||||
next_version = int(state['version'] or 1) + 1
|
||||
conn.execute("""
|
||||
UPDATE fundraising_state
|
||||
SET grid_json = ?, views_json = ?, version = ?, updated_by = ?, updated_at = ?
|
||||
WHERE id = 'main'
|
||||
""", (json.dumps(grid), json.dumps(views), next_version, user['user_id'], now()))
|
||||
sync_fundraising_relational(conn, grid, views, actor_user_id=user['user_id'])
|
||||
log_audit(conn, user['user_id'], 'fundraising_state', 'main', 'update_row',
|
||||
{"version": next_version, "row_id": row_id})
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
# The returned row lacks the read-only computed columns (pipeline/existing_investor/…),
|
||||
# which are injected only on the GET path — the mobile client reloads after a save.
|
||||
return self.send_json({"data": {"row": target_row, "version": next_version}}, 200)
|
||||
|
||||
def _fetch_opportunity_row(self, conn, opp_id):
|
||||
return row_to_dict(conn.execute("""
|
||||
SELECT op.*, c.first_name, c.last_name, c.email as contact_email,
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Integration test for the version-safe single-row update endpoint (P3b).
|
||||
|
||||
POST /api/fundraising/update-row edits ONE investor row's name and/or contact pills by
|
||||
reading the canonical grid blob fresh server-side and mutating only the target row — never
|
||||
accepting a whole-grid payload (BRIEF §3a), so it can't clobber concurrent edits to other
|
||||
rows. This boots the REAL server against a throwaway DB, seeds a two-row grid, then drives
|
||||
the live HTTP endpoint to assert:
|
||||
* rename + pill add/edit persist into the blob and bump the version;
|
||||
* removing a pill drops it from the row + fundraising_contacts, but the classic contacts
|
||||
directory entry is NOT hard-deleted (soft-delete-only convention);
|
||||
* preserved pill fields (title/city/linkedin) survive an edit that only touches the name;
|
||||
* the OTHER grid row is untouched (no whole-grid clobber);
|
||||
* guards: missing row_id -> 400, unknown row_id -> 404, blank name -> 400,
|
||||
no-op body (neither field) -> 400.
|
||||
Synthetic data only (guardrail #9).
|
||||
|
||||
Run: cd backend && python3 test_fundraising_update_row.py
|
||||
"""
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
from http.server import ThreadingHTTPServer
|
||||
|
||||
_DATA = tempfile.mkdtemp()
|
||||
os.environ["CRM_DATA_DIR"] = _DATA
|
||||
os.environ["CRM_DB_PATH"] = os.path.join(_DATA, "crm.db")
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import server # noqa: E402
|
||||
|
||||
FAILS = []
|
||||
|
||||
|
||||
def check(cond, msg):
|
||||
print((" PASS " if cond else " FAIL ") + msg)
|
||||
if not cond:
|
||||
FAILS.append(msg)
|
||||
|
||||
|
||||
class _Quiet(server.CRMHandler):
|
||||
def log_message(self, *a):
|
||||
pass
|
||||
|
||||
|
||||
def _post(port, path, token, payload):
|
||||
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=10)
|
||||
conn.request("POST", path, body=json.dumps(payload),
|
||||
headers={"Authorization": "Bearer " + token, "Content-Type": "application/json"})
|
||||
resp = conn.getresponse()
|
||||
raw = resp.read().decode("utf-8", "replace")
|
||||
conn.close()
|
||||
data = json.loads(raw) if raw else None
|
||||
return resp.status, data
|
||||
|
||||
|
||||
GRID = {
|
||||
"columns": [
|
||||
{"id": "investor_name", "label": "Investor Name", "type": "text"},
|
||||
{"id": "contacts", "label": "Contacts", "type": "contacts"},
|
||||
{"id": "notes", "label": "Notes", "type": "text"},
|
||||
],
|
||||
"rows": [
|
||||
{"id": "row-1", "investor_name": "Acme Capital", "notes": "",
|
||||
"contacts": [
|
||||
{"name": "Jane Doe", "email": "jane@acme.com", "title": "Partner",
|
||||
"city": "Austin", "state": "TX", "country": "USA", "location_query": "Austin",
|
||||
"linkedin_url": "https://linkedin.com/in/janedoe"},
|
||||
{"name": "Bob Roe", "email": "bob@acme.com", "title": ""},
|
||||
]},
|
||||
{"id": "row-2", "investor_name": "Beacon Fund", "notes": "untouched",
|
||||
"contacts": [{"name": "Carl Vane", "email": "carl@beacon.com"}]},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def seed():
|
||||
c = sqlite3.connect(os.environ["CRM_DB_PATH"])
|
||||
c.row_factory = sqlite3.Row
|
||||
c.execute("INSERT INTO users (id,username,email,password_hash,full_name,role,is_active) "
|
||||
"VALUES ('u1','grant','grant@ten31.example','x','Grant','admin',1)")
|
||||
c.execute("INSERT INTO fundraising_state (id, grid_json, views_json, version, updated_by) "
|
||||
"VALUES ('main', ?, '[]', 1, 'u1') "
|
||||
"ON CONFLICT(id) DO UPDATE SET grid_json=excluded.grid_json, views_json='[]', version=1",
|
||||
(json.dumps(GRID),))
|
||||
server.sync_fundraising_relational(c, server.sanitize_fundraising_grid(GRID), [], actor_user_id="u1")
|
||||
c.commit()
|
||||
c.close()
|
||||
|
||||
|
||||
def blob_rows():
|
||||
c = sqlite3.connect(os.environ["CRM_DB_PATH"])
|
||||
c.row_factory = sqlite3.Row
|
||||
row = c.execute("SELECT grid_json, version FROM fundraising_state WHERE id='main'").fetchone()
|
||||
c.close()
|
||||
grid = json.loads(row["grid_json"])
|
||||
by_id = {r["id"]: r for r in grid["rows"]}
|
||||
return by_id, int(row["version"])
|
||||
|
||||
|
||||
def main():
|
||||
server.init_db()
|
||||
seed()
|
||||
token = server.create_token("u1", "grant", "admin")
|
||||
|
||||
httpd = ThreadingHTTPServer(("127.0.0.1", 0), _Quiet)
|
||||
port = httpd.server_address[1]
|
||||
threading.Thread(target=httpd.serve_forever, daemon=True).start()
|
||||
try:
|
||||
# ── rename + add a third contact, edit Bob's title, keep Jane unchanged ──
|
||||
print("\n[rename + pill add/edit]")
|
||||
new_contacts = [
|
||||
# Jane: only her name is "re-sent"; the client preserves her other fields by spread.
|
||||
{"name": "Jane Doe", "email": "jane@acme.com", "title": "Partner",
|
||||
"city": "Austin", "state": "TX", "country": "USA", "location_query": "Austin",
|
||||
"linkedin_url": "https://linkedin.com/in/janedoe"},
|
||||
{"name": "Bob Roe", "email": "bob@acme.com", "title": "Principal"}, # edited title
|
||||
{"name": "Dana Fox", "email": "dana@acme.com", "title": "Analyst"}, # added
|
||||
]
|
||||
st, data = _post(port, "/api/fundraising/update-row", token,
|
||||
{"row_id": "row-1", "investor_name": "Acme Capital LLC", "contacts": new_contacts})
|
||||
check(st == 200, f"update-row -> 200 (got {st})")
|
||||
by_id, version = blob_rows()
|
||||
check(version == 2, f"version bumped 1 -> 2 (got {version})")
|
||||
r1 = by_id.get("row-1", {})
|
||||
check(r1.get("investor_name") == "Acme Capital LLC", f"name renamed in blob (got {r1.get('investor_name')!r})")
|
||||
names = [c.get("name") for c in r1.get("contacts", [])]
|
||||
check(names == ["Jane Doe", "Bob Roe", "Dana Fox"], f"three pills in order (got {names})")
|
||||
bob = next((c for c in r1["contacts"] if c["name"] == "Bob Roe"), {})
|
||||
check(bob.get("title") == "Principal", f"Bob's title edited (got {bob.get('title')!r})")
|
||||
jane = next((c for c in r1["contacts"] if c["name"] == "Jane Doe"), {})
|
||||
check(jane.get("linkedin_url") == "https://linkedin.com/in/janedoe" and jane.get("city") == "Austin",
|
||||
f"Jane's preserved fields survived (got {jane})")
|
||||
|
||||
# ── the OTHER row is byte-for-byte untouched (no whole-grid clobber) ──
|
||||
print("\n[other row untouched]")
|
||||
r2 = by_id.get("row-2", {})
|
||||
check(r2.get("investor_name") == "Beacon Fund" and r2.get("notes") == "untouched"
|
||||
and [c.get("name") for c in r2.get("contacts", [])] == ["Carl Vane"],
|
||||
f"row-2 unchanged (got {r2})")
|
||||
|
||||
# ── relational sync: classic contacts directory now has Dana; Dana also in fundraising_contacts ──
|
||||
print("\n[relational + classic-contacts sync]")
|
||||
c = sqlite3.connect(os.environ["CRM_DB_PATH"])
|
||||
c.row_factory = sqlite3.Row
|
||||
dana = c.execute("SELECT id, deleted_at FROM contacts WHERE lower(email)='dana@acme.com'").fetchone()
|
||||
check(bool(dana), "added contact Dana propagated to classic contacts directory")
|
||||
inv = c.execute("SELECT id FROM fundraising_investors WHERE source_row_id='row-1'").fetchone()
|
||||
fc_names = {r["full_name"] for r in c.execute(
|
||||
"SELECT full_name FROM fundraising_contacts WHERE investor_id=?", (inv["id"],)).fetchall()}
|
||||
check(fc_names == {"Jane Doe", "Bob Roe", "Dana Fox"}, f"fundraising_contacts mirrors the three pills (got {fc_names})")
|
||||
c.close()
|
||||
|
||||
# ── remove Bob: pill drops + fundraising_contacts drops, but classic contact NOT hard-deleted ──
|
||||
print("\n[remove pill is soft on the classic directory]")
|
||||
st, _ = _post(port, "/api/fundraising/update-row", token, {"row_id": "row-1", "contacts": [
|
||||
{"name": "Jane Doe", "email": "jane@acme.com", "title": "Partner"},
|
||||
{"name": "Dana Fox", "email": "dana@acme.com", "title": "Analyst"},
|
||||
]})
|
||||
check(st == 200, f"remove-pill update -> 200 (got {st})")
|
||||
by_id, version = blob_rows()
|
||||
names = [c.get("name") for c in by_id["row-1"].get("contacts", [])]
|
||||
check(names == ["Jane Doe", "Dana Fox"], f"Bob removed from the row (got {names})")
|
||||
check(version == 3, f"version bumped 2 -> 3 (got {version})")
|
||||
c = sqlite3.connect(os.environ["CRM_DB_PATH"])
|
||||
c.row_factory = sqlite3.Row
|
||||
inv = c.execute("SELECT id FROM fundraising_investors WHERE source_row_id='row-1'").fetchone()
|
||||
fc_names = {r["full_name"] for r in c.execute(
|
||||
"SELECT full_name FROM fundraising_contacts WHERE investor_id=?", (inv["id"],)).fetchall()}
|
||||
check("Bob Roe" not in fc_names, f"Bob dropped from fundraising_contacts (got {fc_names})")
|
||||
bob_classic = c.execute("SELECT id FROM contacts WHERE lower(email)='bob@acme.com'").fetchone()
|
||||
check(bool(bob_classic), "removing a pill does NOT hard-delete the classic contacts row (soft-delete only)")
|
||||
c.close()
|
||||
|
||||
# ── name-only update (no contacts key) leaves the pill list intact ──
|
||||
print("\n[name-only update preserves contacts]")
|
||||
st, _ = _post(port, "/api/fundraising/update-row", token, {"row_id": "row-1", "investor_name": "Acme Capital"})
|
||||
check(st == 200, f"name-only update -> 200 (got {st})")
|
||||
by_id, _ = blob_rows()
|
||||
names = [c.get("name") for c in by_id["row-1"].get("contacts", [])]
|
||||
check(by_id["row-1"].get("investor_name") == "Acme Capital", "name updated again")
|
||||
check(names == ["Jane Doe", "Dana Fox"], f"contacts untouched by a name-only edit (got {names})")
|
||||
|
||||
# ── a name-only pill (no email) is KEPT; a fully-blank pill is dropped ──
|
||||
# (locks _sanitize_fundraising_contacts's emptiness rule = name OR email, not AND.)
|
||||
print("\n[name-only pill kept, blank pill dropped]")
|
||||
st, _ = _post(port, "/api/fundraising/update-row", token, {"row_id": "row-1", "contacts": [
|
||||
{"name": "Jane Doe", "email": "jane@acme.com"},
|
||||
{"name": "Erin Pope", "email": ""}, # name only -> kept
|
||||
{"name": "", "email": ""}, # fully blank -> dropped
|
||||
{"name": " ", "title": "Ghost"}, # whitespace-only name, no email -> dropped
|
||||
]})
|
||||
check(st == 200, f"name-only-pill update -> 200 (got {st})")
|
||||
by_id, _ = blob_rows()
|
||||
names = [c.get("name") for c in by_id["row-1"].get("contacts", [])]
|
||||
check(names == ["Jane Doe", "Erin Pope"],
|
||||
f"name-only pill kept, blank/whitespace pills dropped (got {names})")
|
||||
|
||||
# ── guards ──
|
||||
print("\n[validation guards]")
|
||||
st, _ = _post(port, "/api/fundraising/update-row", token, {"investor_name": "No Id"})
|
||||
check(st == 400, f"missing row_id -> 400 (got {st})")
|
||||
st, _ = _post(port, "/api/fundraising/update-row", token, {"row_id": "row-nope", "investor_name": "Ghost"})
|
||||
check(st == 404, f"unknown row_id -> 404 (got {st})")
|
||||
st, _ = _post(port, "/api/fundraising/update-row", token, {"row_id": "row-1", "investor_name": " "})
|
||||
check(st == 400, f"blank name -> 400 (got {st})")
|
||||
st, _ = _post(port, "/api/fundraising/update-row", token, {"row_id": "row-1"})
|
||||
check(st == 400, f"no-op body (neither name nor contacts) -> 400 (got {st})")
|
||||
st, _ = _post(port, "/api/fundraising/update-row", token, {"row_id": "row-1", "contacts": "nope"})
|
||||
check(st == 400, f"contacts wrong type -> 400 (got {st})")
|
||||
finally:
|
||||
httpd.shutdown()
|
||||
|
||||
print()
|
||||
if FAILS:
|
||||
print(f"FAILED ({len(FAILS)}):")
|
||||
for f in FAILS:
|
||||
print(f" - {f}")
|
||||
sys.exit(1)
|
||||
print("ALL PASS (fundraising update-row: version-safe single-row name/pill edit)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+84
-5
@@ -2320,6 +2320,16 @@
|
||||
.dedup-box-title { font-size: 12px; color: var(--due-soon, #e0b341); margin-bottom: 4px; }
|
||||
.dedup-match { font-size: 13px; color: var(--text-secondary); padding: 3px 0; }
|
||||
|
||||
/* P3b — mobile contact-pill editor (the 'edit' sheet: investor name + add/edit/remove pills). */
|
||||
.fs-detail-edit { margin-left: auto; background: transparent; border: none; color: var(--accent); font-size: 15px; font-family: inherit; cursor: pointer; padding: 6px 0 6px 8px; }
|
||||
.pill-edit { background: var(--bg-input); border: 1px solid var(--border); border-radius: var(--mobile-control-radius); padding: 10px 12px; margin-bottom: 10px; }
|
||||
.pill-edit-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
|
||||
.pill-edit-label { font-family: 'IBM Plex Mono', monospace; font-size: 11px; font-weight: 600; letter-spacing: 0.04em; color: var(--text-muted); text-transform: uppercase; }
|
||||
.pill-edit-remove { background: transparent; border: none; color: var(--danger-soft); font-size: 22px; line-height: 1; cursor: pointer; padding: 0 4px; }
|
||||
.pill-edit .sheet-input { margin-bottom: 8px; }
|
||||
.pill-edit .sheet-input:last-child { margin-bottom: 0; }
|
||||
.sheet-addbtn { width: 100%; min-height: var(--mobile-touch-target); margin-bottom: 14px; background: transparent; border: 1px dashed var(--border); color: var(--accent-light); border-radius: var(--mobile-control-radius); font-size: 14px; font-family: inherit; cursor: pointer; }
|
||||
|
||||
/* ─── Phase 4 — Pipeline mobile surface (swipe-between-stages) ─────────────────────
|
||||
JS-gated to MobilePipeline; reuses the .fs-detail / .sheet / .stage-chip patterns.
|
||||
Stage segmented control (count-forward) → horizontal scroll-snap stage pages → dots;
|
||||
@@ -9358,12 +9368,12 @@
|
||||
);
|
||||
};
|
||||
|
||||
// Mobile Fundraising Grid (<768px) — P3a of the mobile-first redesign. A lean card list →
|
||||
// Mobile Fundraising Grid (<768px) — P3a/P3b of the mobile-first redesign. A lean card list →
|
||||
// full-screen detail → edit sheets. Reads /api/fundraising/state once; ALL writes go through
|
||||
// the targeted one-row endpoints (log-communication / pipeline link+stage / reminders), NEVER
|
||||
// the whole-grid PUT (BRIEF §3a — that would race the 5-person live grid). Editable here:
|
||||
// create investor, log a note, pipeline stage, set a reminder. Renaming + contact-pill edits
|
||||
// on an existing row are read-only in P3a (need a narrow per-row PATCH — deferred to P3b).
|
||||
// the targeted one-row endpoints (log-communication / update-row / pipeline link+stage /
|
||||
// reminders), NEVER the whole-grid PUT (BRIEF §3a — that would race the 5-person live grid).
|
||||
// Editable here: create investor, edit name + contact pills (P3b, via update-row), log a
|
||||
// note, pipeline stage, set a reminder. Money amounts stay desktop-only (read-only here).
|
||||
const MobileFundraisingGrid = ({ user, token, onShowToast, views, activeView, setActiveView }) => {
|
||||
const [columns, setColumns] = useState([]);
|
||||
const [rows, setRows] = useState([]);
|
||||
@@ -9376,6 +9386,9 @@
|
||||
const [createForm, setCreateForm] = useState({ name: '', contactName: '', contactEmail: '', note: '' });
|
||||
const [noteForm, setNoteForm] = useState({ type: 'note', subject: '', body: '' });
|
||||
const [reminderForm, setReminderForm] = useState({ title: '', due_date: '', details: '' });
|
||||
// P3b edit sheet: investor name + the full contacts array (pills carry their other
|
||||
// fields — title/location/linkedin — through unedited; we only surface name/email/title).
|
||||
const [editForm, setEditForm] = useState({ name: '', contacts: [] });
|
||||
|
||||
const reload = useCallback(async (silent) => {
|
||||
try {
|
||||
@@ -9526,6 +9539,50 @@
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
|
||||
// ── edit (name + contact pills) — version-safe one-row POST, never the whole-grid PUT ──
|
||||
const openEdit = () => {
|
||||
const row = selectedRow; if (!row) return;
|
||||
setEditForm({
|
||||
name: row.investor_name || '',
|
||||
contacts: (Array.isArray(row.contacts) ? row.contacts : []).map((c) => ({ ...c })),
|
||||
});
|
||||
setSheet('edit');
|
||||
};
|
||||
const setPill = (i, patch) => setEditForm((f) => ({
|
||||
...f, contacts: f.contacts.map((c, j) => (j === i ? { ...c, ...patch } : c)),
|
||||
}));
|
||||
const addPill = () => setEditForm((f) => ({ ...f, contacts: [...f.contacts, { name: '', email: '', title: '' }] }));
|
||||
const removePill = (i) => setEditForm((f) => ({ ...f, contacts: f.contacts.filter((_, j) => j !== i) }));
|
||||
|
||||
const submitEdit = async () => {
|
||||
const row = selectedRow; if (!row) return;
|
||||
const name = String(editForm.name || '').trim();
|
||||
if (!name) { onShowToast('Investor name is required', 'error'); return; }
|
||||
// Client-side dedup (BRIEF §3a): drop blank pills, then collapse duplicates by email
|
||||
// (preferred key) else by name — keeping each pill's preserved fields on the survivor.
|
||||
const seen = new Set();
|
||||
const contacts = [];
|
||||
for (const c of editForm.contacts) {
|
||||
const cn = String(c.name || '').trim();
|
||||
const ce = String(c.email || '').trim();
|
||||
if (!cn && !ce) continue;
|
||||
const key = ce ? `e:${ce.toLowerCase()}` : `n:${cn.toLowerCase()}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
contacts.push({ ...c, name: cn, email: ce, title: String(c.title || '').trim() });
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
await api('/api/fundraising/update-row', { method: 'POST', body: JSON.stringify({
|
||||
row_id: row.id, investor_name: name, contacts,
|
||||
}) }, token);
|
||||
onShowToast('Investor updated', 'success');
|
||||
closeSheet();
|
||||
await reload(true);
|
||||
} catch (err) { onShowToast(getErrorMessage(err, 'Failed to update investor'), 'error'); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const renderCard = (row) => {
|
||||
const committed = gridRollup(row, fundColumnIds);
|
||||
const days = daysSince(row.last_activity_at);
|
||||
@@ -9619,6 +9676,7 @@
|
||||
<div className="fs-detail" role="dialog" aria-modal="true">
|
||||
<div className="fs-detail-header">
|
||||
<button className="fs-detail-back" onClick={() => setSelectedId(null)}>‹ Grid</button>
|
||||
<button className="fs-detail-edit" onClick={openEdit}>Edit</button>
|
||||
</div>
|
||||
<div className="fs-detail-body">
|
||||
<div className="fs-detail-id">
|
||||
@@ -9729,6 +9787,27 @@
|
||||
</div>
|
||||
<button className="sheet-submit" onClick={submitReminder} disabled={busy}>{busy ? 'Saving…' : 'Set reminder'}</button>
|
||||
</BottomSheet>
|
||||
|
||||
<BottomSheet open={sheet === 'edit'} onClose={closeSheet} title="Edit investor">
|
||||
<div className="sheet-field">
|
||||
<label className="sheet-field-label">Investor name</label>
|
||||
<input className="sheet-input" value={editForm.name} onChange={(e) => setEditForm((f) => ({ ...f, name: e.target.value }))} placeholder="e.g. Acme Capital" />
|
||||
</div>
|
||||
<label className="sheet-field-label">Contacts</label>
|
||||
{editForm.contacts.map((c, i) => (
|
||||
<div className="pill-edit" key={i}>
|
||||
<div className="pill-edit-head">
|
||||
<span className="pill-edit-label">Contact {i + 1}</span>
|
||||
<button className="pill-edit-remove" type="button" aria-label="Remove contact" onClick={() => removePill(i)}>×</button>
|
||||
</div>
|
||||
<input className="sheet-input" value={c.name || ''} onChange={(e) => setPill(i, { name: e.target.value })} placeholder="Full name" />
|
||||
<input className="sheet-input" type="email" value={c.email || ''} onChange={(e) => setPill(i, { email: e.target.value })} placeholder="name@firm.com" />
|
||||
<input className="sheet-input" value={c.title || ''} onChange={(e) => setPill(i, { title: e.target.value })} placeholder="Title (optional)" />
|
||||
</div>
|
||||
))}
|
||||
<button className="sheet-addbtn" type="button" onClick={addPill}>+ Add contact</button>
|
||||
<button className="sheet-submit" onClick={submitEdit} disabled={busy}>{busy ? 'Saving…' : 'Save changes'}</button>
|
||||
</BottomSheet>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
Reference in New Issue
Block a user