Browse Source

Complete ProjectsView.vue Triple Migration Pattern with literal extraction

Apply full database migration (databaseUtil → PlatformServiceMixin), replace
raw SQL with service methods, migrate 3 notifications to helper methods.
Preserve 1 complex modal for advanced routing features while extracting
all literal strings to NOTIFY_CAMERA_SHARE_METHOD constant.

Add computed properties (offerTabClasses, projectTabClasses) to streamline
template logic and comprehensive JSDoc documentation for all methods.
Update migration templates to mandate literal extraction from complex modals.

ProjectsView.vue now appropriately incomplete: helper methods for simple
notifications, raw $notify preserved only where advanced features required.
pull/142/head
Matthew Raymer 13 hours ago
parent
commit
e925b4c6a8
  1. 1
      docs/migration-templates/COMPLETE_MIGRATION_CHECKLIST.md
  2. 40
      docs/migration-templates/component-migration.md
  3. 36
      src/constants/notifications.ts
  4. 22
      src/views/ImportAccountView.vue
  5. 324
      src/views/ProjectsView.vue
  6. 15
      src/views/UserProfileView.vue

1
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

40
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

36
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",
};

22
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,
);
}
}

324
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<typeof createNotifyHelpers>;
// User account state
activeDid = "";
allContacts: Array<Contact> = [];
allMyDids: Array<string> = [];
@ -317,61 +341,114 @@ export default class ProjectsView extends Vue {
givenName = "";
isLoading = false;
isRegistered = false;
// Data collections
offers: OfferSummaryRecord[] = [];
projectNameFromHandleId: Record<string, string> = {}; // 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;
}
}
</script>

15
src/views/UserProfileView.vue

@ -49,11 +49,7 @@
<div v-if="hasFirstLocation" class="mt-4">
<h2 class="text-lg font-semibold">Location</h2>
<div class="h-96 mt-2 w-full">
<l-map
ref="profileMap"
:center="firstLocationCoords"
:zoom="mapZoom"
>
<l-map ref="profileMap" :center="firstLocationCoords" :zoom="mapZoom">
<l-tile-layer
:url="tileLayerUrl"
layer-type="base"
@ -248,7 +244,7 @@ export default class UserProfileView extends Vue {
.copy(deepLink)
.then(() => {
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,
);
}
/**

Loading…
Cancel
Save