Skip to main content

max / goingson

Edit contact sub-collections in place Previously emails / phones / social handles / custom fields could only be added and removed; editing required delete + re-add and lost any unrelated metadata. Now each row carries an inline Edit button that opens the same form pre-filled, routed through new update_contact_* Tauri commands. - ContactRepository::update_email / update_phone / update_social_handle / update_custom_field, with SqliteContactRepository impls and a new update_contact_subcollections integration test. - update_contact_* Tauri commands wired into both desktop and mobile builders. - SUB_COLLECTIONS gains updateCommand + prefill + editModalTitle + editButtonText; buildSubCollectionFormHtml(type, cid, editingId?) routes add vs edit through ADD_SUBMIT_FN / EDIT_SUBMIT_FN. - openEditSubCollection / submitEditSubCollection / per-type openEdit*/submitEdit* wrappers; findSubRow looks up the row from GoingsOn.state.contacts instead of smuggling JSON through HTML attrs. - Edit-contact modal rows carry inline Edit + Remove buttons and now display a Primary marker on emails/phones. - Fixes pre-existing reload race: sub-collection add / remove / update now await load() before re-opening the edit modal so the freshly fetched contact is rendered. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-20 23:50 UTC
Commit: 916d094cac1abaafb507ecf86b337e7cc54fe375
Parent: c90e851
8 files changed, +476 insertions, -18 deletions
@@ -926,6 +926,18 @@ pub trait ContactRepository: Send + Sync {
926 926
927 927 /// Removes a custom field from a contact.
928 928 async fn remove_custom_field(&self, field_id: CustomFieldId, user_id: UserId) -> Result<bool>;
929 +
930 + /// Updates a contact email row (address/label/is_primary). Returns the updated row, or `None` if not found.
931 + async fn update_email(&self, email_id: ContactEmailId, user_id: UserId, email: NewContactEmail) -> Result<Option<ContactEmail>>;
932 +
933 + /// Updates a contact phone row (number/label/is_primary). Returns the updated row, or `None` if not found.
934 + async fn update_phone(&self, phone_id: ContactPhoneId, user_id: UserId, phone: NewContactPhone) -> Result<Option<ContactPhone>>;
935 +
936 + /// Updates a social handle row (platform/handle/url). Returns the updated row, or `None` if not found.
937 + async fn update_social_handle(&self, handle_id: SocialHandleId, user_id: UserId, handle: NewSocialHandle) -> Result<Option<SocialHandle>>;
938 +
939 + /// Updates a custom field row (label/value/url). Returns the updated row, or `None` if not found.
940 + async fn update_custom_field(&self, field_id: CustomFieldId, user_id: UserId, field: NewContactCustomField) -> Result<Option<ContactCustomField>>;
929 941 }
930 942
931 943 /// Repository for file attachment operations.
@@ -866,4 +866,160 @@ impl ContactRepository for SqliteContactRepository {
866 866
867 867 Ok(result.rows_affected() > 0)
868 868 }
869 +
870 + #[tracing::instrument(skip_all)]
871 + async fn update_email(&self, email_id: ContactEmailId, user_id: UserId, email: NewContactEmail) -> Result<Option<ContactEmail>> {
872 + let result = sqlx::query(
873 + r#"
874 + UPDATE contact_emails
875 + SET address = ?, label = ?, is_primary = ?
876 + WHERE id = ? AND contact_id IN (SELECT id FROM contacts WHERE user_id = ?)
877 + "#
878 + )
879 + .bind(&email.address)
880 + .bind(&email.label)
881 + .bind(email.is_primary as i32)
882 + .bind(email_id.to_string())
883 + .bind(user_id.to_string())
884 + .execute(&self.pool)
885 + .await
886 + .map_err(CoreError::database)?;
887 +
888 + if result.rows_affected() == 0 {
889 + return Ok(None);
890 + }
891 +
892 + let row = sqlx::query_as::<_, (String, String, String, i32)>(
893 + "SELECT contact_id, address, label, is_primary FROM contact_emails WHERE id = ?"
894 + )
895 + .bind(email_id.to_string())
896 + .fetch_one(&self.pool)
897 + .await
898 + .map_err(CoreError::database)?;
899 +
900 + Ok(Some(ContactEmail {
901 + id: email_id,
902 + contact_id: crate::utils::parse_uuid(&row.0)?.into(),
903 + address: row.1,
904 + label: row.2,
905 + is_primary: row.3 != 0,
906 + }))
907 + }
908 +
909 + #[tracing::instrument(skip_all)]
910 + async fn update_phone(&self, phone_id: ContactPhoneId, user_id: UserId, phone: NewContactPhone) -> Result<Option<ContactPhone>> {
911 + let result = sqlx::query(
912 + r#"
913 + UPDATE contact_phones
914 + SET number = ?, label = ?, is_primary = ?
915 + WHERE id = ? AND contact_id IN (SELECT id FROM contacts WHERE user_id = ?)
916 + "#
917 + )
918 + .bind(&phone.number)
919 + .bind(&phone.label)
920 + .bind(phone.is_primary as i32)
921 + .bind(phone_id.to_string())
922 + .bind(user_id.to_string())
923 + .execute(&self.pool)
924 + .await
925 + .map_err(CoreError::database)?;
926 +
927 + if result.rows_affected() == 0 {
928 + return Ok(None);
929 + }
930 +
931 + let row = sqlx::query_as::<_, (String, String, String, i32)>(
932 + "SELECT contact_id, number, label, is_primary FROM contact_phones WHERE id = ?"
933 + )
934 + .bind(phone_id.to_string())
935 + .fetch_one(&self.pool)
936 + .await
937 + .map_err(CoreError::database)?;
938 +
939 + Ok(Some(ContactPhone {
940 + id: phone_id,
941 + contact_id: crate::utils::parse_uuid(&row.0)?.into(),
942 + number: row.1,
943 + label: row.2,
944 + is_primary: row.3 != 0,
945 + }))
946 + }
947 +
948 + #[tracing::instrument(skip_all)]
949 + async fn update_social_handle(&self, handle_id: SocialHandleId, user_id: UserId, handle: NewSocialHandle) -> Result<Option<SocialHandle>> {
950 + let result = sqlx::query(
951 + r#"
952 + UPDATE contact_social_handles
953 + SET platform = ?, handle = ?, url = ?
954 + WHERE id = ? AND contact_id IN (SELECT id FROM contacts WHERE user_id = ?)
955 + "#
956 + )
957 + .bind(&handle.platform)
958 + .bind(&handle.handle)
959 + .bind(&handle.url)
960 + .bind(handle_id.to_string())
961 + .bind(user_id.to_string())
962 + .execute(&self.pool)
963 + .await
964 + .map_err(CoreError::database)?;
965 +
966 + if result.rows_affected() == 0 {
967 + return Ok(None);
968 + }
969 +
970 + let row = sqlx::query_as::<_, (String, String, String, Option<String>)>(
971 + "SELECT contact_id, platform, handle, url FROM contact_social_handles WHERE id = ?"
972 + )
973 + .bind(handle_id.to_string())
974 + .fetch_one(&self.pool)
975 + .await
976 + .map_err(CoreError::database)?;
977 +
978 + Ok(Some(SocialHandle {
979 + id: handle_id,
980 + contact_id: crate::utils::parse_uuid(&row.0)?.into(),
981 + platform: row.1,
982 + handle: row.2,
983 + url: row.3,
984 + }))
985 + }
986 +
987 + #[tracing::instrument(skip_all)]
988 + async fn update_custom_field(&self, field_id: CustomFieldId, user_id: UserId, field: NewContactCustomField) -> Result<Option<ContactCustomField>> {
989 + let result = sqlx::query(
990 + r#"
991 + UPDATE contact_custom_fields
992 + SET label = ?, value = ?, url = ?
993 + WHERE id = ? AND contact_id IN (SELECT id FROM contacts WHERE user_id = ?)
994 + "#
995 + )
996 + .bind(&field.label)
997 + .bind(&field.value)
998 + .bind(&field.url)
999 + .bind(field_id.to_string())
1000 + .bind(user_id.to_string())
1001 + .execute(&self.pool)
1002 + .await
1003 + .map_err(CoreError::database)?;
1004 +
1005 + if result.rows_affected() == 0 {
1006 + return Ok(None);
1007 + }
1008 +
1009 + let row = sqlx::query_as::<_, (String, String, String, Option<String>)>(
1010 + "SELECT contact_id, label, value, url FROM contact_custom_fields WHERE id = ?"
1011 + )
1012 + .bind(field_id.to_string())
1013 + .fetch_one(&self.pool)
1014 + .await
1015 + .map_err(CoreError::database)?;
1016 +
1017 + Ok(Some(ContactCustomField {
1018 + id: field_id,
1019 + contact_id: crate::utils::parse_uuid(&row.0)?.into(),
1020 + label: row.1,
1021 + value: row.2,
1022 + url: row.3,
1023 + }))
1024 + }
869 1025 }
@@ -721,3 +721,89 @@ async fn find_by_email_case_insensitive() {
721 721 assert!(found.is_some());
722 722 assert_eq!(found.unwrap().display_name, "Case Test");
723 723 }
724 +
725 + #[tokio::test]
726 + async fn update_contact_subcollections() {
727 + let pool = common::setup_test_db().await;
728 + let user_id = common::create_test_user(&pool).await;
729 + let repo = SqliteContactRepository::new(pool);
730 +
731 + let contact = repo
732 + .create(
733 + user_id,
734 + NewContact {
735 + display_name: "Edit Test".to_string(),
736 + nickname: None,
737 + company: None,
738 + title: None,
739 + notes: String::new(),
740 + tags: vec![],
741 + birthday: None,
742 + timezone: None,
743 + is_implicit: false,
744 + },
745 + )
746 + .await
747 + .unwrap();
748 +
749 + let email = repo.add_email(contact.id, user_id, NewContactEmail {
750 + address: "old@example.com".into(), label: "work".into(), is_primary: false,
751 + }).await.unwrap();
752 + let updated = repo.update_email(email.id, user_id, NewContactEmail {
753 + address: "new@example.com".into(), label: "home".into(), is_primary: true,
754 + }).await.unwrap().expect("email should exist");
755 + assert_eq!(updated.address, "new@example.com");
756 + assert_eq!(updated.label, "home");
757 + assert!(updated.is_primary);
758 +
759 + let phone = repo.add_phone(contact.id, user_id, NewContactPhone {
760 + number: "+1-555-0001".into(), label: "mobile".into(), is_primary: false,
761 + }).await.unwrap();
762 + let updated = repo.update_phone(phone.id, user_id, NewContactPhone {
763 + number: "+1-555-0002".into(), label: "work".into(), is_primary: true,
764 + }).await.unwrap().expect("phone should exist");
765 + assert_eq!(updated.number, "+1-555-0002");
766 + assert!(updated.is_primary);
767 +
768 + let handle = repo.add_social_handle(contact.id, user_id, NewSocialHandle {
769 + platform: "github".into(), handle: "old".into(), url: None,
770 + }).await.unwrap();
771 + let updated = repo.update_social_handle(handle.id, user_id, NewSocialHandle {
772 + platform: "mastodon".into(), handle: "new@example.social".into(),
773 + url: Some("https://example.social/@new".into()),
774 + }).await.unwrap().expect("handle should exist");
775 + assert_eq!(updated.platform, "mastodon");
776 + assert_eq!(updated.handle, "new@example.social");
777 + assert_eq!(updated.url.as_deref(), Some("https://example.social/@new"));
778 +
779 + let field = repo.add_custom_field(contact.id, user_id, NewContactCustomField {
780 + label: "Website".into(), value: "old.example".into(), url: None,
781 + }).await.unwrap();
782 + let updated = repo.update_custom_field(field.id, user_id, NewContactCustomField {
783 + label: "Homepage".into(), value: "new.example".into(),
784 + url: Some("https://new.example".into()),
785 + }).await.unwrap().expect("field should exist");
786 + assert_eq!(updated.label, "Homepage");
787 + assert_eq!(updated.url.as_deref(), Some("https://new.example"));
788 + }
789 +
790 + #[tokio::test]
791 + async fn update_missing_subcollection_returns_none() {
792 + let pool = common::setup_test_db().await;
793 + let user_id = common::create_test_user(&pool).await;
794 + let repo = SqliteContactRepository::new(pool);
795 +
796 + let result = repo
797 + .update_email(
798 + goingson_core::ContactEmailId::new(),
799 + user_id,
800 + NewContactEmail {
801 + address: "x@example.com".into(),
802 + label: String::new(),
803 + is_primary: false,
804 + },
805 + )
806 + .await
807 + .unwrap();
808 + assert!(result.is_none());
809 + }
@@ -158,12 +158,16 @@ const api = {
158 158 bulkTag: (ids, tag) => invoke('bulk_tag_contacts', { ids, tag }),
159 159 addEmail: (contactId, input) => invoke('add_contact_email', { contactId, input }),
160 160 removeEmail: (emailId) => invoke('remove_contact_email', { emailId }),
161 + updateEmail: (emailId, input) => invoke('update_contact_email', { emailId, input }),
161 162 addPhone: (contactId, input) => invoke('add_contact_phone', { contactId, input }),
162 163 removePhone: (phoneId) => invoke('remove_contact_phone', { phoneId }),
164 + updatePhone: (phoneId, input) => invoke('update_contact_phone', { phoneId, input }),
163 165 addSocialHandle: (contactId, input) => invoke('add_contact_social_handle', { contactId, input }),
164 166 removeSocialHandle: (handleId) => invoke('remove_contact_social_handle', { handleId }),
167 + updateSocialHandle: (handleId, input) => invoke('update_contact_social_handle', { handleId, input }),
165 168 addCustomField: (contactId, input) => invoke('add_contact_custom_field', { contactId, input }),
166 169 removeCustomField: (fieldId) => invoke('remove_contact_custom_field', { fieldId }),
170 + updateCustomField: (fieldId, input) => invoke('update_contact_custom_field', { fieldId, input }),
167 171 findByEmail: (email) => invoke('find_contact_by_email', { email }), // Reverse lookup for email sender → contact
168 172 validateAddresses: (addresses) => invoke('validate_email_addresses', { addresses }),
169 173 promoteContact: (id) => invoke('promote_contact', { id }),
@@ -105,9 +105,17 @@
105 105 },
106 106 addCommand: 'addEmail',
107 107 removeCommand: 'removeEmail',
108 + updateCommand: 'updateEmail',
108 109 entityLabel: 'email',
109 110 deleteLabel: 'this email address',
110 111 submitButtonText: 'Add Email',
112 + editModalTitle: 'Edit Email Address',
113 + editButtonText: 'Save Email',
114 + prefill: (form, row) => {
115 + form.address.value = row.address || '';
116 + form.label.value = row.label || '';
117 + form.is_primary.checked = !!row.isPrimary;
118 + },
111 119 },
112 120 phone: {
113 121 formId: 'add-contact-phone-form',
@@ -128,9 +136,17 @@
128 136 },
129 137 addCommand: 'addPhone',
130 138 removeCommand: 'removePhone',
139 + updateCommand: 'updatePhone',
131 140 entityLabel: 'phone',
132 141 deleteLabel: 'this phone number',
133 142 submitButtonText: 'Add Phone',
143 + editModalTitle: 'Edit Phone Number',
144 + editButtonText: 'Save Phone',
145 + prefill: (form, row) => {
146 + form.number.value = row.number || '';
147 + form.label.value = row.label || '';
148 + form.is_primary.checked = !!row.isPrimary;
149 + },
134 150 },
135 151 social: {
136 152 formId: 'add-contact-social-form',
@@ -151,8 +167,16 @@
151 167 },
152 168 addCommand: 'addSocialHandle',
153 169 removeCommand: 'removeSocialHandle',
170 + updateCommand: 'updateSocialHandle',
154 171 entityLabel: 'social handle',
155 172 submitButtonText: 'Add Handle',
173 + editModalTitle: 'Edit Social Handle',
174 + editButtonText: 'Save Handle',
175 + prefill: (form, row) => {
176 + form.platform.value = row.platform || '';
177 + form.handle.value = row.handle || '';
178 + form.url.value = row.url || '';
179 + },
156 180 },
157 181 customField: {
158 182 formId: 'add-contact-custom-field-form',
@@ -173,20 +197,42 @@
173 197 },
174 198 addCommand: 'addCustomField',
175 199 removeCommand: 'removeCustomField',
200 + updateCommand: 'updateCustomField',
176 201 entityLabel: 'custom field',
177 202 submitButtonText: 'Add Field',
203 + editModalTitle: 'Edit Custom Field',
204 + editButtonText: 'Save Field',
205 + prefill: (form, row) => {
206 + form.label.value = row.label || '';
207 + form.value.value = row.value || '';
208 + form.url.value = row.url || '';
209 + },
178 210 },
179 211 };
180 212
181 213 // ============ Generic Sub-Collection Functions ============
182 214
215 + const ADD_SUBMIT_FN = {
216 + email: 'submitAddEmail',
217 + phone: 'submitAddPhone',
218 + social: 'submitAddSocial',
219 + customField: 'submitAddCustomField',
220 + };
221 + const EDIT_SUBMIT_FN = {
222 + email: 'submitEditEmail',
223 + phone: 'submitEditPhone',
224 + social: 'submitEditSocial',
225 + customField: 'submitEditCustomField',
226 + };
227 +
183 228 /**
184 - * Build the HTML form for adding a sub-collection item (email, phone, etc.).
229 + * Build the HTML form for adding or editing a sub-collection item.
185 230 * @param {string} type - Sub-collection type key from SUB_COLLECTIONS
186 231 * @param {string} contactId - Parent contact ID
232 + * @param {string|null} editingId - When set, render as edit form (submit routes to update).
187 233 * @returns {string} HTML string for the form
188 234 */
189 - function buildSubCollectionFormHtml(type, contactId) {
235 + function buildSubCollectionFormHtml(type, contactId, editingId = null) {
190 236 const config = SUB_COLLECTIONS[type];
191 237 const fieldHtml = config.fields.map(f => {
192 238 if (f.type === 'checkbox') {
@@ -205,21 +251,19 @@
205 251 </div>`;
206 252 }).join('');
207 253
208 - // Determine the correct submit wrapper name for onclick
209 - const submitFnMap = {
210 - email: 'submitAddEmail',
211 - phone: 'submitAddPhone',
212 - social: 'submitAddSocial',
213 - customField: 'submitAddCustomField',
214 - };
215 - const submitFn = submitFnMap[type];
254 + const isEdit = !!editingId;
255 + const submitFn = isEdit ? EDIT_SUBMIT_FN[type] : ADD_SUBMIT_FN[type];
256 + const submitText = isEdit ? (config.editButtonText || 'Save') : config.submitButtonText;
257 + const onclick = isEdit
258 + ? `GoingsOn.contacts.${submitFn}('${escAttr(contactId)}', '${escAttr(editingId)}')`
259 + : `GoingsOn.contacts.${submitFn}('${escAttr(contactId)}')`;
216 260
217 261 return `
218 262 <form id="${escAttr(config.formId)}">
219 263 ${fieldHtml}
220 264 <div class="form-actions">
221 265 <button type="button" class="btn btn-secondary" onclick="GoingsOn.ui.closeModal()">Cancel</button>
222 - <button type="button" class="btn btn-primary" onclick="GoingsOn.contacts.${submitFn}('${escAttr(contactId)}')">${esc(config.submitButtonText)}</button>
266 + <button type="button" class="btn btn-primary" onclick="${onclick}">${esc(submitText)}</button>
223 267 </div>
224 268 </form>
225 269 `;
@@ -237,6 +281,22 @@
237 281 }
238 282
239 283 /**
284 + * Open a modal to edit an existing sub-collection item, prefilled with its current values.
285 + * @param {string} type - Sub-collection type ('email', 'phone', 'social', 'customField')
286 + * @param {string} contactId - Parent contact ID
287 + * @param {Object} row - Existing sub-collection row (the JSON shape returned by the backend)
288 + */
289 + function openEditSubCollection(type, contactId, row) {
290 + const config = SUB_COLLECTIONS[type];
291 + if (!row || !row.id) return;
292 + const content = buildSubCollectionFormHtml(type, contactId, row.id);
293 + GoingsOn.ui.openModal(config.editModalTitle || config.modalTitle, content);
294 + // openModal injects content synchronously; the form fields are now in the DOM.
295 + const form = document.getElementById(config.formId);
296 + if (form && config.prefill) config.prefill(form, row);
297 + }
298 +
299 + /**
240 300 * Validate and submit a sub-collection add form.
241 301 * @param {string} type - Sub-collection type ('email', 'phone', 'social', 'customField')
242 302 * @param {string} contactId - Parent contact ID
@@ -258,7 +318,34 @@
258 318 await GoingsOn.ui.apiCall(GoingsOn.api.contacts[config.addCommand](contactId, input), {
259 319 successMessage: `${config.entityLabel.charAt(0).toUpperCase() + config.entityLabel.slice(1)} added!`,
260 320 errorMessage: `Failed to add ${config.entityLabel}`,
261 - reload: () => { load(); open(contactId); },
321 + reload: async () => { await load(); openEdit(contactId); },
322 + });
323 + }
324 +
325 + /**
326 + * Validate and submit a sub-collection edit form.
327 + * @param {string} type - Sub-collection type ('email', 'phone', 'social', 'customField')
328 + * @param {string} contactId - Parent contact ID
329 + * @param {string} itemId - Sub-collection row ID being updated
330 + */
331 + async function submitEditSubCollection(type, contactId, itemId) {
332 + const config = SUB_COLLECTIONS[type];
333 + const form = document.getElementById(config.formId);
334 + if (!form) return;
335 +
336 + const error = config.validate ? config.validate(form) : null;
337 + if (error) {
338 + GoingsOn.ui.showToast(error, 'error');
339 + return;
340 + }
341 +
342 + const input = config.collectData(form);
343 +
344 + GoingsOn.cache.invalidate('contacts');
345 + await GoingsOn.ui.apiCall(GoingsOn.api.contacts[config.updateCommand](itemId, input), {
346 + successMessage: `${config.entityLabel.charAt(0).toUpperCase() + config.entityLabel.slice(1)} updated`,
347 + errorMessage: `Failed to update ${config.entityLabel}`,
348 + reload: async () => { await load(); openEdit(contactId); },
262 349 });
263 350 }
264 351
@@ -275,7 +362,7 @@
275 362 await GoingsOn.ui.apiCall(GoingsOn.api.contacts[config.removeCommand](itemId), {
276 363 successMessage: `${config.entityLabel.charAt(0).toUpperCase() + config.entityLabel.slice(1)} removed`,
277 364 errorMessage: `Failed to remove ${config.entityLabel}`,
278 - reload: () => { load(); open(contactId); },
365 + reload: async () => { await load(); openEdit(contactId); },
279 366 });
280 367 }
281 368
@@ -284,18 +371,38 @@
284 371 function openAddEmail(cid) { openAddSubCollection('email', cid); }
285 372 function submitAddEmail(cid) { submitSubCollection('email', cid); }
286 373 function removeEmail(cid, id) { removeSubCollection('email', cid, id); }
374 + function openEditEmail(cid, id) { openEditSubCollection('email', cid, findSubRow(cid, 'emails', id)); }
375 + function submitEditEmail(cid, id) { submitEditSubCollection('email', cid, id); }
287 376
288 377 function openAddPhone(cid) { openAddSubCollection('phone', cid); }
289 378 function submitAddPhone(cid) { submitSubCollection('phone', cid); }
290 379 function removePhone(cid, id) { removeSubCollection('phone', cid, id); }
380 + function openEditPhone(cid, id) { openEditSubCollection('phone', cid, findSubRow(cid, 'phones', id)); }
381 + function submitEditPhone(cid, id) { submitEditSubCollection('phone', cid, id); }
291 382
292 383 function openAddSocial(cid) { openAddSubCollection('social', cid); }
293 384 function submitAddSocial(cid) { submitSubCollection('social', cid); }
294 385 function removeSocialHandle(cid, id) { removeSubCollection('social', cid, id); }
386 + function openEditSocial(cid, id) { openEditSubCollection('social', cid, findSubRow(cid, 'socialHandles', id)); }
387 + function submitEditSocial(cid, id) { submitEditSubCollection('social', cid, id); }
295 388
296 389 function openAddCustomField(cid) { openAddSubCollection('customField', cid); }
297 390 function submitAddCustomField(cid) { submitSubCollection('customField', cid); }
298 391 function removeCustomField(cid, id) { removeSubCollection('customField', cid, id); }
392 + function openEditCustomField(cid, id) { openEditSubCollection('customField', cid, findSubRow(cid, 'customFields', id)); }
393 + function submitEditCustomField(cid, id) { submitEditSubCollection('customField', cid, id); }
394 +
395 + /**
396 + * Look up a sub-collection row by id from the cached `GoingsOn.state.contacts`
397 + * list. Keeps the inline edit buttons honest with the current data without
398 + * passing JSON payloads through HTML attributes.
399 + */
400 + function findSubRow(contactId, field, rowId) {
401 + const contact = (GoingsOn.state.contacts || []).find(c => c.id === contactId);
402 + if (!contact) return null;
403 + const list = contact[field] || [];
404 + return list.find(r => r.id === rowId) || null;
405 + }
299 406
300 407 // ============ Form Field Definitions ============
301 408
@@ -476,21 +583,30 @@
476 583 const contact = (GoingsOn.state.contacts || []).find(c => c.id === id);
477 584 if (!contact) return;
478 585
479 - // Build sub-collection summaries for the edit form
586 + // Build sub-collection summaries for the edit form. Each row carries
587 + // inline Edit / Remove buttons; data is looked up by id from
588 + // `GoingsOn.state.contacts` rather than smuggled through HTML attrs.
589 + const rowActions = (kind, rowId) => `
590 + <span class="sub-item-actions">
591 + <button type="button" class="btn btn-sm btn-secondary" onclick="GoingsOn.contacts.openEdit${kind}('${escAttr(id)}', '${escAttr(rowId)}')" title="Edit">Edit</button>
592 + <button type="button" class="btn btn-sm btn-danger" onclick="GoingsOn.contacts.remove${kind === 'Social' ? 'SocialHandle' : kind}('${escAttr(id)}', '${escAttr(rowId)}')" title="Remove">x</button>
593 + </span>
594 + `;
595 +
480 596 const emailSummary = (contact.emails || []).map(e =>
481 - `<div class="sub-item-compact">${esc(e.address)}${e.label ? ` <small>(${esc(e.label)})</small>` : ''}</div>`
597 + `<div class="sub-item-compact"><span>${esc(e.address)}${e.label ? ` <small>(${esc(e.label)})</small>` : ''}${e.isPrimary ? ' <strong>Primary</strong>' : ''}</span>${rowActions('Email', e.id)}</div>`
482 598 ).join('') || '<span class="text-muted">None</span>';
483 599
484 600 const phoneSummary = (contact.phones || []).map(p =>
485 - `<div class="sub-item-compact">${esc(p.number)}${p.label ? ` <small>(${esc(p.label)})</small>` : ''}</div>`
601 + `<div class="sub-item-compact"><span>${esc(p.number)}${p.label ? ` <small>(${esc(p.label)})</small>` : ''}${p.isPrimary ? ' <strong>Primary</strong>' : ''}</span>${rowActions('Phone', p.id)}</div>`
486 602 ).join('') || '<span class="text-muted">None</span>';
487 603
488 604 const socialSummary = (contact.socialHandles || []).map(s =>
489 - `<div class="sub-item-compact"><strong>${esc(s.platform)}:</strong> ${esc(s.handle)}</div>`
605 + `<div class="sub-item-compact"><span><strong>${esc(s.platform)}:</strong> ${esc(s.handle)}</span>${rowActions('Social', s.id)}</div>`
490 606 ).join('') || '<span class="text-muted">None</span>';
491 607
492 608 const customFieldSummary = (contact.customFields || []).map(f =>
493 - `<div class="sub-item-compact"><strong>${esc(f.label)}:</strong> ${esc(f.value)}</div>`
609 + `<div class="sub-item-compact"><span><strong>${esc(f.label)}:</strong> ${esc(f.value)}</span>${rowActions('CustomField', f.id)}</div>`
494 610 ).join('') || '<span class="text-muted">None</span>';
495 611
496 612 GoingsOn.ui.openFormModal({
@@ -626,15 +742,23 @@
626 742 openAddEmail,
627 743 submitAddEmail,
628 744 removeEmail,
745 + openEditEmail,
746 + submitEditEmail,
629 747 openAddPhone,
630 748 submitAddPhone,
631 749 removePhone,
750 + openEditPhone,
751 + submitEditPhone,
632 752 openAddSocial,
633 753 submitAddSocial,
634 754 removeSocialHandle,
755 + openEditSocial,
756 + submitEditSocial,
635 757 openAddCustomField,
636 758 submitAddCustomField,
637 759 removeCustomField,
760 + openEditCustomField,
761 + submitEditCustomField,
638 762 };
639 763
640 764 })();
@@ -313,6 +313,74 @@ pub async fn remove_contact_custom_field(
313 313 Ok(state.contacts.remove_custom_field(field_id, DESKTOP_USER_ID).await?)
314 314 }
315 315
316 + /// Updates an existing email address on a contact.
317 + #[tauri::command]
318 + #[instrument(skip_all)]
319 + pub async fn update_contact_email(
320 + state: State<'_, Arc<AppState>>,
321 + email_id: ContactEmailId,
322 + input: ContactEmailInput,
323 + ) -> Result<ContactEmail, ApiError> {
324 + let email = NewContactEmail {
325 + address: input.address,
326 + label: input.label.unwrap_or_default(),
327 + is_primary: input.is_primary.unwrap_or(false),
328 + };
329 + state.contacts.update_email(email_id, DESKTOP_USER_ID, email).await?
330 + .or_not_found("contact_email", email_id)
331 + }
332 +
333 + /// Updates an existing phone number on a contact.
334 + #[tauri::command]
335 + #[instrument(skip_all)]
336 + pub async fn update_contact_phone(
337 + state: State<'_, Arc<AppState>>,
338 + phone_id: ContactPhoneId,
339 + input: ContactPhoneInput,
340 + ) -> Result<ContactPhone, ApiError> {
341 + let phone = NewContactPhone {
342 + number: input.number,
343 + label: input.label.unwrap_or_default(),
344 + is_primary: input.is_primary.unwrap_or(false),
345 + };
346 + state.contacts.update_phone(phone_id, DESKTOP_USER_ID, phone).await?
347 + .or_not_found("contact_phone", phone_id)
348 + }
349 +
350 + /// Updates an existing social handle on a contact.
351 + #[tauri::command]
352 + #[instrument(skip_all)]
353 + pub async fn update_contact_social_handle(
354 + state: State<'_, Arc<AppState>>,
355 + handle_id: SocialHandleId,
356 + input: SocialHandleInput,
357 + ) -> Result<SocialHandle, ApiError> {
358 + let handle = NewSocialHandle {
359 + platform: input.platform,
360 + handle: input.handle,
361 + url: input.url,
362 + };
363 + state.contacts.update_social_handle(handle_id, DESKTOP_USER_ID, handle).await?
364 + .or_not_found("social_handle", handle_id)
365 + }
366 +
367 + /// Updates an existing custom field on a contact.
368 + #[tauri::command]
369 + #[instrument(skip_all)]
370 + pub async fn update_contact_custom_field(
371 + state: State<'_, Arc<AppState>>,
372 + field_id: CustomFieldId,
373 + input: CustomFieldInput,
374 + ) -> Result<ContactCustomField, ApiError> {
375 + let field = NewContactCustomField {
376 + label: input.label,
377 + value: input.value,
378 + url: input.url,
379 + };
380 + state.contacts.update_custom_field(field_id, DESKTOP_USER_ID, field).await?
381 + .or_not_found("contact_custom_field", field_id)
382 + }
383 +
316 384 /// Lists contacts filtered by search query and/or tag.
317 385 /// Searches across display name, nickname, company, title, notes, and email addresses.
318 386 #[tauri::command]
@@ -193,6 +193,10 @@ pub fn build_mobile_app() -> tauri::Builder<tauri::Wry> {
193 193 commands::remove_contact_social_handle,
194 194 commands::add_contact_custom_field,
195 195 commands::remove_contact_custom_field,
196 + commands::update_contact_email,
197 + commands::update_contact_phone,
198 + commands::update_contact_social_handle,
199 + commands::update_contact_custom_field,
196 200 commands::find_contact_by_email,
197 201 commands::list_contacts_filtered,
198 202 commands::get_snooze_options,
@@ -489,6 +489,10 @@ fn main() {
489 489 commands::remove_contact_social_handle,
490 490 commands::add_contact_custom_field,
491 491 commands::remove_contact_custom_field,
492 + commands::update_contact_email,
493 + commands::update_contact_phone,
494 + commands::update_contact_social_handle,
495 + commands::update_contact_custom_field,
492 496 commands::find_contact_by_email,
493 497 commands::validate_email_addresses,
494 498 commands::promote_contact,