max / goingson
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, |