docs: add comprehensive JSDoc documentation to views
Changes: - Add detailed JSDoc headers to ContactImportView - Add component-level documentation to ProjectViewView - Document state management and data flow - Add security considerations and usage examples - Improve test script documentation and organization - Add interface documentation for deep linking This improves code maintainability by documenting component architecture, workflows and integration points.
This commit is contained in:
@@ -142,6 +142,37 @@ import { AppString, NotificationIface } from "../constants/app";
|
||||
import { db } from "../db/index";
|
||||
import { Contact, ContactMethod } from "../db/tables/contacts";
|
||||
|
||||
/**
|
||||
* Contact Edit View Component
|
||||
* @author Matthew Raymer
|
||||
*
|
||||
* This component provides a full-featured contact editing interface with support for:
|
||||
* - Basic contact information (name, notes)
|
||||
* - Multiple contact methods with type selection
|
||||
* - Data validation and persistence
|
||||
*
|
||||
* Workflow:
|
||||
* 1. Component loads with DID from route params
|
||||
* 2. Fetches existing contact data from IndexedDB
|
||||
* 3. Presents editable form with current values
|
||||
* 4. Validates and saves updates back to database
|
||||
*
|
||||
* Contact Method Types:
|
||||
* - CELL: Mobile phone numbers
|
||||
* - EMAIL: Email addresses
|
||||
* - WHATSAPP: WhatsApp contact info
|
||||
*
|
||||
* State Management:
|
||||
* - Maintains separate state for form fields to prevent direct mutation
|
||||
* - Handles array cloning for contact methods to prevent reference issues
|
||||
* - Manages dropdown state for method type selection
|
||||
*
|
||||
* Navigation:
|
||||
* - Back button returns to previous view
|
||||
* - Save redirects to contact detail view
|
||||
* - Cancel returns to previous view
|
||||
* - Invalid DID redirects to contacts list
|
||||
*/
|
||||
@Component({
|
||||
components: {
|
||||
QuickNav,
|
||||
@@ -149,22 +180,44 @@ import { Contact, ContactMethod } from "../db/tables/contacts";
|
||||
},
|
||||
})
|
||||
export default class ContactEditView extends Vue {
|
||||
/** Notification function injected by Vue */
|
||||
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||||
/** Current route instance */
|
||||
$route!: RouteLocationNormalizedLoaded;
|
||||
/** Router instance for navigation */
|
||||
$router!: Router;
|
||||
|
||||
/** Current contact data */
|
||||
contact: Contact = {
|
||||
did: "",
|
||||
name: "",
|
||||
notes: "",
|
||||
};
|
||||
/** Editable contact name field */
|
||||
contactName = "";
|
||||
/** Editable contact notes field */
|
||||
contactNotes = "";
|
||||
/** Array of editable contact methods */
|
||||
contactMethods: Array<ContactMethod> = [];
|
||||
/** Currently open dropdown index, null if none open */
|
||||
dropdownIndex: number | null = null;
|
||||
|
||||
/** App string constants */
|
||||
AppString = AppString;
|
||||
|
||||
/**
|
||||
* Component lifecycle hook that initializes the contact edit form
|
||||
*
|
||||
* Workflow:
|
||||
* 1. Extracts DID from route parameters
|
||||
* 2. Queries database for existing contact
|
||||
* 3. Populates form fields with contact data
|
||||
* 4. Handles missing contact error case
|
||||
*
|
||||
* @throws Will not throw but redirects on error
|
||||
* @emits Notification on contact not found
|
||||
* @emits Router navigation on error
|
||||
*/
|
||||
async created() {
|
||||
const contactDid = this.$route.params.did;
|
||||
const contact = await db.contacts.get(contactDid || "");
|
||||
@@ -185,29 +238,75 @@ export default class ContactEditView extends Vue {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a new empty contact method to the methods array
|
||||
*
|
||||
* Creates a new method object with empty fields for:
|
||||
* - label: Custom label for the method
|
||||
* - type: Communication type (CELL, EMAIL, WHATSAPP)
|
||||
* - value: The contact information value
|
||||
*/
|
||||
addContactMethod() {
|
||||
this.contactMethods.push({ label: "", type: "", value: "" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a contact method at the specified index
|
||||
*
|
||||
* @param index The array index of the method to remove
|
||||
*/
|
||||
removeContactMethod(index: number) {
|
||||
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
|
||||
*
|
||||
* Workflow:
|
||||
* 1. Clones contact methods array to prevent reference issues
|
||||
* 2. Normalizes method types to uppercase
|
||||
* 3. Checks for changes in method types
|
||||
* 4. Updates database with new values
|
||||
* 5. Notifies user of success
|
||||
* 6. Redirects to contact detail view
|
||||
*
|
||||
* @throws Will not throw but notifies on validation errors
|
||||
* @emits Notification on type changes or success
|
||||
* @emits Router navigation on success
|
||||
*/
|
||||
async saveEdit() {
|
||||
// without this conversion, "Failed to execute 'put' on 'IDBObjectStore': [object Array] could not be cloned."
|
||||
const contactMethodsObj = JSON.parse(JSON.stringify(this.contactMethods));
|
||||
|
||||
// Normalize method types to uppercase
|
||||
const contactMethods = contactMethodsObj.map((method: ContactMethod) =>
|
||||
R.set(R.lensProp("type"), method.type.toUpperCase(), method),
|
||||
);
|
||||
|
||||
// Check for type changes
|
||||
if (!R.equals(contactMethodsObj, contactMethods)) {
|
||||
this.contactMethods = contactMethods;
|
||||
this.$notify(
|
||||
@@ -221,11 +320,15 @@ export default class ContactEditView extends Vue {
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Save to database
|
||||
await db.contacts.update(this.contact.did, {
|
||||
name: this.contactName,
|
||||
notes: this.contactNotes,
|
||||
contactMethods: contactMethods,
|
||||
});
|
||||
|
||||
// Notify success and redirect
|
||||
this.$notify({
|
||||
group: "alert",
|
||||
type: "success",
|
||||
|
||||
@@ -3,10 +3,7 @@
|
||||
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
|
||||
<!-- Back -->
|
||||
<div class="text-lg text-center font-light relative px-7">
|
||||
<h1
|
||||
class="text-lg text-center px-2 py-1 absolute -left-2 -top-1"
|
||||
@click="$router.back()"
|
||||
>
|
||||
<h1 class="text-lg text-center px-2 py-1 absolute -left-2 -top-1" @click="$router.back()">
|
||||
<font-awesome icon="chevron-left" class="fa-fw"></font-awesome>
|
||||
</h1>
|
||||
</div>
|
||||
@@ -20,43 +17,28 @@
|
||||
<font-awesome icon="spinner" class="animate-spin" />
|
||||
</div>
|
||||
<div v-else>
|
||||
<span
|
||||
v-if="contactsImporting.length > sameCount"
|
||||
class="flex justify-center"
|
||||
>
|
||||
<span v-if="contactsImporting.length > sameCount" class="flex justify-center">
|
||||
<input v-model="makeVisible" type="checkbox" class="mr-2" />
|
||||
Make my activity visible to these contacts.
|
||||
</span>
|
||||
|
||||
<div v-if="sameCount > 0">
|
||||
<span v-if="sameCount == 1"
|
||||
>One contact is the same as an existing contact</span
|
||||
>
|
||||
<span v-else
|
||||
>{{ sameCount }} contacts are the same as existing contacts</span
|
||||
>
|
||||
<span v-if="sameCount == 1">One contact is the same as an existing contact</span>
|
||||
<span v-else>{{ sameCount }} contacts are the same as existing contacts</span>
|
||||
</div>
|
||||
|
||||
<!-- Results List -->
|
||||
<ul
|
||||
v-if="contactsImporting.length > sameCount"
|
||||
class="border-t border-slate-300"
|
||||
>
|
||||
<ul v-if="contactsImporting.length > sameCount" class="border-t border-slate-300">
|
||||
<li v-for="(contact, index) in contactsImporting" :key="contact.did">
|
||||
<div
|
||||
v-if="
|
||||
!contactsExisting[contact.did] ||
|
||||
!R.isEmpty(contactDifferences[contact.did])
|
||||
"
|
||||
class="grow overflow-hidden border-b border-slate-300 pt-2.5 pb-4"
|
||||
>
|
||||
<div v-if="
|
||||
!contactsExisting[contact.did] ||
|
||||
!R.isEmpty(contactDifferences[contact.did])
|
||||
" class="grow overflow-hidden border-b border-slate-300 pt-2.5 pb-4">
|
||||
<h2 class="text-base font-semibold">
|
||||
<input v-model="contactsSelected[index]" type="checkbox" />
|
||||
{{ contact.name || AppString.NO_CONTACT_NAME }}
|
||||
-
|
||||
<span v-if="contactsExisting[contact.did]" class="text-orange-500"
|
||||
>Existing</span
|
||||
>
|
||||
<span v-if="contactsExisting[contact.did]" class="text-orange-500">Existing</span>
|
||||
<span v-else class="text-green-500">New</span>
|
||||
</h2>
|
||||
<div class="text-sm truncate">
|
||||
@@ -69,13 +51,9 @@
|
||||
<div class="font-bold">Old Value</div>
|
||||
<div class="font-bold">New Value</div>
|
||||
</div>
|
||||
<div
|
||||
v-for="(value, contactField) in contactDifferences[
|
||||
contact.did
|
||||
]"
|
||||
:key="contactField"
|
||||
class="grid grid-cols-3 border"
|
||||
>
|
||||
<div v-for="(value, contactField) in contactDifferences[
|
||||
contact.did
|
||||
]" :key="contactField" class="grid grid-cols-3 border">
|
||||
<div class="border font-bold p-1">
|
||||
{{ capitalizeAndInsertSpacesBeforeCaps(contactField) }}
|
||||
</div>
|
||||
@@ -88,8 +66,7 @@
|
||||
</li>
|
||||
<button
|
||||
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-sm text-white mt-2 px-2 py-1.5 rounded"
|
||||
@click="importContacts"
|
||||
>
|
||||
@click="importContacts">
|
||||
Import Selected Contacts
|
||||
</button>
|
||||
</ul>
|
||||
@@ -101,18 +78,10 @@
|
||||
get the full text and paste it. (Note that iOS cuts off data in text
|
||||
messages.) Ask the person to send the data a different way, eg. email.
|
||||
<div class="mt-4 text-center">
|
||||
<textarea
|
||||
v-model="inputJwt"
|
||||
placeholder="Contact-import data"
|
||||
class="mt-4 border-2 border-gray-300 p-2 rounded"
|
||||
cols="30"
|
||||
@input="() => checkContactJwt(inputJwt)"
|
||||
/>
|
||||
<textarea v-model="inputJwt" placeholder="Contact-import data"
|
||||
class="mt-4 border-2 border-gray-300 p-2 rounded" cols="30" @input="() => checkContactJwt(inputJwt)" />
|
||||
<br />
|
||||
<button
|
||||
class="ml-2 p-2 bg-blue-500 text-white rounded"
|
||||
@click="() => processContactJwt(inputJwt)"
|
||||
>
|
||||
<button class="ml-2 p-2 bg-blue-500 text-white rounded" @click="() => processContactJwt(inputJwt)">
|
||||
Check Import
|
||||
</button>
|
||||
</div>
|
||||
@@ -122,6 +91,77 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
/**
|
||||
* @file Contact Import View Component
|
||||
* @author Matthew Raymer
|
||||
*
|
||||
* This component handles the import of contacts into the TimeSafari app.
|
||||
* It supports multiple import methods and handles duplicate detection,
|
||||
* contact validation, and visibility settings.
|
||||
*
|
||||
* Import Methods:
|
||||
* 1. Direct URL Query Parameters:
|
||||
* Example: /contact-import?contacts=[{"did":"did:example:123","name":"Alice"}]
|
||||
*
|
||||
* 2. JWT in URL Path:
|
||||
* Example: /contact-import/eyJhbGciOiJFUzI1NksifQ...
|
||||
* - Supports both single and bulk imports
|
||||
* - JWT payload can be either:
|
||||
* a) Array format: { contacts: [{did: "...", name: "..."}, ...] }
|
||||
* b) Single contact: { own: true, did: "...", name: "..." }
|
||||
*
|
||||
* 3. Manual JWT Input:
|
||||
* - Accepts pasted JWT strings
|
||||
* - Validates format and content before processing
|
||||
*
|
||||
* URL Examples:
|
||||
* ```
|
||||
* # Bulk import via query params
|
||||
* /contact-import?contacts=[
|
||||
* {"did":"did:example:123","name":"Alice"},
|
||||
* {"did":"did:example:456","name":"Bob"}
|
||||
* ]
|
||||
*
|
||||
* # Single contact via JWT
|
||||
* /contact-import/eyJhbGciOiJFUzI1NksifQ.eyJvd24iOnRydWUsImRpZCI6ImRpZDpleGFtcGxlOjEyMyJ9...
|
||||
*
|
||||
* # Bulk import via JWT
|
||||
* /contact-import/eyJhbGciOiJFUzI1NksifQ.eyJjb250YWN0cyI6W3siZGlkIjoiZGlkOmV4YW1wbGU6MTIzIn1dfQ...
|
||||
*
|
||||
* # Redirect to contacts page (single contact)
|
||||
* /contacts?contactJwt=eyJhbGciOiJFUzI1NksifQ...
|
||||
* ```
|
||||
*
|
||||
* Features:
|
||||
* - Automatic duplicate detection
|
||||
* - Field-by-field comparison for existing contacts
|
||||
* - Batch visibility settings
|
||||
* - Auto-import for single new contacts
|
||||
* - Error handling and validation
|
||||
*
|
||||
* State Management:
|
||||
* - Tracks existing contacts
|
||||
* - Maintains selection state for bulk imports
|
||||
* - Records differences for duplicate contacts
|
||||
* - Manages visibility settings
|
||||
*
|
||||
* Security Considerations:
|
||||
* - JWT validation for imported contacts
|
||||
* - Visibility control per contact
|
||||
* - Error handling for malformed data
|
||||
*
|
||||
* @example
|
||||
* // Component usage in router
|
||||
* {
|
||||
* path: "/contact-import/:jwt?",
|
||||
* name: "contact-import",
|
||||
* component: ContactImportView
|
||||
* }
|
||||
*
|
||||
* @see {@link Contact} for contact data structure
|
||||
* @see {@link setVisibilityUtil} for visibility management
|
||||
*/
|
||||
|
||||
import * as R from "ramda";
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import { RouteLocationNormalizedLoaded, Router } from "vue-router";
|
||||
@@ -145,24 +185,75 @@ import {
|
||||
import { getContactJwtFromJwtUrl } from "../libs/crypto";
|
||||
import { decodeEndorserJwt } from "../libs/crypto/vc";
|
||||
|
||||
/**
|
||||
* Contact Import View Component
|
||||
* @author Matthew Raymer
|
||||
*
|
||||
* This component handles the secure import of contacts into TimeSafari via JWT tokens.
|
||||
* It supports both single and multiple contact imports with validation and duplicate detection.
|
||||
*
|
||||
* Import Workflows:
|
||||
* 1. JWT in URL Path (/contact-import/[JWT])
|
||||
* - Extracts JWT from path
|
||||
* - Decodes and validates contact data
|
||||
* - Handles both single and multiple contacts
|
||||
*
|
||||
* 2. JWT in Query Parameter (/contacts?contactJwt=[JWT])
|
||||
* - Used for single contact redirects
|
||||
* - Processes JWT from query parameter
|
||||
* - Redirects to appropriate view
|
||||
*
|
||||
* JWT Payload Structure:
|
||||
* ```json
|
||||
* {
|
||||
* "iat": 1740740453,
|
||||
* "contacts": [{
|
||||
* "did": "did:ethr:0x...",
|
||||
* "name": "Optional Name",
|
||||
* "nextPubKeyHashB64": "base64 string",
|
||||
* "publicKeyBase64": "base64 string"
|
||||
* }],
|
||||
* "iss": "did:ethr:0x..."
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* Security Features:
|
||||
* - JWT validation
|
||||
* - Issuer verification
|
||||
* - Duplicate detection
|
||||
* - Contact data validation
|
||||
*
|
||||
* @component
|
||||
*/
|
||||
@Component({
|
||||
components: { EntityIcon, OfferDialog, QuickNav },
|
||||
})
|
||||
export default class ContactImportView extends Vue {
|
||||
/** Notification function injected by Vue */
|
||||
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||||
/** Current route instance */
|
||||
$route!: RouteLocationNormalizedLoaded;
|
||||
/** Router instance for navigation */
|
||||
$router!: Router;
|
||||
|
||||
// Constants
|
||||
AppString = AppString;
|
||||
capitalizeAndInsertSpacesBeforeCaps = capitalizeAndInsertSpacesBeforeCaps;
|
||||
libsUtil = libsUtil;
|
||||
R = R;
|
||||
|
||||
// Component state
|
||||
/** Active user's DID for authentication and visibility settings */
|
||||
activeDid = "";
|
||||
/** API server URL for backend communication */
|
||||
apiServer = "";
|
||||
contactsExisting: Record<string, Contact> = {}; // user's contacts already in the system, keyed by DID
|
||||
contactsImporting: Array<Contact> = []; // contacts from the import
|
||||
contactsSelected: Array<boolean> = []; // whether each contact in contactsImporting is selected
|
||||
/** Map of existing contacts keyed by DID for duplicate detection */
|
||||
contactsExisting: Record<string, Contact> = {};
|
||||
/** Array of contacts being imported from JWT */
|
||||
contactsImporting: Array<Contact> = [];
|
||||
/** Selection state for each importing contact */
|
||||
contactsSelected: Array<boolean> = [];
|
||||
/** Differences between existing and importing contacts */
|
||||
contactDifferences: Record<
|
||||
string,
|
||||
Record<
|
||||
@@ -172,68 +263,117 @@ export default class ContactImportView extends Vue {
|
||||
old: string | boolean | Array<ContactMethod> | undefined;
|
||||
}
|
||||
>
|
||||
> = {}; // for existing contacts, it shows the difference between imported and existing contacts for each key
|
||||
> = {};
|
||||
/** Loading state for import operations */
|
||||
checkingImports = false;
|
||||
/** JWT input for manual contact import */
|
||||
inputJwt: string = "";
|
||||
/** Visibility setting for imported contacts */
|
||||
makeVisible = true;
|
||||
/** Count of duplicate contacts found */
|
||||
sameCount = 0;
|
||||
|
||||
/**
|
||||
* Component lifecycle hook that initializes the contact import process
|
||||
*
|
||||
* This method handles three distinct import scenarios:
|
||||
* 1. Query Parameter Import:
|
||||
* - Checks for contacts in URL query parameters
|
||||
* - Parses JSON array of contacts if present
|
||||
*
|
||||
* 2. JWT URL Import:
|
||||
* - Extracts JWT from URL path using regex pattern '/contact-import/(ey.+)$'
|
||||
* - Decodes JWT without validation (supports future-dated QR codes)
|
||||
* - Handles two JWT payload formats:
|
||||
* a. Array format: payload.contacts or direct array
|
||||
* b. Single contact format: redirects to contacts page with JWT
|
||||
*
|
||||
* 3. Auto-Import Logic:
|
||||
* - Automatically imports if exactly one new contact is present
|
||||
* - Only triggers if no existing contacts match
|
||||
*
|
||||
* @throws Will not throw but logs errors during JWT processing
|
||||
* @emits router.push when redirecting for single contact import
|
||||
*/
|
||||
async created() {
|
||||
await this.initializeSettings();
|
||||
await this.processQueryParams();
|
||||
await this.processJwtFromPath();
|
||||
await this.handleAutoImport();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes component settings from active account
|
||||
*/
|
||||
private async initializeSettings() {
|
||||
const settings = await retrieveSettingsForActiveAccount();
|
||||
this.activeDid = settings.activeDid || "";
|
||||
this.apiServer = settings.apiServer || "";
|
||||
}
|
||||
|
||||
// look for any imported contact array from the query parameter
|
||||
/**
|
||||
* Processes contacts from URL query parameters
|
||||
*/
|
||||
private async processQueryParams() {
|
||||
const importedContacts = this.$route.query["contacts"] as string;
|
||||
if (importedContacts) {
|
||||
await this.setContactsSelected(JSON.parse(importedContacts));
|
||||
}
|
||||
}
|
||||
|
||||
// look for a JWT after /contact-import/ in the window.location.pathname
|
||||
const jwt = window.location.pathname.match(
|
||||
/\/contact-import\/(ey.+)$/,
|
||||
)?.[1];
|
||||
/**
|
||||
* Processes JWT from URL path and handles different JWT formats
|
||||
*/
|
||||
private async processJwtFromPath() {
|
||||
// JWT tokens always start with 'ey' (base64url encoded header)
|
||||
const JWT_PATTERN = /\/contact-import\/(ey.+)$/;
|
||||
const jwt = window.location.pathname.match(JWT_PATTERN)?.[1];
|
||||
|
||||
if (jwt) {
|
||||
// would prefer to validate but we've got an error with JWTs on QR codes generated in the future
|
||||
// eslint-disable-next-line prettier/prettier
|
||||
// const parsedJwt: Omit<JWTVerified, "didResolutionResult" | "signer" | "jwt"> = await decodeAndVerifyJwt(jwt);
|
||||
// decode the JWT
|
||||
const parsedJwt = decodeEndorserJwt(jwt);
|
||||
|
||||
const contacts: Array<Contact> =
|
||||
parsedJwt.payload.contacts || // someday this will be the only payload sent to this page
|
||||
parsedJwt.payload.contacts ||
|
||||
(Array.isArray(parsedJwt.payload) ? parsedJwt.payload : undefined);
|
||||
|
||||
if (!contacts && parsedJwt.payload.own) {
|
||||
// handle this single-contact JWT in the contacts page, better suited to single additions
|
||||
this.$router.push({
|
||||
name: "contacts",
|
||||
query: { contactJwt: jwt },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (contacts) {
|
||||
await this.setContactsSelected(contacts);
|
||||
} else {
|
||||
// no contacts found so default message should be OK
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles automatic import for single new contacts
|
||||
*/
|
||||
private async handleAutoImport() {
|
||||
if (
|
||||
this.contactsImporting.length === 1 &&
|
||||
R.isEmpty(this.contactsExisting)
|
||||
) {
|
||||
// if there is only one contact and it's new, then we will automatically import it
|
||||
this.contactsSelected[0] = true;
|
||||
this.importContacts(); // ... which routes to the contacts list
|
||||
await this.importContacts();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes contacts for import and checks for duplicates
|
||||
* @param contacts Array of contacts to process
|
||||
*/
|
||||
async setContactsSelected(contacts: Array<Contact>) {
|
||||
this.contactsImporting = contacts;
|
||||
this.contactsSelected = new Array(this.contactsImporting.length).fill(true);
|
||||
|
||||
await db.open();
|
||||
const baseContacts = await db.contacts.toArray();
|
||||
// set the existing contacts, keyed by DID, if they exist in contactsImporting
|
||||
|
||||
// Check for existing contacts and differences
|
||||
for (let i = 0; i < this.contactsImporting.length; i++) {
|
||||
const contactIn = this.contactsImporting[i];
|
||||
const existingContact = baseContacts.find(
|
||||
@@ -242,6 +382,7 @@ export default class ContactImportView extends Vue {
|
||||
if (existingContact) {
|
||||
this.contactsExisting[contactIn.did] = existingContact;
|
||||
|
||||
// Compare contact fields for differences
|
||||
const differences: Record<
|
||||
string,
|
||||
{
|
||||
@@ -250,7 +391,6 @@ export default class ContactImportView extends Vue {
|
||||
}
|
||||
> = {};
|
||||
Object.keys(contactIn).forEach((key) => {
|
||||
// eslint-disable-next-line prettier/prettier
|
||||
if (!R.equals(contactIn[key as keyof Contact], existingContact[key as keyof Contact])) {
|
||||
differences[key] = {
|
||||
old: existingContact[key as keyof Contact],
|
||||
@@ -263,13 +403,16 @@ export default class ContactImportView extends Vue {
|
||||
this.sameCount++;
|
||||
}
|
||||
|
||||
// don't automatically import previous data
|
||||
// Don't auto-select duplicates
|
||||
this.contactsSelected[i] = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// check the contact-import JWT
|
||||
/**
|
||||
* Validates contact import JWT format
|
||||
* @param jwtInput JWT string to validate
|
||||
*/
|
||||
async checkContactJwt(jwtInput: string) {
|
||||
if (
|
||||
jwtInput.endsWith(APP_SERVER) ||
|
||||
@@ -289,14 +432,15 @@ export default class ContactImportView extends Vue {
|
||||
}
|
||||
}
|
||||
|
||||
// process the invite JWT and/or text message containing the URL with the JWT
|
||||
/**
|
||||
* Processes contact import JWT and updates contacts
|
||||
* @param jwtInput JWT string containing contact data
|
||||
*/
|
||||
async processContactJwt(jwtInput: string) {
|
||||
this.checkingImports = true;
|
||||
|
||||
try {
|
||||
// (For another approach used with invites, see InviteOneAcceptView.processInvite)
|
||||
const jwt: string = getContactJwtFromJwtUrl(jwtInput);
|
||||
// JWT format: { header, payload, signature, data }
|
||||
const payload = decodeEndorserJwt(jwt).payload;
|
||||
|
||||
if (Array.isArray(payload.contacts)) {
|
||||
@@ -320,10 +464,16 @@ export default class ContactImportView extends Vue {
|
||||
this.checkingImports = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports selected contacts and sets visibility if requested
|
||||
* Updates existing contacts or adds new ones
|
||||
*/
|
||||
async importContacts() {
|
||||
this.checkingImports = true;
|
||||
let importedCount = 0,
|
||||
updatedCount = 0;
|
||||
|
||||
// Process selected contacts
|
||||
for (let i = 0; i < this.contactsImporting.length; i++) {
|
||||
if (this.contactsSelected[i]) {
|
||||
const contact = this.contactsImporting[i];
|
||||
@@ -339,6 +489,8 @@ export default class ContactImportView extends Vue {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set visibility if requested
|
||||
if (this.makeVisible) {
|
||||
const failedVisibileToContacts = [];
|
||||
for (let i = 0; i < this.contactsImporting.length; i++) {
|
||||
@@ -365,9 +517,8 @@ export default class ContactImportView extends Vue {
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Visibility Error",
|
||||
text: `Failed to set visibility for ${failedVisibileToContacts.length} contact${
|
||||
failedVisibileToContacts.length == 1 ? "" : "s"
|
||||
}. You must set them individually: ${failedVisibileToContacts.map((c) => c.name).join(", ")}`,
|
||||
text: `Failed to set visibility for ${failedVisibileToContacts.length} contact${failedVisibileToContacts.length == 1 ? "" : "s"
|
||||
}. You must set them individually: ${failedVisibileToContacts.map((c) => c.name).join(", ")}`,
|
||||
},
|
||||
-1,
|
||||
);
|
||||
@@ -376,6 +527,7 @@ export default class ContactImportView extends Vue {
|
||||
|
||||
this.checkingImports = false;
|
||||
|
||||
// Show success notification
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
|
||||
@@ -568,6 +568,37 @@ import * as serverUtil from "../libs/endorserServer";
|
||||
import { retrieveAccountDids } from "../libs/util";
|
||||
import HiddenDidDialog from "../components/HiddenDidDialog.vue";
|
||||
|
||||
/**
|
||||
* Project View Component
|
||||
* @author Matthew Raymer
|
||||
*
|
||||
* This component displays detailed project information and manages interactions including:
|
||||
* - Project metadata (name, description, dates, location)
|
||||
* - Issuer information and verification
|
||||
* - Project contributions and fulfillments
|
||||
* - Offers and gifts tracking
|
||||
* - Contact interactions
|
||||
*
|
||||
* Data Flow:
|
||||
* 1. Component loads with project ID from route
|
||||
* 2. Fetches project data, contacts, and account settings
|
||||
* 3. Loads related data (offers, gifts, fulfillments)
|
||||
* 4. Updates UI with paginated results
|
||||
*
|
||||
* Security Features:
|
||||
* - DID visibility controls
|
||||
* - JWT validation for imports
|
||||
* - Permission checks for actions
|
||||
*
|
||||
* State Management:
|
||||
* - Maintains separate loading states for different data types
|
||||
* - Handles pagination limits
|
||||
* - Tracks confirmation states
|
||||
*
|
||||
* @see GiftedDialog for gift creation
|
||||
* @see OfferDialog for offer creation
|
||||
* @see HiddenDidDialog for DID privacy explanations
|
||||
*/
|
||||
@Component({
|
||||
components: {
|
||||
EntityIcon,
|
||||
@@ -580,49 +611,103 @@ import HiddenDidDialog from "../components/HiddenDidDialog.vue";
|
||||
},
|
||||
})
|
||||
export default class ProjectViewView extends Vue {
|
||||
/** Notification function injected by Vue */
|
||||
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||||
/** Router instance for navigation */
|
||||
$router!: Router;
|
||||
|
||||
// Account and Settings State
|
||||
/** Currently active DID */
|
||||
activeDid = "";
|
||||
/** Project agent DID */
|
||||
agentDid = "";
|
||||
/** DIDs that can see the agent DID */
|
||||
agentDidVisibleToDids: Array<string> = [];
|
||||
/** All DIDs associated with current account */
|
||||
allMyDids: Array<string> = [];
|
||||
/** All known contacts */
|
||||
allContacts: Array<Contact> = [];
|
||||
/** API server endpoint */
|
||||
apiServer = "";
|
||||
checkingConfirmationForJwtId = "";
|
||||
description = "";
|
||||
endTime = "";
|
||||
expanded = false;
|
||||
fulfilledByThis: PlanSummaryRecord | null = null;
|
||||
fulfillersToThis: Array<PlanSummaryRecord> = [];
|
||||
fulfillersToHitLimit = false;
|
||||
givesToThis: Array<GiveSummaryRecord> = [];
|
||||
givesHitLimit = false;
|
||||
givesProvidedByThis: Array<GiveSummaryRecord> = [];
|
||||
givesProvidedByHitLimit = false;
|
||||
imageUrl = "";
|
||||
/** Registration status of current user */
|
||||
isRegistered = false;
|
||||
|
||||
// Project Data
|
||||
/** Project description */
|
||||
description = "";
|
||||
/** Project end time */
|
||||
endTime = "";
|
||||
/** Text expansion state */
|
||||
expanded = false;
|
||||
/** Project fulfilled by this project */
|
||||
fulfilledByThis: PlanSummaryRecord | null = null;
|
||||
/** Projects fulfilling this project */
|
||||
fulfillersToThis: Array<PlanSummaryRecord> = [];
|
||||
/** Flag for fulfiller pagination */
|
||||
fulfillersToHitLimit = false;
|
||||
/** Project image URL */
|
||||
imageUrl = "";
|
||||
/** Project issuer DID */
|
||||
issuer = "";
|
||||
/** Cached issuer information */
|
||||
issuerInfoObject: {
|
||||
known: boolean;
|
||||
displayName: string;
|
||||
profileImageUrl?: string;
|
||||
} | null = null;
|
||||
/** DIDs that can see issuer information */
|
||||
issuerVisibleToDids: Array<string> = [];
|
||||
/** Project location data */
|
||||
latitude = 0;
|
||||
longitude = 0;
|
||||
/** Project name */
|
||||
name = "";
|
||||
offersToThis: Array<OfferSummaryRecord> = [];
|
||||
offersHitLimit = false;
|
||||
projectId = ""; // handle ID
|
||||
recentlyCheckedAndUnconfirmableJwts: string[] = [];
|
||||
/** Project ID (handle) */
|
||||
projectId = "";
|
||||
/** Project start time */
|
||||
startTime = "";
|
||||
truncatedDesc = "";
|
||||
truncateLength = 40;
|
||||
/** Project URL */
|
||||
url = "";
|
||||
|
||||
// Interaction Data
|
||||
/** Gifts to this project */
|
||||
givesToThis: Array<GiveSummaryRecord> = [];
|
||||
/** Flag for gifts pagination */
|
||||
givesHitLimit = false;
|
||||
/** Gifts from this project */
|
||||
givesProvidedByThis: Array<GiveSummaryRecord> = [];
|
||||
/** Flag for provided gifts pagination */
|
||||
givesProvidedByHitLimit = false;
|
||||
/** Offers to this project */
|
||||
offersToThis: Array<OfferSummaryRecord> = [];
|
||||
/** Flag for offers pagination */
|
||||
offersHitLimit = false;
|
||||
|
||||
// UI State
|
||||
/** JWT being checked for confirmation */
|
||||
checkingConfirmationForJwtId = "";
|
||||
/** Recently checked unconfirmable JWTs */
|
||||
recentlyCheckedAndUnconfirmableJwts: string[] = [];
|
||||
truncatedDesc = "";
|
||||
/** Truncation length */
|
||||
truncateLength = 40;
|
||||
|
||||
// Utility References
|
||||
libsUtil = libsUtil;
|
||||
serverUtil = serverUtil;
|
||||
|
||||
/**
|
||||
* Component lifecycle hook that initializes the project view
|
||||
*
|
||||
* Workflow:
|
||||
* 1. Loads account settings and contacts
|
||||
* 2. Retrieves all account DIDs
|
||||
* 3. Extracts project ID from URL
|
||||
* 4. Initializes project data loading
|
||||
*
|
||||
* @throws Logs errors but continues loading
|
||||
* @emits Notification on profile loading errors
|
||||
*/
|
||||
async created() {
|
||||
const settings = await retrieveSettingsForActiveAccount();
|
||||
this.activeDid = settings.activeDid || "";
|
||||
@@ -656,23 +741,19 @@ export default class ProjectViewView extends Vue {
|
||||
this.loadProject(this.projectId, this.activeDid);
|
||||
}
|
||||
|
||||
onEditClick() {
|
||||
const route = {
|
||||
name: "new-edit-project",
|
||||
query: { projectId: this.projectId },
|
||||
};
|
||||
this.$router.push(route);
|
||||
}
|
||||
|
||||
// Isn't there a better way to make this available to the template?
|
||||
expandText() {
|
||||
this.expanded = true;
|
||||
}
|
||||
|
||||
collapseText() {
|
||||
this.expanded = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads project data and related information
|
||||
*
|
||||
* Workflow:
|
||||
* 1. Fetches project details from API
|
||||
* 2. Updates component state with project data
|
||||
* 3. Initializes related data loading (gifts, offers, fulfillments)
|
||||
*
|
||||
* @param projectId Project handle ID
|
||||
* @param userDid Active user's DID
|
||||
* @throws Logs errors and notifies user
|
||||
* @emits Notification on loading errors
|
||||
*/
|
||||
async loadProject(projectId: string, userDid: string) {
|
||||
this.projectId = projectId;
|
||||
|
||||
@@ -759,6 +840,15 @@ export default class ProjectViewView extends Vue {
|
||||
this.loadPlanFulfilledBy();
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads gifts made to this project
|
||||
*
|
||||
* Handles pagination and updates component state with results.
|
||||
* Uses beforeId for pagination based on last loaded gift.
|
||||
*
|
||||
* @throws Logs errors and notifies user
|
||||
* @emits Notification on loading errors
|
||||
*/
|
||||
async loadGives() {
|
||||
const givesUrl =
|
||||
this.apiServer +
|
||||
@@ -806,6 +896,15 @@ export default class ProjectViewView extends Vue {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads gifts provided by this project
|
||||
*
|
||||
* Similar to loadGives but for outgoing gifts.
|
||||
* Maintains separate pagination state.
|
||||
*
|
||||
* @throws Logs errors and notifies user
|
||||
* @emits Notification on loading errors
|
||||
*/
|
||||
async loadGivesProvidedBy() {
|
||||
const providedByUrl =
|
||||
this.apiServer +
|
||||
@@ -856,6 +955,15 @@ export default class ProjectViewView extends Vue {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads offers made to this project
|
||||
*
|
||||
* Handles pagination and filtering of valid offers.
|
||||
* Updates component state with results.
|
||||
*
|
||||
* @throws Logs errors and notifies user
|
||||
* @emits Notification on loading errors
|
||||
*/
|
||||
async loadOffers() {
|
||||
const offersUrl =
|
||||
this.apiServer +
|
||||
@@ -903,6 +1011,14 @@ export default class ProjectViewView extends Vue {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads projects that fulfill this project
|
||||
*
|
||||
* Manages pagination state and updates component with results.
|
||||
*
|
||||
* @throws Logs errors and notifies user
|
||||
* @emits Notification on loading errors
|
||||
*/
|
||||
async loadPlanFulfillersTo() {
|
||||
const fulfillsUrl =
|
||||
this.apiServer +
|
||||
@@ -951,6 +1067,14 @@ export default class ProjectViewView extends Vue {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads project that this project fulfills
|
||||
*
|
||||
* Updates fulfilledByThis state with result.
|
||||
*
|
||||
* @throws Logs errors and notifies user
|
||||
* @emits Notification on loading errors
|
||||
*/
|
||||
async loadPlanFulfilledBy() {
|
||||
const fulfilledByUrl =
|
||||
this.apiServer +
|
||||
@@ -990,6 +1114,23 @@ export default class ProjectViewView extends Vue {
|
||||
}
|
||||
}
|
||||
|
||||
onEditClick() {
|
||||
const route = {
|
||||
name: "new-edit-project",
|
||||
query: { projectId: this.projectId },
|
||||
};
|
||||
this.$router.push(route);
|
||||
}
|
||||
|
||||
// Isn't there a better way to make this available to the template?
|
||||
expandText() {
|
||||
this.expanded = true;
|
||||
}
|
||||
|
||||
collapseText() {
|
||||
this.expanded = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle clicking on a project entry found in the list
|
||||
* @param id of the project
|
||||
|
||||
Reference in New Issue
Block a user