Compare commits

...

19 Commits

Author SHA1 Message Date
06fdaff879 Merge pull request 'entitygrid-infinite-scroll-improvements' (#223) from entitygrid-infinite-scroll-improvements into master
Reviewed-on: #223
2025-11-18 06:56:55 +00:00
8024a3d02a Merge pull request 'meeting-project-dialog' (#222) from meeting-project-dialog into master
Reviewed-on: #222
2025-11-18 06:56:23 +00:00
Jose Olarte III
223031866b refactor: remove unused loadMoreCallback prop from EntityGrid
Remove loadMoreCallback prop and related backward compatibility code.
No parent components were using this prop, and it has been superseded
by the internal pagination mechanism using fetchProjects() and beforeId.
2025-11-17 19:58:55 +08:00
Jose Olarte III
cb75b25529 refactor: consolidate project loading into EntityGrid component
Unify project loading and searching logic in EntityGrid.vue to eliminate
duplication. Make entities prop optional for projects, add internal
project state, and auto-load projects when needed.

- EntityGrid: Combine search/load into fetchProjects(), add internal
  allProjects state, handle pagination internally for both search and
  load modes
- OnboardMeetingSetupView: Remove project loading methods
- MeetingProjectDialog: Remove project props
- GiftedDialog: Remove project loading logic
- EntitySelectionStep: Make projects prop optional

Reduces code duplication by ~150 lines and simplifies component APIs.
All project selection now uses EntityGrid's internal loading.
2025-11-17 19:49:17 +08:00
83b470e28a fix: link from DID page to Help 2025-11-16 15:35:19 -07:00
Jose Olarte III
acf104eaa7 refactor: remove debug loggers from EntityGrid component
Remove three logger.debug() calls used for debugging project search
results and pagination state. Error logging remains intact.
2025-11-13 21:41:34 +08:00
Jose Olarte III
e793d7a9e2 refactor: defer project loading until MeetingProjectDialog opens
- Move loadProjects() call from created() to handleDialogOpen()
- Remove allProjects check from ensureSelectedProjectLoaded()
- Projects now load only when dialog is opened, improving initial page load performance
- ensureSelectedProjectLoaded() now directly fetches project by handleId when needed
2025-11-13 21:28:47 +08:00
Jose Olarte III
3ecae0be0f refactor(OnboardMeetingSetupView): fix selected project display after refresh
Refactor selectedProject computation to use separate storage instead of
relying on allProjects array. This fixes a bug where the selected project
wouldn't display after page refresh if it wasn't in the initial allProjects
batch.

Changes:
- Add selectedProjectData property to store selected project independently
- Simplify selectedProject computed to return selectedProjectData directly
- Add fetchProjectByHandleId() to fetch single project by handleId
- Add ensureSelectedProjectLoaded() to check allProjects first, then fetch
- Update handleProjectLinkAssigned() to store directly in selectedProjectData
- Remove band-aid solution of adding selected projects to allProjects array
- Update startEditing() and cancelEditing() to ensure selected project loads
- Call ensureSelectedProjectLoaded() in created() lifecycle hook

This ensures the selected project always displays correctly, even when:
- Selected from search results (not in allProjects)
- Page is refreshed (allProjects reloads without selected project)
- Project is in a later pagination batch
2025-11-13 19:21:22 +08:00
Jose Olarte III
d37e53b1a9 fix: pause MembersList auto-refresh during project dialog interaction
Stop auto-refresh when MeetingProjectDialog opens and resume when it closes
to prevent UI conflicts during project selection.
2025-11-13 18:10:35 +08:00
Jose Olarte III
2f89c7e13b feat(EntityGrid): add server-side search with pagination for projects
Implement server-side search for projects using API endpoint with
pagination support via beforeId parameter. Contacts continue using
client-side filtering from complete local database.

- Add PlatformServiceMixin for internal apiServer access
- Implement performProjectSearch() with pagination
- Update infinite scroll to handle search pagination
- Add search lifecycle management and error handling

No breaking changes to parent components.
2025-11-12 21:06:20 +08:00
Jose Olarte III
6bf4055c2f feat: add pagination support for project lists in dialogs
Add server-side pagination to EntityGrid component for projects, enabling
infinite scrolling to load all available projects instead of stopping after
the initial batch.

Changes:
- EntityGrid: Add loadMoreCallback prop to trigger server-side loading when
  scroll reaches end of loaded projects
- OnboardMeetingSetupView: Update loadProjects() to support pagination with
  beforeId parameter and add handleLoadMoreProjects() callback
- MeetingProjectDialog: Accept and pass through loadMoreCallback to EntityGrid
- GiftedDialog: Add pagination support to loadProjects() and
  handleLoadMoreProjects() callback
- EntitySelectionStep: Accept and pass through loadMoreCallback prop to
  EntityGrid when showing projects

This ensures users can access all projects in MeetingProjectDialog and
GiftedDialog by automatically loading more as they scroll, matching the
behavior already present in DiscoverView.

All project uses of EntityGrid now use pagination by default.
2025-11-12 17:10:03 +08:00
Jose Olarte III
bf7ee630d0 feat(meeting): enable selecting all projects in meeting setup
Update loadProjects to fetch all projects instead of only user's projects
by switching from plansByIssuer to plans endpoint.
2025-11-12 15:58:23 +08:00
1739567b18 Merge pull request 'feat: replace authorized representative input with contact selection dialog' (#219) from project-representative-dialog into master
Reviewed-on: #219
2025-11-12 01:42:48 -05:00
Jose Olarte III
a5a9af5ddc feat(meetings): add project selection dialog for meeting setup
Replace Project Link text input with interactive selection dialog
using new MeetingProjectDialog component. Dialog displays user's
projects with icons and issuer information, following the same
pattern as ProjectRepresentativeDialog.

- Create MeetingProjectDialog with EntityGrid integration
- Add clickable project field with icon, name, and issuer display
- Load projects from /api/v2/report/plansByIssuer endpoint
- Show issuer name instead of handleId for better UX
- Refactor loadProjects to remove unused rowId field
2025-11-11 21:34:11 +08:00
5050156beb fix: a type, plus add the type-check to the mobile build scripts 2025-11-08 08:31:42 -07:00
d265a9f78c chore: bump version and add "-beta" 2025-11-06 08:56:33 -07:00
f848de15f1 chore: bump version to 1.1.2 build 47 (for fix to seed backup) 2025-11-06 08:54:11 -07:00
ebaf2dedf0 Merge pull request 'fix: database connection error causing navigation redirect on iOS/Android' (#220) from fix-sqlite-connection-error-mobile into master
Reviewed-on: #220
2025-11-06 09:52:31 -05:00
Jose Olarte III
749204f96b fix: database connection error causing navigation redirect on iOS/Android
Handle "Connection already exists" error when initializing SQLite database
on Capacitor platforms. The native connection can persist across app
restarts while the JavaScript connection Map is empty, causing a mismatch.

When createConnection fails with "already exists":
- Check if connection exists in JavaScript Map and retrieve it if present
- If not in Map, close the native connection and recreate to sync both sides
- Handle "already open" errors gracefully when opening existing connections

This fixes the issue where clicking "Backup Identifier Seed" would redirect
to StartView instead of SeedBackupView due to database initialization
failures in the router navigation guard.

Fixes navigation issue on both iOS and Android platforms.
2025-11-06 21:38:51 +08:00
17 changed files with 942 additions and 163 deletions

View File

@@ -1151,28 +1151,28 @@ If you need to build manually or want to understand the individual steps:
- ... and you may have to fix these, especially with pkgx: - ... and you may have to fix these, especially with pkgx:
```bash ```bash
gem_path=$(which gem) gem_path=$(which gem)
shortened_path="${gem_path:h:h}" shortened_path="${gem_path:h:h}"
export GEM_HOME=$shortened_path export GEM_HOME=$shortened_path
export GEM_PATH=$shortened_path export GEM_PATH=$shortened_path
``` ```
##### 1. Bump the version in package.json for `MARKETING_VERSION`, then `grep CURRENT_PROJECT_VERSION ios/App/App.xcodeproj/project.pbxproj` and add 1 for the numbered version; ##### 1. Bump the version in package.json for `MARKETING_VERSION`, then `grep CURRENT_PROJECT_VERSION ios/App/App.xcodeproj/project.pbxproj` and add 1 for the numbered version;
```bash ```bash
cd ios/App && xcrun agvtool new-version 46 && perl -p -i -e "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 1.1.1;/g" App.xcodeproj/project.pbxproj && cd - cd ios/App && xcrun agvtool new-version 47 && perl -p -i -e "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 1.1.2;/g" App.xcodeproj/project.pbxproj && cd -
# Unfortunately this edits Info.plist directly. # Unfortunately this edits Info.plist directly.
#xcrun agvtool new-marketing-version 0.4.5 #xcrun agvtool new-marketing-version 0.4.5
``` ```
##### 2. Build ##### 2. Build
Here's prod. Also available: test, dev Here's prod. Also available: test, dev
```bash ```bash
npm run build:ios:prod npm run build:ios:prod
``` ```
3.1. Use Xcode to build and run on simulator or device. 3.1. Use Xcode to build and run on simulator or device.
@@ -1197,7 +1197,8 @@ If you need to build manually or want to understand the individual steps:
- It can take 15 minutes for the build to show up in the list of builds. - It can take 15 minutes for the build to show up in the list of builds.
- You'll probably have to "Manage" something about encryption, disallowed in France. - You'll probably have to "Manage" something about encryption, disallowed in France.
- Then "Save" and "Add to Review" and "Resubmit to App Review". - Then "Save" and "Add to Review" and "Resubmit to App Review".
- Eventually it'll be "Ready for Distribution" which means - Eventually it'll be "Ready for Distribution" which means it's live
- When finished, bump package.json version
### Android Build ### Android Build
@@ -1315,26 +1316,26 @@ The recommended way to build for Android is using the automated build script:
#### Android Manual Build Process #### Android Manual Build Process
##### 1. Bump the version in package.json, then here: android/app/build.gradle ##### 1. Bump the version in package.json, then update these versions & run:
```bash ```bash
perl -p -i -e 's/versionCode .*/versionCode 46/g' android/app/build.gradle perl -p -i -e 's/versionCode .*/versionCode 47/g' android/app/build.gradle
perl -p -i -e 's/versionName .*/versionName "1.1.1"/g' android/app/build.gradle perl -p -i -e 's/versionName .*/versionName "1.1.2"/g' android/app/build.gradle
``` ```
##### 2. Build ##### 2. Build
Here's prod. Also available: test, dev Here's prod. Also available: test, dev
```bash ```bash
npm run build:android:prod npm run build:android:prod
``` ```
##### 3. Open the project in Android Studio ##### 3. Open the project in Android Studio
```bash ```bash
npx cap open android npx cap open android
``` ```
##### 4. Use Android Studio to build and run on emulator or device ##### 4. Use Android Studio to build and run on emulator or device
@@ -1379,6 +1380,8 @@ At play.google.com/console:
- Note that if you add testers, you have to go to "Publishing Overview" and send - Note that if you add testers, you have to go to "Publishing Overview" and send
those changes or your (closed) testers won't see it. those changes or your (closed) testers won't see it.
- When finished, bump package.json version
### Capacitor Operations ### Capacitor Operations
```bash ```bash

View File

@@ -6,6 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.1.2] - 2025.11.06
### Fixed
- Bad page when user follows prompt to backup seed
## [1.1.1] - 2025.11.03 ## [1.1.1] - 2025.11.03
### Added ### Added

View File

@@ -31,8 +31,8 @@ android {
applicationId "app.timesafari.app" applicationId "app.timesafari.app"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 46 versionCode 47
versionName "1.1.1" versionName "1.1.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions { aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.

View File

@@ -403,7 +403,7 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 46; CURRENT_PROJECT_VERSION = 47;
DEVELOPMENT_TEAM = GM3FS5JQPH; DEVELOPMENT_TEAM = GM3FS5JQPH;
ENABLE_APP_SANDBOX = NO; ENABLE_APP_SANDBOX = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO; ENABLE_USER_SCRIPT_SANDBOXING = NO;
@@ -413,7 +413,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.1.1; MARKETING_VERSION = 1.1.2;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari; PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
@@ -430,7 +430,7 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 46; CURRENT_PROJECT_VERSION = 47;
DEVELOPMENT_TEAM = GM3FS5JQPH; DEVELOPMENT_TEAM = GM3FS5JQPH;
ENABLE_APP_SANDBOX = NO; ENABLE_APP_SANDBOX = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO; ENABLE_USER_SCRIPT_SANDBOXING = NO;
@@ -440,7 +440,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.1.1; MARKETING_VERSION = 1.1.2;
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari; PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "timesafari", "name": "timesafari",
"version": "1.1.2-beta", "version": "1.1.3-beta",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "timesafari", "name": "timesafari",
"version": "1.1.2-beta", "version": "1.1.3-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.2-beta", "version": "1.1.3-beta",
"description": "Time Safari Application", "description": "Time Safari Application",
"author": { "author": {
"name": "Time Safari Team" "name": "Time Safari Team"

View File

@@ -436,7 +436,21 @@ fi
log_info "Cleaning dist directory..." log_info "Cleaning dist directory..."
clean_build_artifacts "dist" clean_build_artifacts "dist"
# Step 4: Build Capacitor version with mode # Step 4: Run TypeScript type checking for test and production builds
if [ "$BUILD_MODE" = "production" ] || [ "$BUILD_MODE" = "test" ]; then
log_info "Running TypeScript type checking for $BUILD_MODE mode..."
if ! measure_time npm run type-check; then
log_error "TypeScript type checking failed for $BUILD_MODE mode!"
exit 2
fi
log_success "TypeScript type checking completed for $BUILD_MODE mode"
else
log_debug "Skipping TypeScript type checking for development mode"
fi
# Step 5: Build Capacitor version with mode
if [ "$BUILD_MODE" = "development" ]; then if [ "$BUILD_MODE" = "development" ]; then
safe_execute "Building Capacitor version (development)" "npm run build:capacitor" || exit 3 safe_execute "Building Capacitor version (development)" "npm run build:capacitor" || exit 3
elif [ "$BUILD_MODE" = "test" ]; then elif [ "$BUILD_MODE" = "test" ]; then
@@ -445,23 +459,23 @@ elif [ "$BUILD_MODE" = "production" ]; then
safe_execute "Building Capacitor version (production)" "npm run build:capacitor -- --mode production" || exit 3 safe_execute "Building Capacitor version (production)" "npm run build:capacitor -- --mode production" || exit 3
fi fi
# Step 5: Clean Gradle build # Step 6: Clean Gradle build
safe_execute "Cleaning Gradle build" "cd android && ./gradlew clean && cd .." || exit 4 safe_execute "Cleaning Gradle build" "cd android && ./gradlew clean && cd .." || exit 4
# Step 6: Build based on type # Step 7: Build based on type
if [ "$BUILD_TYPE" = "debug" ]; then if [ "$BUILD_TYPE" = "debug" ]; then
safe_execute "Assembling debug build" "cd android && ./gradlew assembleDebug && cd .." || exit 5 safe_execute "Assembling debug build" "cd android && ./gradlew assembleDebug && cd .." || exit 5
elif [ "$BUILD_TYPE" = "release" ]; then elif [ "$BUILD_TYPE" = "release" ]; then
safe_execute "Assembling release build" "cd android && ./gradlew assembleRelease && cd .." || exit 5 safe_execute "Assembling release build" "cd android && ./gradlew assembleRelease && cd .." || exit 5
fi fi
# Step 7: Sync with Capacitor # Step 8: Sync with Capacitor
safe_execute "Syncing with Capacitor" "npx cap sync android" || exit 6 safe_execute "Syncing with Capacitor" "npx cap sync android" || exit 6
# Step 8: Generate assets # Step 9: Generate assets
safe_execute "Generating assets" "npx capacitor-assets generate --android" || exit 7 safe_execute "Generating assets" "npx capacitor-assets generate --android" || exit 7
# Step 9: Build APK/AAB if requested # Step 10: Build APK/AAB if requested
if [ "$BUILD_APK" = true ]; then if [ "$BUILD_APK" = true ]; then
if [ "$BUILD_TYPE" = "debug" ]; then if [ "$BUILD_TYPE" = "debug" ]; then
safe_execute "Building debug APK" "cd android && ./gradlew assembleDebug && cd .." || exit 5 safe_execute "Building debug APK" "cd android && ./gradlew assembleDebug && cd .." || exit 5
@@ -474,7 +488,7 @@ if [ "$BUILD_AAB" = true ]; then
safe_execute "Building AAB" "cd android && ./gradlew bundleRelease && cd .." || exit 5 safe_execute "Building AAB" "cd android && ./gradlew bundleRelease && cd .." || exit 5
fi fi
# Step 10: Auto-run app if requested # Step 11: Auto-run app if requested
if [ "$AUTO_RUN" = true ]; then if [ "$AUTO_RUN" = true ]; then
log_step "Auto-running Android app..." log_step "Auto-running Android app..."
safe_execute "Launching app" "npx cap run android" || { safe_execute "Launching app" "npx cap run android" || {
@@ -485,7 +499,7 @@ if [ "$AUTO_RUN" = true ]; then
log_success "Android app launched successfully!" log_success "Android app launched successfully!"
fi fi
# Step 11: Open Android Studio if requested # Step 12: Open Android Studio if requested
if [ "$OPEN_STUDIO" = true ]; then if [ "$OPEN_STUDIO" = true ]; then
safe_execute "Opening Android Studio" "npx cap open android" || exit 8 safe_execute "Opening Android Studio" "npx cap open android" || exit 8
fi fi

View File

@@ -381,7 +381,21 @@ safe_execute "Cleaning iOS build" "clean_ios_build" || exit 1
log_info "Cleaning dist directory..." log_info "Cleaning dist directory..."
clean_build_artifacts "dist" clean_build_artifacts "dist"
# Step 4: Build Capacitor version with mode # Step 4: Run TypeScript type checking for test and production builds
if [ "$BUILD_MODE" = "production" ] || [ "$BUILD_MODE" = "test" ]; then
log_info "Running TypeScript type checking for $BUILD_MODE mode..."
if ! measure_time npm run type-check; then
log_error "TypeScript type checking failed for $BUILD_MODE mode!"
exit 2
fi
log_success "TypeScript type checking completed for $BUILD_MODE mode"
else
log_debug "Skipping TypeScript type checking for development mode"
fi
# Step 5: Build Capacitor version with mode
if [ "$BUILD_MODE" = "development" ]; then if [ "$BUILD_MODE" = "development" ]; then
safe_execute "Building Capacitor version (development)" "npm run build:capacitor" || exit 3 safe_execute "Building Capacitor version (development)" "npm run build:capacitor" || exit 3
elif [ "$BUILD_MODE" = "test" ]; then elif [ "$BUILD_MODE" = "test" ]; then
@@ -390,16 +404,16 @@ elif [ "$BUILD_MODE" = "production" ]; then
safe_execute "Building Capacitor version (production)" "npm run build:capacitor -- --mode production" || exit 3 safe_execute "Building Capacitor version (production)" "npm run build:capacitor -- --mode production" || exit 3
fi fi
# Step 5: Sync with Capacitor # Step 6: Sync with Capacitor
safe_execute "Syncing with Capacitor" "npx cap sync ios" || exit 6 safe_execute "Syncing with Capacitor" "npx cap sync ios" || exit 6
# Step 6: Generate assets # Step 7: Generate assets
safe_execute "Generating assets" "npx capacitor-assets generate --ios" || exit 7 safe_execute "Generating assets" "npx capacitor-assets generate --ios" || exit 7
# Step 7: Build iOS app # Step 8: Build iOS app
safe_execute "Building iOS app" "build_ios_app" || exit 5 safe_execute "Building iOS app" "build_ios_app" || exit 5
# Step 8: Build IPA/App if requested # Step 9: Build IPA/App if requested
if [ "$BUILD_IPA" = true ]; then if [ "$BUILD_IPA" = true ]; then
log_info "Building IPA package..." log_info "Building IPA package..."
cd ios/App cd ios/App
@@ -426,12 +440,12 @@ if [ "$BUILD_APP" = true ]; then
log_success "App bundle built successfully" log_success "App bundle built successfully"
fi fi
# Step 9: Auto-run app if requested # Step 10: Auto-run app if requested
if [ "$AUTO_RUN" = true ]; then if [ "$AUTO_RUN" = true ]; then
safe_execute "Auto-running iOS app" "auto_run_ios_app" || exit 9 safe_execute "Auto-running iOS app" "auto_run_ios_app" || exit 9
fi fi
# Step 10: Open Xcode if requested # Step 11: Open Xcode if requested
if [ "$OPEN_STUDIO" = true ]; then if [ "$OPEN_STUDIO" = true ]; then
safe_execute "Opening Xcode" "npx cap open ios" || exit 8 safe_execute "Opening Xcode" "npx cap open ios" || exit 8
fi fi

View File

@@ -76,7 +76,7 @@ projects, and special entities with selection. * * @author Matthew Raymer */
</template> </template>
<!-- Empty state message --> <!-- Empty state message -->
<li v-if="entities.length === 0" :class="emptyStateClasses"> <li v-if="hasNoEntities" :class="emptyStateClasses">
{{ emptyStateMessage }} {{ emptyStateMessage }}
</li> </li>
@@ -164,6 +164,10 @@ import { Contact } from "../db/tables/contacts";
import { PlanData } from "../interfaces/records"; import { PlanData } from "../interfaces/records";
import { NotificationIface } from "../constants/app"; import { NotificationIface } from "../constants/app";
import { UNNAMED_ENTITY_NAME } from "@/constants/entities"; import { UNNAMED_ENTITY_NAME } from "@/constants/entities";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { getHeaders } from "../libs/endorserServer";
import { logger } from "../utils/logger";
import { TIMEOUTS } from "@/utils/notify";
/** /**
* Constants for infinite scroll configuration * Constants for infinite scroll configuration
@@ -191,6 +195,7 @@ const RECENT_CONTACTS_COUNT = 3;
ProjectCard, ProjectCard,
SpecialEntityCard, SpecialEntityCard,
}, },
mixins: [PlatformServiceMixin],
}) })
export default class EntityGrid extends Vue { export default class EntityGrid extends Vue {
/** Type of entities to display */ /** Type of entities to display */
@@ -202,6 +207,16 @@ export default class EntityGrid extends Vue {
isSearching = false; isSearching = false;
searchTimeout: NodeJS.Timeout | null = null; searchTimeout: NodeJS.Timeout | null = null;
filteredEntities: Contact[] | PlanData[] = []; filteredEntities: Contact[] | PlanData[] = [];
searchBeforeId: string | undefined = undefined;
isLoadingSearchMore = false;
// API server for project searches
apiServer = "";
// Internal project state (when entities prop not provided for projects)
allProjects: PlanData[] = [];
loadBeforeId: string | undefined = undefined;
isLoadingProjects = false;
// Infinite scroll state // Infinite scroll state
displayedCount = INITIAL_BATCH_SIZE; displayedCount = INITIAL_BATCH_SIZE;
@@ -211,17 +226,17 @@ export default class EntityGrid extends Vue {
/** /**
* Array of entities to display * Array of entities to display
* *
* For contacts (entityType === 'people'): REQUIRED - Must be a COMPLETE list from local database.
* Use $contactsByDateAdded() to ensure all contacts are included.
* Client-side filtering assumes the complete list is available.
* IMPORTANT: When passing Contact[] arrays, they must be sorted by date added * IMPORTANT: When passing Contact[] arrays, they must be sorted by date added
* (newest first) for the "Recently Added" section to display correctly. * (newest first) for the "Recently Added" section to display correctly.
* Use $contactsByDateAdded() instead of $getAllContacts() or $contacts().
* *
* The recentContacts computed property assumes contacts are already sorted * For projects (entityType === 'projects'): OPTIONAL - If not provided, EntityGrid loads
* by date added and simply takes the first 3. If contacts are sorted * projects internally from the API server. If provided, uses the provided list.
* alphabetically or in another order, the wrong contacts will appear in
* "Recently Added".
*/ */
@Prop({ required: true }) @Prop({ required: false })
entities!: Contact[] | PlanData[]; entities?: Contact[] | PlanData[];
/** Active user's DID */ /** Active user's DID */
@Prop({ required: true }) @Prop({ required: true })
@@ -293,6 +308,33 @@ export default class EntityGrid extends Vue {
return "text-xs text-slate-500 italic col-span-full"; return "text-xs text-slate-500 italic col-span-full";
} }
/**
* Check if there are no entities to display
*/
get hasNoEntities(): boolean {
if (this.entityType === "projects") {
// For projects: check internal state if no entities prop, otherwise check prop
const projectsToCheck = this.entities || this.allProjects;
return projectsToCheck.length === 0;
} else {
// For people: entities prop is required
return !this.entities || this.entities.length === 0;
}
}
/**
* Get the entities array to use (prop or internal state)
*/
get entitiesToUse(): Contact[] | PlanData[] {
if (this.entityType === "projects") {
// For projects: use prop if provided, otherwise use internal state
return this.entities || this.allProjects;
} else {
// For people: entities prop is required
return this.entities || [];
}
}
/** /**
* Computed entities to display - uses function prop if provided, otherwise uses infinite scroll * Computed entities to display - uses function prop if provided, otherwise uses infinite scroll
* When searching, returns filtered results with infinite scroll applied * When searching, returns filtered results with infinite scroll applied
@@ -305,12 +347,12 @@ export default class EntityGrid extends Vue {
// If custom function provided, use it (disables infinite scroll) // If custom function provided, use it (disables infinite scroll)
if (this.displayEntitiesFunction) { if (this.displayEntitiesFunction) {
return this.displayEntitiesFunction(this.entities, this.entityType); return this.displayEntitiesFunction(this.entitiesToUse, this.entityType);
} }
// Default: projects use infinite scroll // Default: projects use infinite scroll
if (this.entityType === "projects") { if (this.entityType === "projects") {
return (this.entities as PlanData[]).slice(0, this.displayedCount); return (this.entitiesToUse as PlanData[]).slice(0, this.displayedCount);
} }
// People: handled by recentContacts + alphabeticalContacts (both use displayedCount) // People: handled by recentContacts + alphabeticalContacts (both use displayedCount)
@@ -324,7 +366,11 @@ export default class EntityGrid extends Vue {
* See the entities prop documentation for details on using $contactsByDateAdded(). * See the entities prop documentation for details on using $contactsByDateAdded().
*/ */
get recentContacts(): Contact[] { get recentContacts(): Contact[] {
if (this.entityType !== "people" || this.searchTerm.trim()) { if (
this.entityType !== "people" ||
this.searchTerm.trim() ||
!this.entities
) {
return []; return [];
} }
// Entities are already sorted by date added (newest first) // Entities are already sorted by date added (newest first)
@@ -336,7 +382,11 @@ export default class EntityGrid extends Vue {
* Uses infinite scroll to control how many are displayed * Uses infinite scroll to control how many are displayed
*/ */
get alphabeticalContacts(): Contact[] { get alphabeticalContacts(): Contact[] {
if (this.entityType !== "people" || this.searchTerm.trim()) { if (
this.entityType !== "people" ||
this.searchTerm.trim() ||
!this.entities
) {
return []; return [];
} }
// Skip the first few (recent contacts) and sort the rest alphabetically // Skip the first few (recent contacts) and sort the rest alphabetically
@@ -457,47 +507,28 @@ export default class EntityGrid extends Vue {
/** /**
* Perform the actual search * Perform the actual search
* Routes to server-side search for projects or client-side filtering for contacts
*/ */
async performSearch(): Promise<void> { async performSearch(): Promise<void> {
if (!this.searchTerm.trim()) { if (!this.searchTerm.trim()) {
this.filteredEntities = []; this.filteredEntities = [];
this.displayedCount = INITIAL_BATCH_SIZE; this.displayedCount = INITIAL_BATCH_SIZE;
this.searchBeforeId = undefined;
this.infiniteScrollReset?.(); this.infiniteScrollReset?.();
return; return;
} }
this.isSearching = true; this.isSearching = true;
this.searchBeforeId = undefined; // Reset pagination for new search
try { try {
// Simulate async search (in case we need to add API calls later) if (this.entityType === "projects") {
await new Promise((resolve) => setTimeout(resolve, 100)); // Server-side search for projects (initial load, no beforeId)
const searchLower = this.searchTerm.toLowerCase().trim();
const searchLower = this.searchTerm.toLowerCase().trim(); await this.fetchProjects(undefined, searchLower);
if (this.entityType === "people") {
this.filteredEntities = (this.entities as Contact[])
.filter((contact: Contact) => {
const name = contact.name?.toLowerCase() || "";
const did = contact.did.toLowerCase();
return name.includes(searchLower) || did.includes(searchLower);
})
.sort((a: Contact, b: Contact) => {
// Sort alphabetically by name, falling back to DID if name is missing
const nameA = (a.name || a.did).toLowerCase();
const nameB = (b.name || b.did).toLowerCase();
return nameA.localeCompare(nameB);
});
} else { } else {
this.filteredEntities = (this.entities as PlanData[]) // Client-side filtering for contacts (complete list)
.filter((project: PlanData) => { await this.performContactSearch();
const name = project.name?.toLowerCase() || "";
const handleId = project.handleId.toLowerCase();
return name.includes(searchLower) || handleId.includes(searchLower);
})
.sort((a: PlanData, b: PlanData) => {
// Sort alphabetically by name
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
});
} }
// Reset displayed count when search completes // Reset displayed count when search completes
@@ -508,6 +539,194 @@ export default class EntityGrid extends Vue {
} }
} }
/**
* Fetch projects from API server
* Unified method for both loading all projects and searching projects.
* If claimContents is provided, performs search and updates filteredEntities.
* If claimContents is not provided, loads all projects and updates allProjects.
*
* @param beforeId - Optional rowId for pagination (loads projects before this ID)
* @param claimContents - Optional search term (if provided, performs search; if not, loads all)
*/
async fetchProjects(
beforeId?: string,
claimContents?: string,
): Promise<void> {
if (!this.apiServer) {
if (claimContents) {
this.filteredEntities = [];
} else {
this.allProjects = [];
}
if (this.notify) {
this.notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "API server not configured",
},
TIMEOUTS.SHORT,
);
}
return;
}
const isSearch = !!claimContents;
let url = `${this.apiServer}/api/v2/report/plans`;
// Build query parameters
const params: string[] = [];
if (claimContents) {
params.push(
`claimContents=${encodeURIComponent(claimContents.toLowerCase().trim())}`,
);
}
if (beforeId) {
params.push(`beforeId=${encodeURIComponent(beforeId)}`);
}
if (params.length > 0) {
url += `?${params.join("&")}`;
}
try {
const response = await fetch(url, {
method: "GET",
headers: await getHeaders(this.activeDid),
});
if (response.status !== 200) {
throw new Error(
isSearch ? "Failed to search projects" : "Failed to load projects",
);
}
const results = await response.json();
if (results.data) {
const newProjects = results.data.map(
(plan: PlanData & { rowId?: string }) => ({
...plan,
rowId: plan.rowId,
}),
);
if (isSearch) {
// Search mode: update filteredEntities
if (beforeId) {
// Pagination: append new projects to existing search results
this.filteredEntities.push(...newProjects);
} else {
// Initial search: replace array
this.filteredEntities = newProjects;
}
// Update searchBeforeId for next pagination
if (newProjects.length > 0) {
const lastProject = newProjects[newProjects.length - 1];
this.searchBeforeId = lastProject.rowId || undefined;
} else {
this.searchBeforeId = undefined; // No more results
}
} else {
// Load mode: update allProjects
if (beforeId) {
// Pagination: append new projects
this.allProjects.push(...newProjects);
} else {
// Initial load: replace array
this.allProjects = newProjects;
}
// Update loadBeforeId for next pagination
if (newProjects.length > 0) {
const lastProject = newProjects[newProjects.length - 1];
this.loadBeforeId = lastProject.rowId || undefined;
} else {
this.loadBeforeId = undefined; // No more results
}
}
} else {
// No data in response
if (isSearch) {
if (!beforeId) {
// Only clear on initial search, not pagination
this.filteredEntities = [];
}
this.searchBeforeId = undefined;
} else {
if (!beforeId) {
// Only clear on initial load, not pagination
this.allProjects = [];
}
this.loadBeforeId = undefined;
}
}
} catch (error) {
logger.error(
`Error ${isSearch ? "searching" : "loading"} projects:`,
error,
);
if (isSearch) {
if (!beforeId) {
// Only clear on initial search error, not pagination error
this.filteredEntities = [];
}
this.searchBeforeId = undefined;
} else {
if (!beforeId) {
// Only clear on initial load error, not pagination error
this.allProjects = [];
}
this.loadBeforeId = undefined;
}
if (this.notify) {
this.notify(
{
group: "alert",
type: "danger",
title: "Error",
text: isSearch
? "Failed to search projects. Please try again."
: "Failed to load projects. Please try again.",
},
TIMEOUTS.STANDARD,
);
}
}
}
/**
* Client-side contact search
* Assumes entities prop contains complete contact list from local database
*/
async performContactSearch(): Promise<void> {
if (!this.entities) {
this.filteredEntities = [];
return;
}
// Simulate async (for consistency with project search)
await new Promise((resolve) => setTimeout(resolve, 100));
const searchLower = this.searchTerm.toLowerCase().trim();
this.filteredEntities = (this.entities as Contact[])
.filter((contact: Contact) => {
const name = contact.name?.toLowerCase() || "";
const did = contact.did.toLowerCase();
return name.includes(searchLower) || did.includes(searchLower);
})
.sort((a: Contact, b: Contact) => {
// Sort alphabetically by name, falling back to DID if name is missing
const nameA = (a.name || a.did).toLowerCase();
const nameB = (b.name || b.did).toLowerCase();
return nameA.localeCompare(nameB);
});
// Contacts don't need pagination (complete list)
this.searchBeforeId = undefined;
}
/** /**
* Clear the search * Clear the search
*/ */
@@ -516,6 +735,7 @@ export default class EntityGrid extends Vue {
this.filteredEntities = []; this.filteredEntities = [];
this.isSearching = false; this.isSearching = false;
this.displayedCount = INITIAL_BATCH_SIZE; this.displayedCount = INITIAL_BATCH_SIZE;
this.searchBeforeId = undefined;
this.infiniteScrollReset?.(); this.infiniteScrollReset?.();
// Clear any pending timeout // Clear any pending timeout
@@ -535,17 +755,48 @@ export default class EntityGrid extends Vue {
} }
if (this.searchTerm.trim()) { if (this.searchTerm.trim()) {
// Search mode: check filtered entities // Search mode: check if more results available
return this.displayedCount < this.filteredEntities.length; if (this.entityType === "projects") {
// Projects: can load more if:
// 1. We have more already-loaded results to show, OR
// 2. We've shown all loaded results AND there's a searchBeforeId to load more
const hasMoreLoaded =
this.displayedCount < this.filteredEntities.length;
const canLoadMoreFromServer =
this.displayedCount >= this.filteredEntities.length &&
!!this.searchBeforeId &&
!this.isLoadingSearchMore;
return hasMoreLoaded || canLoadMoreFromServer;
} else {
// Contacts: client-side filtering returns all results at once
return this.displayedCount < this.filteredEntities.length;
}
} }
// Non-search mode
if (this.entityType === "projects") { if (this.entityType === "projects") {
// Projects: check if more available // Projects: check internal state or prop
return this.displayedCount < this.entities.length; const projectsToCheck = this.entities || this.allProjects;
const beforeId = this.entities ? undefined : this.loadBeforeId;
// Can load more if:
// 1. We have more already-loaded results to show, OR
// 2. We've shown all loaded results AND there's a beforeId to load more (and not using entities prop)
const hasMoreLoaded = this.displayedCount < projectsToCheck.length;
const canLoadMoreFromServer =
!this.entities &&
this.displayedCount >= projectsToCheck.length &&
!!beforeId &&
!this.isLoadingProjects;
return hasMoreLoaded || canLoadMoreFromServer;
} }
// People: check if more alphabetical contacts available // People: check if more alphabetical contacts available
// Total available = recent + all alphabetical // Total available = recent + all alphabetical
if (!this.entities) {
return false;
}
const totalAvailable = RECENT_CONTACTS_COUNT + this.entities.length; const totalAvailable = RECENT_CONTACTS_COUNT + this.entities.length;
return this.displayedCount < totalAvailable; return this.displayedCount < totalAvailable;
} }
@@ -553,16 +804,112 @@ export default class EntityGrid extends Vue {
/** /**
* Initialize infinite scroll on mount * Initialize infinite scroll on mount
*/ */
mounted(): void { async mounted(): Promise<void> {
// Load apiServer for project searches/loads
if (this.entityType === "projects") {
const settings = await this.$accountSettings();
this.apiServer = settings.apiServer || "";
// Load projects on mount if entities prop not provided
if (!this.entities && this.apiServer) {
this.isLoadingProjects = true;
try {
await this.fetchProjects();
} catch (error) {
logger.error("Error loading projects on mount:", error);
} finally {
this.isLoadingProjects = false;
}
}
}
// Validate entities prop for people
if (this.entityType === "people" && !this.entities) {
logger.error(
"EntityGrid: entities prop is required when entityType is 'people'",
);
if (this.notify) {
this.notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Contacts data is required but not provided.",
},
TIMEOUTS.SHORT,
);
}
}
this.$nextTick(() => { this.$nextTick(() => {
const container = this.$refs.scrollContainer as HTMLElement; const container = this.$refs.scrollContainer as HTMLElement;
if (container) { if (container) {
const { reset } = useInfiniteScroll( const { reset } = useInfiniteScroll(
container, container,
() => { async () => {
// Load more: increment displayedCount // Search mode: handle search pagination
this.displayedCount += INCREMENT_SIZE; if (this.searchTerm.trim()) {
if (this.entityType === "projects") {
// Projects: load more search results if available
if (
this.displayedCount >= this.filteredEntities.length &&
this.searchBeforeId &&
!this.isLoadingSearchMore
) {
this.isLoadingSearchMore = true;
try {
const searchLower = this.searchTerm.toLowerCase().trim();
await this.fetchProjects(this.searchBeforeId, searchLower);
// After loading more, reset scroll state to allow further loading
this.infiniteScrollReset?.();
} catch (error) {
logger.error("Error loading more search results:", error);
// Error already handled in fetchProjects
} finally {
this.isLoadingSearchMore = false;
}
} else {
// Show more from already-loaded search results
this.displayedCount += INCREMENT_SIZE;
}
} else {
// Contacts: show more from already-filtered results
this.displayedCount += INCREMENT_SIZE;
}
} else {
// Non-search mode
if (this.entityType === "projects") {
const projectsToCheck = this.entities || this.allProjects;
const beforeId = this.entities ? undefined : this.loadBeforeId;
// If using internal state and need to load more from server
if (
!this.entities &&
this.displayedCount >= projectsToCheck.length &&
beforeId &&
!this.isLoadingProjects
) {
this.isLoadingProjects = true;
try {
await this.fetchProjects(beforeId);
// After loading more, reset scroll state to allow further loading
this.infiniteScrollReset?.();
} catch (error) {
logger.error("Error loading more projects:", error);
// Error already handled in fetchProjects
} finally {
this.isLoadingProjects = false;
}
} else {
// Normal case: increment displayedCount to show more from memory
this.displayedCount += INCREMENT_SIZE;
}
} else {
// People: increment displayedCount to show more from memory
this.displayedCount += INCREMENT_SIZE;
}
}
}, },
{ {
distance: 50, // pixels from bottom distance: 50, // pixels from bottom
@@ -588,21 +935,35 @@ export default class EntityGrid extends Vue {
} }
/** /**
* Watch for changes in search term to reset displayed count * Watch for changes in search term to reset displayed count and pagination
*/ */
@Watch("searchTerm") @Watch("searchTerm")
onSearchTermChange(): void { onSearchTermChange(): void {
// Reset displayed count and pagination when search term changes
this.displayedCount = INITIAL_BATCH_SIZE; this.displayedCount = INITIAL_BATCH_SIZE;
this.searchBeforeId = undefined;
this.infiniteScrollReset?.(); this.infiniteScrollReset?.();
} }
/** /**
* Watch for changes in entities prop to reset displayed count * Watch for changes in entities prop to clear search and reset displayed count
*/ */
@Watch("entities") @Watch("entities")
onEntitiesChange(): void { onEntitiesChange(): void {
// Clear search when entities change (fresh dialog open)
if (this.searchTerm) {
this.searchTerm = "";
this.filteredEntities = [];
this.searchBeforeId = undefined;
}
this.displayedCount = INITIAL_BATCH_SIZE; this.displayedCount = INITIAL_BATCH_SIZE;
this.infiniteScrollReset?.(); this.infiniteScrollReset?.();
// For projects: if entities prop is provided, clear internal state
if (this.entityType === "projects" && this.entities) {
this.allProjects = [];
this.loadBeforeId = undefined;
}
} }
/** /**

View File

@@ -14,7 +14,7 @@ properties * * @author Matthew Raymer */
<EntityGrid <EntityGrid
:entity-type="shouldShowProjects ? 'projects' : 'people'" :entity-type="shouldShowProjects ? 'projects' : 'people'"
:entities="shouldShowProjects ? projects : allContacts" :entities="shouldShowProjects ? projects || undefined : allContacts"
:active-did="activeDid" :active-did="activeDid"
:all-my-dids="allMyDids" :all-my-dids="allMyDids"
:all-contacts="allContacts" :all-contacts="allContacts"
@@ -94,9 +94,9 @@ export default class EntitySelectionStep extends Vue {
@Prop({ default: false }) @Prop({ default: false })
isFromProjectView!: boolean; isFromProjectView!: boolean;
/** Array of available projects */ /** Array of available projects (optional - EntityGrid loads internally if not provided) */
@Prop({ required: true }) @Prop({ required: false })
projects!: PlanData[]; projects?: PlanData[];
/** Array of available contacts */ /** Array of available contacts */
@Prop({ required: true }) @Prop({ required: true })

View File

@@ -15,7 +15,6 @@
giverEntityType === 'project' || recipientEntityType === 'project' giverEntityType === 'project' || recipientEntityType === 'project'
" "
:is-from-project-view="isFromProjectView" :is-from-project-view="isFromProjectView"
:projects="projects"
:all-contacts="allContacts" :all-contacts="allContacts"
:active-did="activeDid" :active-did="activeDid"
:all-my-dids="allMyDids" :all-my-dids="allMyDids"
@@ -68,7 +67,6 @@ import {
createAndSubmitGive, createAndSubmitGive,
didInfo, didInfo,
serverMessageForUser, serverMessageForUser,
getHeaders,
} from "../libs/endorserServer"; } from "../libs/endorserServer";
import * as libsUtil from "../libs/util"; import * as libsUtil from "../libs/util";
import { Contact } from "../db/tables/contacts"; import { Contact } from "../db/tables/contacts";
@@ -134,7 +132,6 @@ export default class GiftedDialog extends Vue {
firstStep = true; // true = Step 1 (giver/recipient selection), false = Step 2 (amount/description) firstStep = true; // true = Step 1 (giver/recipient selection), false = Step 2 (amount/description)
giver?: libsUtil.GiverReceiverInputInfo; // undefined means no identified giver agent giver?: libsUtil.GiverReceiverInputInfo; // undefined means no identified giver agent
offerId = ""; offerId = "";
projects: PlanData[] = [];
prompt = ""; prompt = "";
receiver?: libsUtil.GiverReceiverInputInfo; receiver?: libsUtil.GiverReceiverInputInfo;
stepType = "giver"; stepType = "giver";
@@ -234,16 +231,6 @@ export default class GiftedDialog extends Vue {
this.allContacts = await this.$contactsByDateAdded(); this.allContacts = await this.$contactsByDateAdded();
this.allMyDids = await retrieveAccountDids(); this.allMyDids = await retrieveAccountDids();
if (
this.giverEntityType === "project" ||
this.recipientEntityType === "project"
) {
await this.loadProjects();
} else {
// Clear projects array when not needed
this.projects = [];
}
} catch (err: unknown) { } catch (err: unknown) {
logger.error("Error retrieving settings from database:", err); logger.error("Error retrieving settings from database:", err);
this.safeNotify.error( this.safeNotify.error(
@@ -489,27 +476,6 @@ export default class GiftedDialog extends Vue {
this.firstStep = false; this.firstStep = false;
} }
async loadProjects() {
try {
const response = await fetch(this.apiServer + "/api/v2/report/plans", {
method: "GET",
headers: await getHeaders(this.activeDid),
});
if (response.status !== 200) {
throw new Error("Failed to load projects");
}
const results = await response.json();
if (results.data) {
this.projects = results.data;
}
} catch (error) {
logger.error("Error loading projects:", error);
this.safeNotify.error("Failed to load projects", TIMEOUTS.STANDARD);
}
}
selectProject(project: PlanData) { selectProject(project: PlanData) {
this.giver = { this.giver = {
did: project.handleId, did: project.handleId,

View File

@@ -0,0 +1,130 @@
<template>
<div v-if="visible" class="dialog-overlay">
<div class="dialog">
<!-- Header -->
<h2 class="text-lg font-semibold leading-[1.25] mb-4">Select Project</h2>
<!-- EntityGrid for projects -->
<EntityGrid
:entity-type="'projects'"
:active-did="activeDid"
:all-my-dids="allMyDids"
:all-contacts="allContacts"
:conflict-checker="() => false"
:show-you-entity="false"
:show-unnamed-entity="false"
:notify="notify"
:conflict-context="'project'"
@entity-selected="handleEntitySelected"
/>
<!-- Cancel Button -->
<div class="flex gap-2 mt-4">
<button
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-2 rounded-md"
@click="handleCancel"
>
Cancel
</button>
</div>
</div>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue, Emit } from "vue-facing-decorator";
import EntityGrid from "./EntityGrid.vue";
import { Contact } from "../db/tables/contacts";
import { PlanData } from "../interfaces/records";
import { NotificationIface } from "../constants/app";
/**
* MeetingProjectDialog - Dialog for selecting a project link for a meeting
*
* Features:
* - EntityGrid integration for project selection
* - No special entities (You, Unnamed)
* - Immediate assignment on project selection
* - Cancel button to close without selection
*/
@Component({
components: {
EntityGrid,
},
})
export default class MeetingProjectDialog extends Vue {
/** Whether the dialog is visible */
visible = false;
/** Active user's DID */
@Prop({ required: true })
activeDid!: string;
/** All user's DIDs */
@Prop({ required: true })
allMyDids!: string[];
/** All contacts */
@Prop({ required: true })
allContacts!: Contact[];
/** Notification function from parent component */
@Prop()
notify?: (notification: NotificationIface, timeout?: number) => void;
/**
* Handle entity selection from EntityGrid
* Immediately assigns the selected project and closes the dialog
*/
handleEntitySelected(event: {
type: "person" | "project";
data: Contact | PlanData;
}) {
const project = event.data as PlanData;
this.emitAssign(project);
this.close();
}
/**
* Handle cancel button click
*/
handleCancel(): void {
this.close();
}
/**
* Open the dialog
*/
open(): void {
this.visible = true;
this.emitOpen();
}
/**
* Close the dialog
*/
close(): void {
this.visible = false;
this.emitClose();
}
// Emit methods using @Emit decorator
@Emit("assign")
emitAssign(project: PlanData): PlanData {
return project;
}
@Emit("open")
emitOpen(): void {
// Emit when dialog opens
}
@Emit("close")
emitClose(): void {
// Emit when dialog closes
}
}
</script>
<style scoped></style>

View File

@@ -8,7 +8,7 @@ issuer information. * * @author Matthew Raymer */
> >
<ProjectIcon <ProjectIcon
:entity-id="project.handleId" :entity-id="project.handleId"
:icon-size="48" :icon-size="30"
:image-url="project.image" :image-url="project.image"
class="!size-[2rem] shrink-0 border border-slate-300 bg-white overflow-hidden rounded-full" class="!size-[2rem] shrink-0 border border-slate-300 bg-white overflow-hidden rounded-full"
/> />

View File

@@ -1686,7 +1686,10 @@ export async function register(
"Registration thrown error:", "Registration thrown error:",
errorMessage || JSON.stringify(err), errorMessage || JSON.stringify(err),
); );
return { error: errorMessage || "Got a server error when registering." }; return {
error:
(errorMessage as string) || "Got a server error when registering.",
};
} }
return { error: "Got a server error when registering." }; return { error: "Got a server error when registering." };
} }

View File

@@ -91,16 +91,92 @@ export class CapacitorPlatformService
} }
try { try {
// Create/Open database // Try to create/Open database connection
this.db = await this.sqlite.createConnection( try {
this.dbName, this.db = await this.sqlite.createConnection(
false, this.dbName,
"no-encryption", false,
1, "no-encryption",
false, 1,
); false,
);
} catch (createError: unknown) {
// If connection already exists, try to retrieve it or handle gracefully
const errorMessage =
createError instanceof Error
? createError.message
: String(createError);
const errorObj =
typeof createError === "object" && createError !== null
? (createError as { errorMessage?: string; message?: string })
: {};
await this.db.open(); const fullErrorMessage =
errorObj.errorMessage || errorObj.message || errorMessage;
if (fullErrorMessage.includes("already exists")) {
logger.debug(
"[CapacitorPlatformService] Connection already exists on native side, attempting to retrieve",
);
// Check if connection exists in JavaScript Map
const isConnResult = await this.sqlite.isConnection(
this.dbName,
false,
);
if (isConnResult.result) {
// Connection exists in Map, retrieve it
this.db = await this.sqlite.retrieveConnection(this.dbName, false);
logger.debug(
"[CapacitorPlatformService] Successfully retrieved existing connection from Map",
);
} else {
// Connection exists on native side but not in JavaScript Map
// This can happen when the app is restarted but native connections persist
// Try to close the native connection first, then create a new one
logger.debug(
"[CapacitorPlatformService] Connection exists natively but not in Map, closing and recreating",
);
try {
await this.sqlite.closeConnection(this.dbName, false);
} catch (closeError) {
// Ignore close errors - connection might not be properly tracked
logger.debug(
"[CapacitorPlatformService] Error closing connection (may be expected):",
closeError,
);
}
// Now try to create the connection again
this.db = await this.sqlite.createConnection(
this.dbName,
false,
"no-encryption",
1,
false,
);
logger.debug(
"[CapacitorPlatformService] Successfully created connection after cleanup",
);
}
} else {
// Re-throw if it's a different error
throw createError;
}
}
// Open the connection if it's not already open
try {
await this.db.open();
} catch (openError: unknown) {
const openErrorMessage =
openError instanceof Error ? openError.message : String(openError);
// If already open, that's fine - continue
if (!openErrorMessage.includes("already open")) {
throw openError;
}
logger.debug(
"[CapacitorPlatformService] Database connection already open",
);
}
// Set journal mode to WAL for better performance // Set journal mode to WAL for better performance
// await this.db.execute("PRAGMA journal_mode=WAL;"); // await this.db.execute("PRAGMA journal_mode=WAL;");

View File

@@ -20,12 +20,12 @@
</button> </button>
<!-- Help button --> <!-- Help button -->
<button <router-link
:to="{ name: 'help' }"
class="block ms-auto text-sm text-center text-white bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-1.5 rounded-full" class="block ms-auto text-sm text-center text-white bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] p-1.5 rounded-full"
@click="goToHelp()"
> >
<font-awesome icon="question" class="block text-center w-[1em]" /> <font-awesome icon="question" class="block text-center w-[1em]" />
</button> </router-link>
</div> </div>
<!-- Identity Details --> <!-- Identity Details -->

View File

@@ -186,16 +186,59 @@
<div> <div>
<label <label
for="projectLink" for="projectLink"
class="block text-sm font-medium text-gray-700" class="block text-sm font-medium text-gray-700 mb-1"
>Project Link</label >Project Link</label
> >
<input <div class="w-full flex items-stretch">
id="projectLink" <div
v-model="newOrUpdatedMeetingInputs.projectLink" class="flex items-center gap-2 grow border border-slate-400 border-r-0 last:border-r px-3 py-2 rounded-l last:rounded overflow-hidden cursor-pointer hover:bg-slate-100"
type="text" @click="openProjectLinkDialog"
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none" >
placeholder="Project ID" <div>
/> <ProjectIcon
v-if="selectedProject"
:entity-id="selectedProject.handleId"
:icon-size="30"
:image-url="selectedProject.image"
class="!size-[2rem] shrink-0 border border-slate-300 bg-white overflow-hidden rounded-full"
/>
<font-awesome
v-else
icon="folder-open"
class="text-slate-400"
/>
</div>
<div class="overflow-hidden">
<div
:class="{
'text-sm font-semibold': selectedProject,
'text-slate-400': !selectedProject,
}"
class="truncate"
>
{{
selectedProject
? selectedProject.name || "Unnamed Project"
: "Select Project…"
}}
</div>
<div
v-if="selectedProject"
class="text-xs text-slate-500 truncate"
>
<font-awesome icon="user" class="text-slate-400" />
{{ selectedProjectIssuerName }}
</div>
</div>
</div>
<button
v-if="selectedProject"
class="text-rose-600 px-3 py-2 border border-slate-400 border-l-0 rounded-r hover:bg-rose-600 hover:text-white hover:border-rose-600"
@click="unsetProjectLink"
>
<font-awesome icon="trash-can" />
</button>
</div>
</div> </div>
<button <button
@@ -224,6 +267,17 @@
</form> </form>
</div> </div>
<MeetingProjectDialog
ref="meetingProjectDialog"
:active-did="activeDid"
:all-my-dids="allMyDids"
:all-contacts="allContacts"
:notify="$notify"
@assign="handleProjectLinkAssigned"
@open="handleDialogOpen"
@close="handleDialogClose"
/>
<!-- Members Section --> <!-- Members Section -->
<div <div
v-if="!isLoading && currentMeeting != null && !!currentMeeting.password" v-if="!isLoading && currentMeeting != null && !!currentMeeting.password"
@@ -254,6 +308,7 @@
</ul> </ul>
<MembersList <MembersList
ref="membersList"
:password="currentMeeting.password || ''" :password="currentMeeting.password || ''"
:show-organizer-tools="true" :show-organizer-tools="true"
class="mt-4" class="mt-4"
@@ -292,10 +347,13 @@ import { RouteLocationNormalizedLoaded, Router } from "vue-router";
import QuickNav from "../components/QuickNav.vue"; import QuickNav from "../components/QuickNav.vue";
import TopMessage from "../components/TopMessage.vue"; import TopMessage from "../components/TopMessage.vue";
import MembersList from "../components/MembersList.vue"; import MembersList from "../components/MembersList.vue";
import MeetingProjectDialog from "../components/MeetingProjectDialog.vue";
import ProjectIcon from "../components/ProjectIcon.vue";
import { import {
errorStringForLog, errorStringForLog,
getHeaders, getHeaders,
serverMessageForUser, serverMessageForUser,
didInfo,
} from "../libs/endorserServer"; } from "../libs/endorserServer";
import { encryptMessage } from "../libs/crypto"; import { encryptMessage } from "../libs/crypto";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin"; import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
@@ -309,6 +367,8 @@ import {
NOTIFY_MEETING_DELETED, NOTIFY_MEETING_DELETED,
NOTIFY_MEETING_LINK_COPIED, NOTIFY_MEETING_LINK_COPIED,
} from "@/constants/notifications"; } from "@/constants/notifications";
import { PlanData } from "../interfaces/records";
import { Contact } from "../db/tables/contacts";
interface ServerMeeting { interface ServerMeeting {
groupId: number; // from the server groupId: number; // from the server
name: string; // to & from the server name: string; // to & from the server
@@ -331,6 +391,8 @@ interface MeetingSetupInputs {
QuickNav, QuickNav,
TopMessage, TopMessage,
MembersList, MembersList,
MeetingProjectDialog,
ProjectIcon,
}, },
mixins: [PlatformServiceMixin], mixins: [PlatformServiceMixin],
}) })
@@ -354,6 +416,9 @@ export default class OnboardMeetingView extends Vue {
isRegistered = false; isRegistered = false;
showDeleteConfirm = false; showDeleteConfirm = false;
fullName = ""; fullName = "";
allContacts: Contact[] = [];
allMyDids: string[] = [];
selectedProjectData: PlanData | null = null;
get minDateTime() { get minDateTime() {
const now = new Date(); const now = new Date();
now.setMinutes(now.getMinutes() + 5); // Set minimum 5 minutes in the future now.setMinutes(now.getMinutes() + 5); // Set minimum 5 minutes in the future
@@ -370,7 +435,17 @@ export default class OnboardMeetingView extends Vue {
this.fullName = settings?.firstName || ""; this.fullName = settings?.firstName || "";
this.isRegistered = !!settings?.isRegistered; this.isRegistered = !!settings?.isRegistered;
// Load contacts and DIDs for dialog
// eslint-disable-next-line @typescript-eslint/no-explicit-any
this.allContacts = await (this as any).$contactsByDateAdded();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
this.allMyDids = await (this as any).$getAllAccountDids();
await this.fetchCurrentMeeting(); await this.fetchCurrentMeeting();
// Ensure selected project is loaded if projectLink exists
await this.ensureSelectedProjectLoaded();
this.isLoading = false; this.isLoading = false;
} }
@@ -442,6 +517,54 @@ export default class OnboardMeetingView extends Vue {
} }
} }
/**
* Ensure the selected project is loaded if projectLink exists
*/
async ensureSelectedProjectLoaded(): Promise<void> {
const projectLink =
this.currentMeeting?.projectLink ||
this.newOrUpdatedMeetingInputs?.projectLink;
if (!projectLink) {
this.selectedProjectData = null;
return;
}
await this.fetchProjectByHandleId(projectLink);
}
/**
* Fetch a single project by handleId
* @param handleId - The project handleId to fetch
*/
async fetchProjectByHandleId(handleId: string): Promise<void> {
try {
const headers = await getHeaders(this.activeDid);
const url = `${this.apiServer}/api/v2/report/plans?handleId=${encodeURIComponent(handleId)}`;
const resp = await this.axios.get(url, { headers });
if (resp.status === 200 && resp.data.data && resp.data.data.length > 0) {
const project = resp.data.data[0];
this.selectedProjectData = {
name: project.name,
description: project.description,
image: project.image,
handleId: project.handleId,
issuerDid: project.issuerDid,
rowId: project.rowId,
};
} else {
this.selectedProjectData = null;
}
} catch (error) {
this.$logAndConsole(
"Error fetching project by handleId: " + errorStringForLog(error),
true,
);
this.selectedProjectData = null;
}
}
async createMeeting() { async createMeeting() {
this.isLoading = true; this.isLoading = true;
@@ -576,7 +699,7 @@ export default class OnboardMeetingView extends Vue {
} }
} }
startEditing() { async startEditing() {
// Populate form with existing meeting data // Populate form with existing meeting data
if (this.currentMeeting) { if (this.currentMeeting) {
const localExpiresAt = new Date(this.currentMeeting.expiresAt); const localExpiresAt = new Date(this.currentMeeting.expiresAt);
@@ -587,6 +710,10 @@ export default class OnboardMeetingView extends Vue {
password: this.currentMeeting.password || "", password: this.currentMeeting.password || "",
projectLink: this.currentMeeting.projectLink || "", projectLink: this.currentMeeting.projectLink || "",
}; };
// Ensure selected project is loaded if projectLink exists
if (this.currentMeeting.projectLink) {
await this.ensureSelectedProjectLoaded();
}
} else { } else {
this.$logError( this.$logError(
"There is no current meeting to edit. We should never get here.", "There is no current meeting to edit. We should never get here.",
@@ -594,9 +721,15 @@ export default class OnboardMeetingView extends Vue {
} }
} }
cancelEditing() { async cancelEditing() {
// Reset form data // Reset form data
this.newOrUpdatedMeetingInputs = null; this.newOrUpdatedMeetingInputs = null;
// Restore selected project from currentMeeting if it exists
if (this.currentMeeting?.projectLink) {
await this.ensureSelectedProjectLoaded();
} else {
this.selectedProjectData = null;
}
} }
async updateMeeting() { async updateMeeting() {
@@ -710,5 +843,78 @@ export default class OnboardMeetingView extends Vue {
this.notify.error("Failed to copy meeting link to clipboard."); this.notify.error("Failed to copy meeting link to clipboard.");
} }
} }
/**
* Computed property for selected project
* Returns the separately stored selected project data
*/
get selectedProject(): PlanData | null {
return this.selectedProjectData;
}
/**
* Computed property for selected project issuer display name
* Uses didInfo to format the issuer name similar to ProjectCard
*/
get selectedProjectIssuerName(): string {
if (!this.selectedProject) {
return "";
}
return didInfo(
this.selectedProject.issuerDid,
this.activeDid,
this.allMyDids,
this.allContacts,
);
}
/**
* Open the project link selection dialog
*/
openProjectLinkDialog(): void {
(this.$refs.meetingProjectDialog as MeetingProjectDialog).open();
}
/**
* Handle project assignment from dialog
*/
handleProjectLinkAssigned(project: PlanData): void {
// Store the selected project directly
this.selectedProjectData = project;
if (this.newOrUpdatedMeetingInputs) {
this.newOrUpdatedMeetingInputs.projectLink = project.handleId;
}
}
/**
* Unset the project link and revert to initial state
*/
unsetProjectLink(): void {
this.selectedProjectData = null;
if (this.newOrUpdatedMeetingInputs) {
this.newOrUpdatedMeetingInputs.projectLink = "";
}
}
/**
* Handle dialog open event - stop auto-refresh in MembersList
*/
handleDialogOpen(): void {
const membersList = this.$refs.membersList as MembersList;
if (membersList) {
membersList.stopAutoRefresh();
}
}
/**
* Handle dialog close event - start auto-refresh in MembersList
*/
handleDialogClose(): void {
const membersList = this.$refs.membersList as MembersList;
if (membersList) {
membersList.startAutoRefresh();
}
}
} }
</script> </script>