Compare commits

..

6 Commits

Author SHA1 Message Date
6ec2002cb0 Merge pull request 'refactor: move Import Contacts section to DataExportSection component' (#226) from accountview-contact-management-bundling into master
Reviewed-on: #226
2025-11-21 11:02:59 +00:00
Jose Olarte III
36eb9a16b0 fix: preserve contact methods and notes in export/import workflow
- Fix contactMethods not being exported (was checking for string instead of array)
- Add missing notes and iViewContent fields to $insertContact method
- Normalize empty strings to null when saving contacts in ContactEditView

This ensures contact data integrity is maintained during export/import operations.
2025-11-20 18:11:27 +08:00
7d295dd062 feat: make the contact methods more presentable, and clarify exact types 2025-11-19 20:00:42 -07:00
5f1b4dcc21 chore: bump version and add "-beta" 2025-11-19 20:00:09 -07:00
Jose Olarte III
203cf6b078 refactor(DataExportSection): rename section title to "Data Management"
Update the section title from "Data Export" to "Data Management" to better reflect that the component handles both data export and import functionality.
2025-11-19 21:32:35 +08:00
Jose Olarte III
9b84b28a78 refactor: move Import Contacts section to DataExportSection component
- Move Import Contacts UI section from AccountViewView to DataExportSection
- Consolidate import/export functionality in a single component
- Move related methods: uploadImportFile, showContactImport, checkContactImports
- Convert module-level ref to component property (inputImportFileName)
- Remove unused imports (ref, ImportContent) from AccountViewView
- Rename "Download Contacts" button to "Export Contacts"
- Improve import UI styling with full-width file input and button
2025-11-19 19:26:36 +08:00
8 changed files with 240 additions and 199 deletions

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "timesafari", "name": "timesafari",
"version": "1.1.3", "version": "1.1.4-beta",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "timesafari", "name": "timesafari",
"version": "1.1.3", "version": "1.1.4-beta",
"dependencies": { "dependencies": {
"@capacitor-community/electron": "^5.0.1", "@capacitor-community/electron": "^5.0.1",
"@capacitor-community/sqlite": "6.0.2", "@capacitor-community/sqlite": "6.0.2",

View File

@@ -1,6 +1,6 @@
{ {
"name": "timesafari", "name": "timesafari",
"version": "1.1.3", "version": "1.1.4-beta",
"description": "Time Safari Application", "description": "Time Safari Application",
"author": { "author": {
"name": "Time Safari Team" "name": "Time Safari Team"

View File

@@ -10,7 +10,7 @@ messages * - Conditional UI based on platform capabilities * * @component *
<template> <template>
<div id="sectionDataExport" :class="containerClasses"> <div id="sectionDataExport" :class="containerClasses">
<div :class="titleClasses">Data Export</div> <div :class="titleClasses">Data Management</div>
<router-link <router-link
v-if="activeDid" v-if="activeDid"
:to="{ name: 'seed-backup' }" :to="{ name: 'seed-backup' }"
@@ -30,7 +30,7 @@ messages * - Conditional UI based on platform capabilities * * @component *
:class="exportButtonClasses" :class="exportButtonClasses"
@click="exportDatabase()" @click="exportDatabase()"
> >
{{ isExporting ? "Exporting..." : "Download Contacts" }} {{ isExporting ? "Exporting..." : "Export Contacts" }}
</button> </button>
<div <div
@@ -55,11 +55,54 @@ messages * - Conditional UI based on platform capabilities * * @component *
</li> </li>
</ul> </ul>
</div> </div>
<!-- Import Contacts -->
<div id="sectionImportContactsSettings" class="mt-4">
<h2 class="text-slate-500 text-sm font-bold">Import Contacts</h2>
<div class="mt-2">
<input
type="file"
class="w-full bg-white rounded-md pe-2 file:border-0 file:bg-gradient-to-b file:from-blue-400 file:to-blue-700 file:shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] file:text-white file:px-3 file:py-2 file:me-2 file:rounded-s-md"
@change="uploadImportFile"
/>
<transition
enter-active-class="transform ease-out duration-300 transition"
enter-from-class="translate-y-2 opacity-0 sm:translate-y-4"
enter-to-class="translate-y-0 opacity-100 sm:translate-y-0"
leave-active-class="transition ease-in duration-500"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div v-if="showContactImport()" class="mt-2">
<!-- Bulk import has an error
<div class="flex justify-center">
<button
class="block text-center text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mb-6"
@click="confirmSubmitImportFile()"
>
Overwrite Settings & Contacts
<br />
(which doesn't include Identifier Data)
</button>
</div>
-->
<button
class="block w-full text-center text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md"
@click="checkContactImports()"
>
Import Contacts
</button>
</div>
</transition>
</div>
</div>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Prop, Vue } from "vue-facing-decorator"; import { Component, Prop, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import * as R from "ramda"; import * as R from "ramda";
import { AppString, NotificationIface } from "../constants/app"; import { AppString, NotificationIface } from "../constants/app";
@@ -67,8 +110,10 @@ import { Contact } from "../db/tables/contacts";
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";
import { contactsToExportJson } from "../libs/util"; import { contactsToExportJson } from "../libs/util";
import { createNotifyHelpers } from "@/utils/notify"; import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin"; import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { ACCOUNT_VIEW_CONSTANTS } from "@/constants/accountView";
import { ImportContent } from "@/interfaces/accountView";
/** /**
* @vue-component * @vue-component
@@ -91,6 +136,12 @@ export default class DataExportSection extends Vue {
*/ */
$notify!: (notification: NotificationIface, timeout?: number) => void; $notify!: (notification: NotificationIface, timeout?: number) => void;
/**
* Router instance injected by Vue
* Used for navigation
*/
$router!: Router;
/** /**
* Active DID (Decentralized Identifier) of the user * Active DID (Decentralized Identifier) of the user
* Controls visibility of seed backup option * Controls visibility of seed backup option
@@ -110,6 +161,12 @@ export default class DataExportSection extends Vue {
*/ */
showRedNotificationDot = false; showRedNotificationDot = false;
/**
* Reference to the selected import file
* Used to store the file selected by the user for import
*/
private inputImportFileName: Blob | undefined;
/** /**
* Notification helper for consistent notification patterns * Notification helper for consistent notification patterns
* Created as a getter to ensure $notify is available when called * Created as a getter to ensure $notify is available when called
@@ -200,12 +257,30 @@ export default class DataExportSection extends Vue {
// first remove the contactMethods field, mostly to cast to a clear type (that will end up with JSON objects) // first remove the contactMethods field, mostly to cast to a clear type (that will end up with JSON objects)
const exContact: Contact = R.omit(["contactMethods"], contact); const exContact: Contact = R.omit(["contactMethods"], contact);
// now add contactMethods as a true array of ContactMethod objects // now add contactMethods as a true array of ContactMethod objects
exContact.contactMethods = contact.contactMethods // $contacts() returns normalized contacts where contactMethods is already an array,
? typeof contact.contactMethods === "string" && // but we handle both array and string cases for robustness
contact.contactMethods.trim() !== "" if (contact.contactMethods) {
? JSON.parse(contact.contactMethods) if (Array.isArray(contact.contactMethods)) {
: [] // Already an array, use it directly
: []; exContact.contactMethods = contact.contactMethods;
} else {
// Check if it's a string that needs parsing (shouldn't happen with normalized contacts, but handle for robustness)
const contactMethodsValue = contact.contactMethods as unknown;
if (
typeof contactMethodsValue === "string" &&
contactMethodsValue.trim() !== ""
) {
// String that needs parsing
exContact.contactMethods = JSON.parse(contactMethodsValue);
} else {
// Invalid data, use empty array
exContact.contactMethods = [];
}
}
} else {
// No contactMethods, use empty array
exContact.contactMethods = [];
}
return exContact; return exContact;
}); });
@@ -248,5 +323,58 @@ export default class DataExportSection extends Vue {
this.showRedNotificationDot = false; this.showRedNotificationDot = false;
} }
} }
/**
* Handles file selection for contact import
* Stores the selected file for later processing
*/
async uploadImportFile(event: Event): Promise<void> {
this.inputImportFileName = (event.target as HTMLInputElement).files?.[0];
}
/**
* Checks if a contact import file has been selected
* Used to conditionally show the import button
*/
showContactImport(): boolean {
return !!this.inputImportFileName;
}
/**
* Processes the selected import file and navigates to the contact import view
* Parses the JSON file and extracts contact data for import
*/
async checkContactImports(): Promise<void> {
if (!this.inputImportFileName) {
return;
}
const reader = new FileReader();
reader.onload = (event) => {
const fileContent: string = (event.target?.result as string) || "{}";
try {
const contents: ImportContent = JSON.parse(fileContent);
const contactTableRows: Array<Contact> = (
contents.data?.data as [{ tableName: string; rows: Array<Contact> }]
)?.find((table) => table.tableName === "contacts")
?.rows as Array<Contact>;
const contactRows = contactTableRows.map(
// @ts-expect-error for omitting this field that is found in the Dexie format
(contact) => R.omit(["$types"], contact) as Contact,
);
this.$router.push({
name: "contact-import",
query: { contacts: JSON.stringify(contactRows) },
});
} catch (error) {
logger.error("Error checking contact imports:", error);
this.notify.error(
ACCOUNT_VIEW_CONSTANTS.ERRORS.IMPORT_ERROR,
TIMEOUTS.STANDARD,
);
}
};
reader.readAsText(this.inputImportFileName);
}
} }
</script> </script>

View File

@@ -43,6 +43,7 @@ import {
faDownload, faDownload,
faEllipsis, faEllipsis,
faEllipsisVertical, faEllipsisVertical,
faEnvelope,
faEnvelopeOpenText, faEnvelopeOpenText,
faEraser, faEraser,
faEye, faEye,
@@ -101,6 +102,9 @@ import {
// these are referenced differently, eg. ":icon='['far', 'star']'" as in ProjectViewView.vue // these are referenced differently, eg. ":icon='['far', 'star']'" as in ProjectViewView.vue
import { faStar as faStarRegular } from "@fortawesome/free-regular-svg-icons"; import { faStar as faStarRegular } from "@fortawesome/free-regular-svg-icons";
// Brand icons
import { faWhatsapp } from "@fortawesome/free-brands-svg-icons";
// Initialize Font Awesome library with all required icons // Initialize Font Awesome library with all required icons
library.add( library.add(
faArrowDown, faArrowDown,
@@ -140,6 +144,7 @@ library.add(
faDownload, faDownload,
faEllipsis, faEllipsis,
faEllipsisVertical, faEllipsisVertical,
faEnvelope,
faEnvelopeOpenText, faEnvelopeOpenText,
faEraser, faEraser,
faEye, faEye,
@@ -193,6 +198,7 @@ library.add(
faTriangleExclamation, faTriangleExclamation,
faUser, faUser,
faUsers, faUsers,
faWhatsapp,
faXmark, faXmark,
); );

View File

@@ -1367,6 +1367,9 @@ export const PlatformServiceMixin = {
contact.profileImageUrl !== undefined contact.profileImageUrl !== undefined
? contact.profileImageUrl ? contact.profileImageUrl
: null, : null,
notes: contact.notes !== undefined ? contact.notes : null,
iViewContent:
contact.iViewContent !== undefined ? contact.iViewContent : null,
contactMethods: contactMethods:
contact.contactMethods !== undefined contact.contactMethods !== undefined
? Array.isArray(contact.contactMethods) ? Array.isArray(contact.contactMethods)
@@ -1377,8 +1380,8 @@ export const PlatformServiceMixin = {
await this.$dbExec( await this.$dbExec(
`INSERT OR REPLACE INTO contacts `INSERT OR REPLACE INTO contacts
(did, name, publicKeyBase64, seesMe, registered, nextPubKeyHashB64, profileImageUrl, contactMethods) (did, name, publicKeyBase64, seesMe, registered, nextPubKeyHashB64, profileImageUrl, notes, iViewContent, contactMethods)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[ [
safeContact.did, safeContact.did,
safeContact.name, safeContact.name,
@@ -1387,6 +1390,8 @@ export const PlatformServiceMixin = {
safeContact.registered, safeContact.registered,
safeContact.nextPubKeyHashB64, safeContact.nextPubKeyHashB64,
safeContact.profileImageUrl, safeContact.profileImageUrl,
safeContact.notes,
safeContact.iViewContent,
safeContact.contactMethods, safeContact.contactMethods,
], ],
); );

View File

@@ -375,45 +375,6 @@
Switch Identifier Switch Identifier
</router-link> </router-link>
<div id="sectionImportContactsSettings" class="mt-4">
<h2 class="text-slate-500 text-sm font-bold">Import Contacts</h2>
<div class="ml-4 mt-2">
<input type="file" class="ml-2" @change="uploadImportFile" />
<transition
enter-active-class="transform ease-out duration-300 transition"
enter-from-class="translate-y-2 opacity-0 sm:translate-y-4"
enter-to-class="translate-y-0 opacity-100 sm:translate-y-0"
leave-active-class="transition ease-in duration-500"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div v-if="showContactImport()" class="mt-4">
<!-- Bulk import has an error
<div class="flex justify-center">
<button
class="block text-center text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mb-6"
@click="confirmSubmitImportFile()"
>
Overwrite Settings & Contacts
<br />
(which doesn't include Identifier Data)
</button>
</div>
-->
<div class="flex justify-center">
<button
class="block text-center text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mb-6"
@click="checkContactImports()"
>
Import Contacts
</button>
</div>
</div>
</transition>
</div>
</div>
<label <label
for="toggleShowAmounts" for="toggleShowAmounts"
class="flex items-center justify-between cursor-pointer my-4" class="flex items-center justify-between cursor-pointer my-4"
@@ -770,9 +731,7 @@ import "dexie-export-import";
import { ImportProgress } from "dexie-export-import"; import { ImportProgress } from "dexie-export-import";
import { LeafletMouseEvent } from "leaflet"; import { LeafletMouseEvent } from "leaflet";
import * as L from "leaflet"; import * as L from "leaflet";
import * as R from "ramda";
import { IIdentifier } from "@veramo/core"; import { IIdentifier } from "@veramo/core";
import { ref } from "vue";
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from "vue-facing-decorator";
import { RouteLocationNormalizedLoaded, Router } from "vue-router"; import { RouteLocationNormalizedLoaded, Router } from "vue-router";
import { copyToClipboard } from "../services/ClipboardService"; import { copyToClipboard } from "../services/ClipboardService";
@@ -799,7 +758,6 @@ import {
NotificationIface, NotificationIface,
PASSKEYS_ENABLED, PASSKEYS_ENABLED,
} from "../constants/app"; } from "../constants/app";
import { Contact } from "../db/tables/contacts";
import { import {
DEFAULT_PASSKEY_EXPIRATION_MINUTES, DEFAULT_PASSKEY_EXPIRATION_MINUTES,
BoundingBox, BoundingBox,
@@ -823,11 +781,7 @@ import { PlatformServiceMixin } from "../utils/PlatformServiceMixin";
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify"; import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
import { ACCOUNT_VIEW_CONSTANTS } from "@/constants/accountView"; import { ACCOUNT_VIEW_CONSTANTS } from "@/constants/accountView";
import { showSeedPhraseReminder } from "@/utils/seedPhraseReminder"; import { showSeedPhraseReminder } from "@/utils/seedPhraseReminder";
import { import { AccountSettings, isApiError } from "@/interfaces/accountView";
AccountSettings,
isApiError,
ImportContent,
} from "@/interfaces/accountView";
// Profile data interface (inlined from ProfileService) // Profile data interface (inlined from ProfileService)
interface ProfileData { interface ProfileData {
description: string; description: string;
@@ -836,8 +790,6 @@ interface ProfileData {
includeLocation: boolean; includeLocation: boolean;
} }
const inputImportFileNameRef = ref<Blob>();
interface UserNameDialogRef { interface UserNameDialogRef {
open: (cb: (name?: string) => void) => void; open: (cb: (name?: string) => void) => void;
} }
@@ -1369,65 +1321,6 @@ export default class AccountViewView extends Vue {
); );
} }
async uploadImportFile(event: Event): Promise<void> {
inputImportFileNameRef.value = (
event.target as HTMLInputElement
).files?.[0];
}
showContactImport(): boolean {
return !!inputImportFileNameRef.value;
}
confirmSubmitImportFile(): void {
if (inputImportFileNameRef.value != null) {
this.notify.confirm(
ACCOUNT_VIEW_CONSTANTS.WARNINGS.IMPORT_REPLACE_WARNING,
this.submitImportFile,
);
}
}
/**
* Asynchronously imports the database from a downloadable JSON file.
*
* @throws Will notify the user if there is an export error.
*/
async submitImportFile(): Promise<void> {
if (inputImportFileNameRef.value != null) {
// TODO: implement this for SQLite
}
}
async checkContactImports(): Promise<void> {
const reader = new FileReader();
reader.onload = (event) => {
const fileContent: string = (event.target?.result as string) || "{}";
try {
const contents: ImportContent = JSON.parse(fileContent);
const contactTableRows: Array<Contact> = (
contents.data?.data as [{ tableName: string; rows: Array<Contact> }]
)?.find((table) => table.tableName === "contacts")
?.rows as Array<Contact>;
const contactRows = contactTableRows.map(
// @ts-expect-error for omitting this field that is found in the Dexie format
(contact) => R.omit(["$types"], contact) as Contact,
);
(this.$router as Router).push({
name: "contact-import",
query: { contacts: JSON.stringify(contactRows) },
});
} catch (error) {
logger.error("Error checking contact imports:", error);
this.notify.error(
ACCOUNT_VIEW_CONSTANTS.ERRORS.IMPORT_ERROR,
TIMEOUTS.STANDARD,
);
}
};
reader.readAsText(inputImportFileNameRef.value as Blob);
}
private progressCallback(progress: ImportProgress): boolean { private progressCallback(progress: ImportProgress): boolean {
logger.log( logger.log(
`Import progress: ${progress.completedRows} of ${progress.totalRows} rows completed.`, `Import progress: ${progress.completedRows} of ${progress.totalRows} rows completed.`,

View File

@@ -55,56 +55,70 @@
<!-- Contact Methods --> <!-- Contact Methods -->
<div class="mt-4"> <div class="mt-4">
<h2 class="text-lg font-medium text-gray-700">Contact Methods</h2> <div v-for="(method, index) in contactMethods" :key="index" class="mt-4">
<div <!-- Type and Value Row -->
v-for="(method, index) in contactMethods" <div class="flex gap-2">
:key="index" <div class="flex-none w-32">
class="flex mt-2" <label class="block text-xs font-medium text-gray-700 mb-1">
> Type
<input </label>
v-model="method.label" <select
type="text" v-model="method.type"
class="block w-1/4 border border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500" class="block w-full border border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
placeholder="Label"
/>
<input
v-model="method.type"
type="text"
class="block ml-2 w-1/4 border border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
placeholder="Type"
/>
<div class="relative">
<button
class="px-2 py-1 bg-gray-200 rounded-md"
@click="toggleDropdown(index)"
>
<font-awesome icon="caret-down" class="fa-fw" />
</button>
<div
v-if="dropdownIndex === index"
class="absolute bg-white border border-gray-300 rounded-md mt-1"
>
<div
v-for="methodType in contactMethodTypes"
:key="methodType.value"
class="px-4 py-2 hover:bg-gray-100 cursor-pointer"
@click="setMethodType(index, methodType.value)"
> >
{{ methodType.label }} <option value=""></option>
</div> <option
v-for="methodType in contactMethodTypes"
:key="methodType.value"
:value="methodType.value"
>
{{ methodType.label }}
</option>
</select>
</div> </div>
<div class="flex-1">
<label class="block text-xs font-medium text-gray-700 mb-1">
Value
</label>
<input
v-model="method.value"
type="text"
class="block w-full border border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
placeholder="Number, email, etc."
/>
</div>
<button
class="self-end pb-0.5 text-red-500"
@click="removeContactMethod(index)"
>
<font-awesome icon="trash-can" class="fa-fw" />
</button>
</div>
<!-- WhatsApp Help Text -->
<div
v-if="method.type === 'WHATSAPP'"
class="mt-1 ml-[calc(8rem+0.5rem)] text-xs text-gray-600 italic"
>
Must include country code and only numbers (e.g., 12225551234)
</div>
<!-- Label Row -->
<div class="mt-2 flex justify-end">
<div class="flex-1 ml-[calc(8rem+0.5rem)]">
<label class="block text-xs font-medium text-gray-700 mb-1">
</label>
<input
v-model="method.label"
type="text"
class="block w-full border border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
placeholder="Label / Note"
/>
</div>
<div class="w-[2.5rem]"></div>
</div> </div>
<input
v-model="method.value"
type="text"
class="block ml-2 w-1/2 border border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
placeholder="Number, email, etc."
/>
<button class="ml-2 text-red-500" @click="removeContactMethod(index)">
<font-awesome icon="trash-can" class="fa-fw" />
</button>
</div> </div>
<button class="mt-2" @click="addContactMethod"> <button class="mt-4" @click="addContactMethod">
<font-awesome <font-awesome
icon="plus" icon="plus"
class="fa-fw px-2 py-2.5 bg-green-500 text-green-100 rounded-full" class="fa-fw px-2 py-2.5 bg-green-500 text-green-100 rounded-full"
@@ -210,12 +224,10 @@ export default class ContactEditView extends Vue {
contactNotes = ""; contactNotes = "";
/** Array of editable contact methods */ /** Array of editable contact methods */
contactMethods: Array<ContactMethod> = []; contactMethods: Array<ContactMethod> = [];
/** Currently open dropdown index, null if none open */
dropdownIndex: number | null = null;
/** App string constants */ /** App string constants */
AppString = AppString; AppString = AppString;
/** Contact method types for dropdown */ /** Contact method types for datalist suggestions */
contactMethodTypes = CONTACT_METHOD_TYPES; contactMethodTypes = CONTACT_METHOD_TYPES;
/** /**
@@ -273,29 +285,6 @@ export default class ContactEditView extends Vue {
this.contactMethods.splice(index, 1); this.contactMethods.splice(index, 1);
} }
/**
* Toggles the type selection dropdown for a contact method
*
* If the clicked dropdown is already open, closes it.
* If another dropdown is open, closes it and opens the clicked one.
*
* @param index The array index of the method whose dropdown to toggle
*/
toggleDropdown(index: number) {
this.dropdownIndex = this.dropdownIndex === index ? null : index;
}
/**
* Sets the type for a contact method and closes the dropdown
*
* @param index The array index of the method to update
* @param type The new type value (CELL, EMAIL, WHATSAPP)
*/
setMethodType(index: number, type: string) {
this.contactMethods[index].type = type;
this.dropdownIndex = null;
}
/** /**
* Saves the edited contact information to the database * Saves the edited contact information to the database
* *
@@ -331,9 +320,10 @@ export default class ContactEditView extends Vue {
} }
// Save to database via PlatformServiceMixin // Save to database via PlatformServiceMixin
// Normalize empty strings to null to preserve database consistency
await this.$updateContact(this.contact?.did || "", { await this.$updateContact(this.contact?.did || "", {
name: this.contactName, name: this.contactName?.trim() || null,
notes: this.contactNotes, notes: this.contactNotes?.trim() || null,
contactMethods: contactMethods, contactMethods: contactMethods,
}); });

View File

@@ -52,14 +52,16 @@
<!-- Contact Methods --> <!-- Contact Methods -->
<div v-if="contactFromDid.contactMethods?.length" class="mt-3"> <div v-if="contactFromDid.contactMethods?.length" class="mt-3">
<div class="flex flex-wrap gap-2"> <div class="flex flex-col gap-2">
<div <div
v-for="(method, index) in contactFromDid.contactMethods" v-for="(method, index) in contactFromDid.contactMethods"
:key="index" :key="index"
class="inline-flex items-center gap-2 text-sm" class="flex items-center gap-2 text-sm"
> >
<span class="font-semibold text-slate-600" <span class="font-semibold text-slate-600"
>{{ getContactMethodLabel(method.type) }}:</span >{{
getContactMethodLabel(method.type) || "(unspecified)"
}}:</span
> >
<span class="text-slate-700">{{ method.label }}</span> <span class="text-slate-700">{{ method.label }}</span>
<span class="text-slate-600">{{ method.value }}</span> <span class="text-slate-600">{{ method.value }}</span>
@@ -71,6 +73,23 @@
> >
<font-awesome icon="message" class="text-base" /> <font-awesome icon="message" class="text-base" />
</a> </a>
<a
v-if="method.type === 'EMAIL'"
:href="`mailto:${method.value}`"
class="ml-2 text-blue-500 hover:text-blue-700"
title="Send email"
>
<font-awesome icon="envelope" class="text-base" />
</a>
<a
v-if="method.type === 'WHATSAPP'"
:href="`https://wa.me/${method.value.replace(/\D/g, '')}`"
target="_blank"
class="ml-2 text-blue-700"
title="Send WhatsApp message"
>
<font-awesome :icon="['fab', 'whatsapp']" class="text-base" />
</a>
</div> </div>
</div> </div>
</div> </div>