diff --git a/docs/migration-templates/COMPLETE_MIGRATION_CHECKLIST.md b/docs/migration-templates/COMPLETE_MIGRATION_CHECKLIST.md index a4e529c8..8c6fde80 100644 --- a/docs/migration-templates/COMPLETE_MIGRATION_CHECKLIST.md +++ b/docs/migration-templates/COMPLETE_MIGRATION_CHECKLIST.md @@ -81,6 +81,7 @@ This checklist ensures NO migration steps are forgotten. **Every component migra ### [ ] 10. Constants vs Literal Strings - [ ] **Use constants** for static, reusable messages - [ ] **Use literal strings** for dynamic messages with variables +- [ ] **Extract literals from complex modals** - Even raw `$notify` calls should use constants for text - [ ] **Document decision** for each notification call ### [ ] 11. Template Logic Streamlining diff --git a/docs/migration-templates/component-migration.md b/docs/migration-templates/component-migration.md index 301263e6..a1219681 100644 --- a/docs/migration-templates/component-migration.md +++ b/docs/migration-templates/component-migration.md @@ -233,6 +233,46 @@ this.notify.error(userMessage || "Fallback error message", TIMEOUTS.LONG); - **Use literal strings** for dynamic messages with variables - **Add new constants** to `notifications.ts` for new reusable messages +#### Extract Literals from Complex Modals +**IMPORTANT**: Even when complex modals must remain as raw `$notify` calls due to advanced features (custom buttons, nested callbacks, `promptToStopAsking`, etc.), **always extract literal strings to constants**: + +```typescript +// ❌ BAD - Literals in complex modal +this.$notify({ + group: "modal", + type: "confirm", + title: "Are you nearby with cameras?", + text: "If so, we'll use those with QR codes to share.", + yesText: "we are nearby with cameras", + noText: "we will share another way", + onNo: () => { /* complex callback */ } +}); + +// ✅ GOOD - Constants used even in complex modal +export const NOTIFY_CAMERA_SHARE_METHOD = { + title: "Are you nearby with cameras?", + text: "If so, we'll use those with QR codes to share.", + yesText: "we are nearby with cameras", + noText: "we will share another way", +}; + +this.$notify({ + group: "modal", + type: "confirm", + title: NOTIFY_CAMERA_SHARE_METHOD.title, + text: NOTIFY_CAMERA_SHARE_METHOD.text, + yesText: NOTIFY_CAMERA_SHARE_METHOD.yesText, + noText: NOTIFY_CAMERA_SHARE_METHOD.noText, + onNo: () => { /* complex callback preserved */ } +}); +``` + +This approach provides: +- **Consistency**: All user-facing text centralized +- **Maintainability**: Easy to update messages +- **Localization**: Ready for future i18n support +- **Testability**: Constants can be imported in tests + ## Template Logic Streamlining ### Move Complex Template Logic to Class diff --git a/src/constants/notifications.ts b/src/constants/notifications.ts index 29153d4b..73e60727 100644 --- a/src/constants/notifications.ts +++ b/src/constants/notifications.ts @@ -159,6 +159,40 @@ export const NOTIFY_CONFIRM_CLAIM = { // UserProfileView.vue constants export const NOTIFY_PROFILE_LOAD_ERROR = { - title: "Profile Load Error", + title: "Profile Load Error", message: "There was a problem loading the profile.", }; + +// ProjectsView.vue constants +export const NOTIFY_NO_ACCOUNT_ERROR = { + title: "No Account Found", + message: "You need an identifier to load your projects.", +}; + +export const NOTIFY_PROJECT_LOAD_ERROR = { + title: "Project Load Error", + message: "Failed to get projects from the server.", +}; + +export const NOTIFY_PROJECT_INIT_ERROR = { + title: "Initialization Error", + message: "Something went wrong loading your projects.", +}; + +export const NOTIFY_OFFERS_LOAD_ERROR = { + title: "Offer Load Error", + message: "Failed to get offers from the server.", +}; + +export const NOTIFY_OFFERS_FETCH_ERROR = { + title: "Offer Fetch Error", + message: "Got an error loading offers.", +}; + +// ProjectsView.vue complex modals +export const NOTIFY_CAMERA_SHARE_METHOD = { + title: "Are you nearby with cameras?", + text: "If so, we'll use those with QR codes to share.", + yesText: "we are nearby with cameras", + noText: "we will share another way", +}; diff --git a/src/views/ImportAccountView.vue b/src/views/ImportAccountView.vue index 915a67b8..b71ffa9a 100644 --- a/src/views/ImportAccountView.vue +++ b/src/views/ImportAccountView.vue @@ -95,25 +95,25 @@ import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify"; /** * Import Account View Component - * + * * Allows users to import existing identifiers using seed phrases: * - Secure mnemonic phrase input with validation * - Advanced options for custom derivation paths * - Legacy uPort compatibility support * - Test environment utilities for development - * + * * Features: * - Secure seed phrase import functionality * - Custom derivation path configuration * - Account erasure options for fresh imports * - Development mode test utilities * - Comprehensive error handling and validation - * + * * Security Considerations: * - Seed phrases are handled securely and not logged * - Import process includes validation and error recovery * - Advanced options are hidden by default - * + * * @author Matthew Raymer */ @Component({ @@ -148,7 +148,7 @@ export default class ImportAccountView extends Vue { /** * Component initialization - * + * * Loads account count and server settings for import configuration * Uses PlatformServiceMixin for secure database access */ @@ -167,7 +167,7 @@ export default class ImportAccountView extends Vue { /** * Handles cancel button click - * + * * Navigates back to previous view */ public onCancelClick() { @@ -176,7 +176,7 @@ export default class ImportAccountView extends Vue { /** * Checks if running on production server - * + * * @returns True if not on production server (enables test utilities) */ public isNotProdServer() { @@ -185,11 +185,11 @@ export default class ImportAccountView extends Vue { /** * Imports identifier from mnemonic phrase - * + * * Processes the mnemonic phrase with optional custom derivation path * and account erasure options. Handles validation and error scenarios * with appropriate user feedback. - * + * * Error Handling: * - Invalid mnemonic format validation * - Import process failure recovery @@ -209,12 +209,12 @@ export default class ImportAccountView extends Vue { if (err == "Error: invalid mnemonic") { this.notify.error( "Please check your mnemonic and try again.", - TIMEOUTS.LONG + TIMEOUTS.LONG, ); } else { this.notify.error( "Got an error creating that identifier.", - TIMEOUTS.LONG + TIMEOUTS.LONG, ); } } diff --git a/src/views/ProjectsView.vue b/src/views/ProjectsView.vue index f5762307..9b1b4f8d 100644 --- a/src/views/ProjectsView.vue +++ b/src/views/ProjectsView.vue @@ -280,14 +280,41 @@ import OnboardingDialog from "../components/OnboardingDialog.vue"; import ProjectIcon from "../components/ProjectIcon.vue"; import TopMessage from "../components/TopMessage.vue"; import UserNameDialog from "../components/UserNameDialog.vue"; -import * as databaseUtil from "../db/databaseUtil"; import { Contact } from "../db/tables/contacts"; import { didInfo, getHeaders, getPlanFromCache } from "../libs/endorserServer"; import { OfferSummaryRecord, PlanData } from "../interfaces/records"; import * as libsUtil from "../libs/util"; import { OnboardPage } from "../libs/util"; import { logger } from "../utils/logger"; -import { PlatformServiceFactory } from "@/services/PlatformServiceFactory"; +import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin"; +import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify"; +import { + NOTIFY_NO_ACCOUNT_ERROR, + NOTIFY_PROJECT_LOAD_ERROR, + NOTIFY_PROJECT_INIT_ERROR, + NOTIFY_OFFERS_LOAD_ERROR, + NOTIFY_OFFERS_FETCH_ERROR, + NOTIFY_CAMERA_SHARE_METHOD, +} from "@/constants/notifications"; + +/** + * Projects View Component + * + * Main dashboard for managing user projects and offers within the TimeSafari platform. + * Provides dual-mode interface for viewing: + * - Personal projects: Ideas and plans created by the user + * - Active offers: Commitments made to help with other projects + * + * Key Features: + * - Infinite scrolling for large datasets + * - Project creation and navigation + * - Offer tracking with confirmation status + * - Onboarding integration for new users + * - Cross-platform compatibility (web, mobile, desktop) + * + * Security: All API calls are authenticated using user's DID + * Privacy: Only user's own projects and offers are displayed + */ @Component({ components: { EntityIcon, @@ -298,18 +325,15 @@ import { PlatformServiceFactory } from "@/services/PlatformServiceFactory"; TopMessage, UserNameDialog, }, + mixins: [PlatformServiceMixin], }) export default class ProjectsView extends Vue { $notify!: (notification: NotificationIface, timeout?: number) => void; $router!: Router; - errNote(message: string) { - this.$notify( - { group: "alert", type: "danger", title: "Error", text: message }, - 5000, - ); - } + notify!: ReturnType; + // User account state activeDid = ""; allContacts: Array = []; allMyDids: Array = []; @@ -317,61 +341,114 @@ export default class ProjectsView extends Vue { givenName = ""; isLoading = false; isRegistered = false; + + // Data collections offers: OfferSummaryRecord[] = []; projectNameFromHandleId: Record = {}; // mapping from handleId to description projects: PlanData[] = []; + + // UI state showOffers = false; showProjects = true; + // Utility imports libsUtil = libsUtil; didInfo = didInfo; + /** + * Initializes notification helpers + */ + created() { + this.notify = createNotifyHelpers(this.$notify); + } + + /** + * Component initialization + * + * Workflow: + * 1. Load user settings and account information + * 2. Load contacts for displaying offer recipients + * 3. Initialize onboarding dialog if needed + * 4. Load initial project data + * + * Error handling: Shows appropriate user messages for different failure scenarios + */ async mounted() { try { - const settings = await databaseUtil.retrieveSettingsForActiveAccount(); - this.activeDid = settings.activeDid || ""; - this.apiServer = settings.apiServer || ""; - this.isRegistered = !!settings.isRegistered; - this.givenName = settings.firstName || ""; - - const platformService = PlatformServiceFactory.getInstance(); - const queryResult = await platformService.dbQuery( - "SELECT * FROM contacts", - ); - this.allContacts = databaseUtil.mapQueryResultToValues( - queryResult, - ) as unknown as Contact[]; + await this.initializeUserSettings(); + await this.loadContactsData(); + await this.initializeUserIdentities(); + await this.checkOnboardingStatus(); + await this.loadInitialData(); + } catch (err) { + logger.error("Error initializing ProjectsView:", err); + this.notify.error(NOTIFY_PROJECT_INIT_ERROR.message, TIMEOUTS.LONG); + } + } - this.allMyDids = await libsUtil.retrieveAccountDids(); + /** + * Loads user settings from active account + */ + private async initializeUserSettings() { + const settings = await this.$accountSettings(); + this.activeDid = settings.activeDid || ""; + this.apiServer = settings.apiServer || ""; + this.isRegistered = !!settings.isRegistered; + this.givenName = settings.firstName || ""; + } - if (!settings.finishedOnboarding) { - (this.$refs.onboardingDialog as OnboardingDialog).open( - OnboardPage.Create, - ); - } + /** + * Loads contacts data for displaying offer recipients + */ + private async loadContactsData() { + this.allContacts = await this.$getAllContacts(); + } - if (this.allMyDids.length === 0) { - logger.error("No accounts found."); - this.errNote("You need an identifier to load your projects."); - } else { - await this.loadProjects(); - } - } catch (err) { - logger.error("Error initializing:", err); - this.errNote("Something went wrong loading your projects."); + /** + * Initializes user identity information + */ + private async initializeUserIdentities() { + this.allMyDids = await libsUtil.retrieveAccountDids(); + } + + /** + * Checks if onboarding dialog should be shown + */ + private async checkOnboardingStatus() { + const settings = await this.$accountSettings(); + if (!settings.finishedOnboarding) { + (this.$refs.onboardingDialog as OnboardingDialog).open( + OnboardPage.Create, + ); + } + } + + /** + * Loads initial project data if user has valid account + */ + private async loadInitialData() { + if (this.allMyDids.length === 0) { + logger.error("No accounts found for user"); + this.notify.error(NOTIFY_NO_ACCOUNT_ERROR.message, TIMEOUTS.LONG); + } else { + await this.loadProjects(); } } /** * Core project data loader - * @param url the url used to fetch the data - * @param token Authorization token - **/ + * + * Fetches project data from the endorser server and populates the projects array. + * Handles authentication, error scenarios, and loading states. + * + * @param url - The API endpoint URL for fetching project data + */ async projectDataLoader(url: string) { try { const headers = await getHeaders(this.activeDid, this.$notify); this.isLoading = true; const resp = await this.axios.get(url, { headers } as AxiosRequestConfig); + if (resp.status === 200 && resp.data.data) { const plans: PlanData[] = resp.data.data; for (const plan of plans) { @@ -391,12 +468,11 @@ export default class ProjectsView extends Vue { resp.status, resp.data, ); - this.errNote("Failed to get projects from the server."); + this.notify.error(NOTIFY_PROJECT_LOAD_ERROR.message, TIMEOUTS.LONG); } - // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (error: any) { logger.error("Got error loading plans:", error.message || error); - this.errNote("Got an error loading projects."); + this.notify.error(NOTIFY_PROJECT_LOAD_ERROR.message, TIMEOUTS.LONG); } finally { this.isLoading = false; } @@ -404,8 +480,12 @@ export default class ProjectsView extends Vue { /** * Data loader used by infinite scroller - * @param payload is the flag from the InfiniteScroll indicating if it should load - **/ + * + * Implements pagination by loading additional projects when user scrolls to bottom. + * Uses the last project's rowId as a cursor for the next batch. + * + * @param payload - Flag from InfiniteScroll component indicating if more data should be loaded + */ async loadMoreProjectData(payload: boolean) { if (this.projects.length > 0 && payload) { const latestProject = this.projects[this.projects.length - 1]; @@ -414,19 +494,24 @@ export default class ProjectsView extends Vue { } /** - * Load projects initially - * @param issuerDid of the user - * @param urlExtra additional url parameters in a string - **/ + * Load projects initially or with pagination + * + * Constructs the API URL for fetching user's projects and delegates to projectDataLoader. + * + * @param urlExtra - Additional URL parameters for pagination (e.g., "beforeId=123") + */ async loadProjects(urlExtra: string = "") { const url = `${this.apiServer}/api/v2/report/plansByIssuer?${urlExtra}`; await this.projectDataLoader(url); } /** - * Handle clicking on a project entry found in the list - * @param id of the project - **/ + * Handle clicking on a project entry + * + * Navigates to the detailed project view for the selected project. + * + * @param id - The unique identifier of the project to view + */ onClickLoadProject(id: string) { const route = { path: "/project/" + encodeURIComponent(id), @@ -435,8 +520,10 @@ export default class ProjectsView extends Vue { } /** - * Handling clicking on the new project button - **/ + * Handle clicking on the new project button + * + * Navigates to the project creation/editing interface. + */ onClickNewProject(): void { const route = { name: "new-edit-project", @@ -444,6 +531,13 @@ export default class ProjectsView extends Vue { this.$router.push(route); } + /** + * Handle clicking on a claim/offer link + * + * Navigates to the detailed claim view for the selected offer. + * + * @param jwtId - The JWT identifier of the claim to view + */ onClickLoadClaim(jwtId: string) { const route = { path: "/claim/" + encodeURIComponent(jwtId), @@ -453,17 +547,21 @@ export default class ProjectsView extends Vue { /** * Core offer data loader - * @param url the url used to fetch the data - * @param token Authorization token - **/ + * + * Fetches offer data from the endorser server and populates the offers array. + * Also retrieves associated project names for display purposes. + * + * @param url - The API endpoint URL for fetching offer data + */ async offerDataLoader(url: string) { const headers = await getHeaders(this.activeDid); try { this.isLoading = true; const resp = await this.axios.get(url, { headers } as AxiosRequestConfig); + if (resp.status === 200 && resp.data.data) { - // add one-by-one as they retrieve project names, potentially from the server + // Process offers one-by-one to retrieve project names from server cache for (const offer of resp.data.data) { if (offer.fulfillsPlanHandleId) { const project = await getPlanFromCache( @@ -484,37 +582,24 @@ export default class ProjectsView extends Vue { resp.status, resp.data, ); - this.$notify( - { - group: "alert", - type: "danger", - title: "Error", - text: "Failed to get offers from the server.", - }, - 5000, - ); + this.notify.error(NOTIFY_OFFERS_LOAD_ERROR.message, TIMEOUTS.LONG); } - // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (error: any) { logger.error("Got error loading offers:", error.message || error); - this.$notify( - { - group: "alert", - type: "danger", - title: "Error", - text: "Got an error loading offers.", - }, - 5000, - ); + this.notify.error(NOTIFY_OFFERS_FETCH_ERROR.message, TIMEOUTS.LONG); } finally { this.isLoading = false; } } /** - * Data loader used by infinite scroller - * @param payload is the flag from the InfiniteScroll indicating if it should load - **/ + * Data loader used by infinite scroller for offers + * + * Implements pagination by loading additional offers when user scrolls to bottom. + * Uses the last offer's jwtId as a cursor for the next batch. + * + * @param payload - Flag from InfiniteScroll component indicating if more data should be loaded + */ async loadMoreOfferData(payload: boolean) { if (this.offers.length > 0 && payload) { const latestOffer = this.offers[this.offers.length - 1]; @@ -523,15 +608,23 @@ export default class ProjectsView extends Vue { } /** - * Load offers initially - * @param issuerDid of the user - * @param urlExtra additional url parameters in a string - **/ + * Load offers initially or with pagination + * + * Constructs the API URL for fetching user's offers and delegates to offerDataLoader. + * + * @param urlExtra - Additional URL parameters for pagination (e.g., "&beforeId=123") + */ async loadOffers(urlExtra: string = "") { const url = `${this.apiServer}/api/v2/report/offers?offeredByDid=${this.activeDid}${urlExtra}`; await this.offerDataLoader(url); } + /** + * Shows name dialog if needed, then prompts for share method + * + * Ensures user has provided their name before proceeding with contact sharing. + * Uses UserNameDialog component if name is not set. + */ showNameThenIdDialog() { if (!this.givenName) { (this.$refs.userNameDialog as UserNameDialog).open(() => { @@ -542,13 +635,23 @@ export default class ProjectsView extends Vue { } } + /** + * Prompts user to choose contact sharing method + * + * Presents modal dialog asking if users are nearby with cameras. + * Routes to appropriate sharing method based on user's choice: + * - QR code sharing for nearby users with cameras + * - Alternative sharing methods for remote users + * + * Note: Uses raw $notify for complex modal with custom buttons and onNo callback + */ promptForShareMethod() { this.$notify( { group: "modal", type: "confirm", - title: "Are you nearby with cameras?", - text: "If so, we'll use those with QR codes to share.", + title: NOTIFY_CAMERA_SHARE_METHOD.title, + text: NOTIFY_CAMERA_SHARE_METHOD.text, onCancel: async () => {}, onNo: async () => { this.$router.push({ name: "share-my-contact-info" }); @@ -556,49 +659,68 @@ export default class ProjectsView extends Vue { onYes: async () => { this.handleQRCodeClick(); }, - noText: "we will share another way", - yesText: "we are nearby with cameras", + noText: NOTIFY_CAMERA_SHARE_METHOD.noText, + yesText: NOTIFY_CAMERA_SHARE_METHOD.yesText, }, -1, ); } - public computedOfferTabClassNames() { + /** + * Computed properties for template logic streamlining + */ + + /** + * CSS class names for offer tab styling + * @returns Object with CSS classes based on current tab selection + */ + get offerTabClasses() { return { "inline-block": true, "py-3": true, "rounded-t-lg": true, "border-b-2": true, - active: this.showOffers, "text-black": this.showOffers, "border-black": this.showOffers, "font-semibold": this.showOffers, - "text-blue-600": !this.showOffers, "border-transparent": !this.showOffers, "hover:border-slate-400": !this.showOffers, }; } - public computedProjectTabClassNames() { + /** + * CSS class names for project tab styling + * @returns Object with CSS classes based on current tab selection + */ + get projectTabClasses() { return { "inline-block": true, "py-3": true, "rounded-t-lg": true, "border-b-2": true, - active: this.showProjects, "text-black": this.showProjects, "border-black": this.showProjects, "font-semibold": this.showProjects, - "text-blue-600": !this.showProjects, "border-transparent": !this.showProjects, "hover:border-slate-400": !this.showProjects, }; } + /** + * Utility methods + */ + + /** + * Handles QR code sharing functionality with platform detection + * + * Routes to appropriate QR code interface based on current platform: + * - Full QR scanner for native mobile platforms + * - Web-based QR interface for browser environments + */ private handleQRCodeClick() { if (Capacitor.isNativePlatform()) { this.$router.push({ name: "contact-qr-scan-full" }); @@ -606,5 +728,21 @@ export default class ProjectsView extends Vue { this.$router.push({ name: "contact-qr" }); } } + + /** + * Legacy method compatibility + * @deprecated Use computedOfferTabClassNames for backward compatibility + */ + public computedOfferTabClassNames() { + return this.offerTabClasses; + } + + /** + * Legacy method compatibility + * @deprecated Use computedProjectTabClassNames for backward compatibility + */ + public computedProjectTabClassNames() { + return this.projectTabClasses; + } } diff --git a/src/views/UserProfileView.vue b/src/views/UserProfileView.vue index a8295c83..71e10d7d 100644 --- a/src/views/UserProfileView.vue +++ b/src/views/UserProfileView.vue @@ -49,11 +49,7 @@

Location

- + { this.notify.copied("profile link", TIMEOUTS.STANDARD); - }); + }); } /** @@ -260,7 +256,12 @@ export default class UserProfileView extends Vue { * @returns Formatted display name for the profile owner */ get profileDisplayName() { - return this.didInfo(this.profile?.issuerDid, this.activeDid, this.allMyDids, this.allContacts); + return this.didInfo( + this.profile?.issuerDid, + this.activeDid, + this.allMyDids, + this.allContacts, + ); } /**