/// /** * Contacts - Master-detail container for PersonLike items. * * Architecture: Stores charm results (pattern outputs with [UI]), not raw data. * Pattern instantiation happens at insertion time in handlers. * * Features: * - Add/remove Person and FamilyMember contacts * - Master-detail layout with resizable panels * - Groups data model (UI deferred due to framework limitation: * computed() siblings of .map() break reactive rendering) */ import { action, computed, type Default, handler, NAME, navigateTo, pattern, UI, type VNode, Writable, } from "commontools"; // Import shared types import type { ContactGroup, ContactPiece, FamilyMember, Person, } from "./contact-types.tsx"; // Import patterns (they return pieces with [UI]) import PersonPattern from "./person.tsx"; import FamilyMemberPattern from "./family-member.tsx"; // ============================================================================ // Input/Output Schemas // ============================================================================ interface Input { // Store piece results, not raw data contacts: Writable>; groups: Writable>; } interface Output { [NAME]: string; [UI]: VNode; contacts: ContactPiece[]; groups: ContactGroup[]; count: number; } // ============================================================================ // Handlers - Instantiate patterns here, push piece results // ============================================================================ const addPerson = handler< unknown, { contacts: Writable; selectedIndex: Writable; } >((_event, { contacts, selectedIndex }) => { const personData = Writable.of({ firstName: "", lastName: "", middleName: "", nickname: "", prefix: "", suffix: "", pronouns: "", birthday: { month: 0, day: 0, year: 0 }, photo: "", email: "", phone: "", notes: "", tags: [], addresses: [], socialProfiles: [], }); const charm = PersonPattern({ person: personData, sameAs: contacts, }); const newIndex = (contacts.get() || []).length; contacts.push(charm as ContactPiece); selectedIndex.set(newIndex); }); const addFamilyMember = handler< unknown, { contacts: Writable; selectedIndex: Writable; } >((_event, { contacts, selectedIndex }) => { const memberData = Writable.of({ firstName: "", lastName: "", relationship: "", birthday: "", dietaryRestrictions: [], notes: "", tags: [], allergies: [], giftIdeas: [], }); const charm = FamilyMemberPattern({ member: memberData, sameAs: contacts, }); const newIndex = (contacts.get() || []).length; contacts.push(charm as ContactPiece); selectedIndex.set(newIndex); }); const removeContact = handler< unknown, { contacts: Writable; groups: Writable; index: number; selectedIndex: Writable; } >((_event, { contacts, groups, index, selectedIndex }) => { const current = contacts.get() || []; contacts.set(current.toSpliced(index, 1)); // Update group indices: remove references to deleted index, shift higher indices const currentGroups = groups.get() || []; const updatedGroups = currentGroups.map((g) => ({ ...g, contactIndices: (g.contactIndices || []) .filter((i: number) => i !== index) .map((i: number) => (i > index ? i - 1 : i)), })); groups.set(updatedGroups); const sel = selectedIndex.get(); if (sel >= current.length - 1) { selectedIndex.set(Math.max(-1, current.length - 2)); } else if (sel > index) { selectedIndex.set(sel - 1); } }); const selectContact = handler< unknown, { selectedIndex: Writable; index: number } >((_event, { selectedIndex, index }) => { selectedIndex.set(index); }); // Group handlers const addGroup = handler< unknown, { groups: Writable } >((_event, { groups }) => { const current = groups.get() || []; groups.set([...current, { name: "New Group", contactIndices: [] }]); }); // ============================================================================ // Pattern // ============================================================================ export default pattern(({ contacts, groups }) => { const count = computed(() => (contacts.get() || []).length); const selectedIndex = Writable.of(-1); const openInNewView = action(() => { const idx = selectedIndex.get(); if (idx < 0) return; const charm = contacts.key(idx).get(); return navigateTo(charm); }); return { [NAME]: computed(() => `Contacts (${count})`), [UI]: ( Contacts + Person + Family + Group {/* Left: Contact List */} {/* Render contact list using reactive .map() */} {contacts.map((charm, index) => ( selectedIndex.get() === index ? "background: var(--ct-color-blue-50, #eff6ff); border: 1px solid var(--ct-color-blue-300, #93c5fd); cursor: pointer;" : "cursor: pointer;" )} onClick={selectContact({ selectedIndex, index })} > {computed(() => { const name = charm[NAME] || ""; const parts = name.split(" "); if (parts.length >= 2) { return (parts[0].charAt(0) + parts[1].charAt(0)) .toUpperCase(); } return name.charAt(0).toUpperCase() || "?"; })} {charm[NAME]} × ))} {/* Right: Detail View - just render the piece's [UI] */} {computed(() => { const idx = selectedIndex.get(); if (idx < 0 || idx >= (contacts.get() || []).length) { return ( Select a contact to view details ); } const piece = contacts.key(idx); // Piece already has [UI] - just render it with wrapper return ( Open ↗ ); })} ), contacts, groups, count, }; });