forked from trent_larson/crowd-funder-for-time-pwa
refactor: improve feed loading and infinite scroll reliability
- Add debouncing and loading state to InfiniteScroll component to prevent duplicate entries - Refactor updateAllFeed into smaller, focused functions for better maintainability - Add proper error handling and type assertions - Optimize test performance with networkidle waits and better selectors - Fix strict mode violations in Playwright tests - Clean up test configuration by commenting out unused browser targets
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -38,7 +38,6 @@ pnpm-debug.log*
|
||||
/dist-capacitor/
|
||||
/test-playwright-results/
|
||||
playwright-tests
|
||||
test-playwright
|
||||
dist-electron-packages
|
||||
ios
|
||||
.ruby-version
|
||||
|
||||
24953
package-lock.json
generated
24953
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -46,21 +46,21 @@ export default defineConfig({
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium-serial',
|
||||
testMatch: /.*\/(35-record-gift-from-image-share|40-add-contact)\.spec\.ts/,
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
permissions: ["clipboard-read"],
|
||||
},
|
||||
workers: 1, // Force serial execution for problematic tests
|
||||
},
|
||||
{
|
||||
name: 'firefox-serial',
|
||||
testMatch: /.*\/(35-record-gift-from-image-share|40-add-contact)\.spec\.ts/,
|
||||
use: { ...devices['Desktop Firefox'] },
|
||||
workers: 1,
|
||||
},
|
||||
// {
|
||||
// name: 'chromium-serial',
|
||||
// testMatch: /.*\/(35-record-gift-from-image-share|40-add-contact)\.spec\.ts/,
|
||||
// use: {
|
||||
// ...devices['Desktop Chrome'],
|
||||
// permissions: ["clipboard-read"],
|
||||
// },
|
||||
// workers: 1, // Force serial execution for problematic tests
|
||||
// },
|
||||
// {
|
||||
// name: 'firefox-serial',
|
||||
// testMatch: /.*\/(35-record-gift-from-image-share|40-add-contact)\.spec\.ts/,
|
||||
// use: { ...devices['Desktop Firefox'] },
|
||||
// workers: 1,
|
||||
// },
|
||||
{
|
||||
name: 'chromium',
|
||||
testMatch: /^(?!.*\/(35-record-gift-from-image-share|40-add-contact)\.spec\.ts).+\.spec\.ts$/,
|
||||
@@ -76,26 +76,20 @@ export default defineConfig({
|
||||
},
|
||||
|
||||
/* Test against mobile viewports. */
|
||||
|
||||
{
|
||||
name: "Mobile Chrome",
|
||||
use: { ...devices["Pixel 5"] },
|
||||
},
|
||||
{
|
||||
name: "Mobile Safari",
|
||||
use: { ...devices["iPhone 12"] },
|
||||
},
|
||||
// {
|
||||
// name: "Mobile Chrome",
|
||||
// use: { ...devices["Pixel 5"] },
|
||||
// },
|
||||
// {
|
||||
// name: "Mobile Safari",
|
||||
// use: { ...devices["iPhone 12"] },
|
||||
// },
|
||||
|
||||
/* Test against branded browsers. */
|
||||
|
||||
// {
|
||||
// name: 'Microsoft Edge',
|
||||
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
|
||||
// },
|
||||
{
|
||||
name: "Google Chrome",
|
||||
use: { ...devices["Desktop Chrome"], channel: "chrome" },
|
||||
},
|
||||
],
|
||||
|
||||
/* Configure global timeout; default is 30000 milliseconds */
|
||||
|
||||
@@ -14,6 +14,8 @@ export default class InfiniteScroll extends Vue {
|
||||
readonly distance!: number;
|
||||
private observer!: IntersectionObserver;
|
||||
private isInitialRender = true;
|
||||
private isLoading = false;
|
||||
private debounceTimeout: number | null = null;
|
||||
|
||||
updated() {
|
||||
if (!this.observer) {
|
||||
@@ -35,13 +37,28 @@ export default class InfiniteScroll extends Vue {
|
||||
if (this.observer) {
|
||||
this.observer.disconnect();
|
||||
}
|
||||
if (this.debounceTimeout) {
|
||||
window.clearTimeout(this.debounceTimeout);
|
||||
}
|
||||
}
|
||||
|
||||
@Emit("reached-bottom")
|
||||
handleIntersection(entries: IntersectionObserverEntry[]) {
|
||||
const entry = entries[0];
|
||||
if (entry.isIntersecting) {
|
||||
return true;
|
||||
if (entry.isIntersecting && !this.isLoading) {
|
||||
// Debounce the intersection event
|
||||
if (this.debounceTimeout) {
|
||||
window.clearTimeout(this.debounceTimeout);
|
||||
}
|
||||
|
||||
this.debounceTimeout = window.setTimeout(() => {
|
||||
this.isLoading = true;
|
||||
this.$emit("reached-bottom", true);
|
||||
// Reset loading state after a short delay
|
||||
setTimeout(() => {
|
||||
this.isLoading = false;
|
||||
}, 1000);
|
||||
}, 300);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -525,7 +525,7 @@ export default class HomeView extends Vue {
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text:
|
||||
err.userMessage ||
|
||||
(err as { userMessage?: string })?.userMessage ||
|
||||
"There was an error retrieving your settings or the latest activity.",
|
||||
},
|
||||
5000,
|
||||
@@ -658,7 +658,7 @@ export default class HomeView extends Vue {
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text:
|
||||
err.userMessage ||
|
||||
(err as { userMessage?: string })?.userMessage ||
|
||||
"There was an error retrieving your settings or the latest activity.",
|
||||
},
|
||||
5000,
|
||||
@@ -733,129 +733,210 @@ export default class HomeView extends Vue {
|
||||
async updateAllFeed() {
|
||||
this.isFeedLoading = true;
|
||||
let endOfResults = true;
|
||||
await this.retrieveGives(this.apiServer, this.feedPreviousOldestId)
|
||||
.then(async (results) => {
|
||||
if (results.data.length > 0) {
|
||||
endOfResults = false;
|
||||
// include the descriptions of the giver and receiver
|
||||
for (const record of results.data as GiveSummaryRecord[]) {
|
||||
// similar code is in endorser-mobile utility.ts
|
||||
// claim.claim happen for some claims wrapped in a Verifiable Credential
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const claim = (record.fullClaim as any).claim || record.fullClaim;
|
||||
// agent.did is for legacy data, before March 2023
|
||||
const giverDid =
|
||||
claim.agent?.identifier || (claim.agent as any)?.did; // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
// recipient.did is for legacy data, before March 2023
|
||||
const recipientDid =
|
||||
claim.recipient?.identifier || (claim.recipient as any)?.did; // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
|
||||
try {
|
||||
const results = await this.retrieveGives(this.apiServer, this.feedPreviousOldestId);
|
||||
if (results.data.length > 0) {
|
||||
endOfResults = false;
|
||||
await this.processFeedResults(results.data);
|
||||
await this.updateFeedLastViewedId(results.data);
|
||||
}
|
||||
} catch (e) {
|
||||
this.handleFeedError(e);
|
||||
}
|
||||
|
||||
// This has indeed proven problematic. See loadMoreGives
|
||||
// We should display it immediately and then get the plan later.
|
||||
const fulfillsPlan = await getPlanFromCache(
|
||||
record.fulfillsPlanHandleId,
|
||||
this.axios,
|
||||
this.apiServer,
|
||||
this.activeDid,
|
||||
);
|
||||
|
||||
// check if the record should be filtered out
|
||||
let anyMatch = false;
|
||||
if (this.isFeedFilteredByVisible && containsNonHiddenDid(record)) {
|
||||
// has a visible DID so it's a keeper
|
||||
anyMatch = true;
|
||||
}
|
||||
if (!anyMatch && this.isFeedFilteredByNearby) {
|
||||
// check if the associated project has a location inside user's search box
|
||||
if (record.fulfillsPlanHandleId) {
|
||||
if (fulfillsPlan?.locLat && fulfillsPlan?.locLon) {
|
||||
if (
|
||||
this.latLongInAnySearchBox(
|
||||
fulfillsPlan.locLat,
|
||||
fulfillsPlan.locLon,
|
||||
)
|
||||
) {
|
||||
anyMatch = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (this.isAnyFeedFilterOn && !anyMatch) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// checking for arrays due to legacy data
|
||||
const provider = Array.isArray(claim.provider)
|
||||
? claim.provider[0]
|
||||
: claim.provider;
|
||||
const providedByPlan = await getPlanFromCache(
|
||||
provider?.identifier as string,
|
||||
this.axios,
|
||||
this.apiServer,
|
||||
this.activeDid,
|
||||
);
|
||||
|
||||
const newRecord: GiveRecordWithContactInfo = {
|
||||
...record,
|
||||
jwtId: record.jwtId,
|
||||
giver: didInfoForContact(
|
||||
giverDid,
|
||||
this.activeDid,
|
||||
contactForDid(giverDid, this.allContacts),
|
||||
this.allMyDids,
|
||||
),
|
||||
image: claim.image,
|
||||
issuer: didInfoForContact(
|
||||
record.issuerDid,
|
||||
this.activeDid,
|
||||
contactForDid(record.issuerDid, this.allContacts),
|
||||
this.allMyDids,
|
||||
),
|
||||
providerPlanHandleId: provider?.identifier as string,
|
||||
providerPlanName: providedByPlan?.name as string,
|
||||
recipientProjectName: fulfillsPlan?.name as string,
|
||||
receiver: didInfoForContact(
|
||||
recipientDid,
|
||||
this.activeDid,
|
||||
contactForDid(recipientDid, this.allContacts),
|
||||
this.allMyDids,
|
||||
),
|
||||
};
|
||||
this.feedData.push(newRecord);
|
||||
}
|
||||
this.feedPreviousOldestId =
|
||||
results.data[results.data.length - 1].jwtId;
|
||||
// The following update is only done on the first load.
|
||||
if (
|
||||
this.feedLastViewedClaimId == null ||
|
||||
this.feedLastViewedClaimId < results.data[0].jwtId
|
||||
) {
|
||||
await db.open();
|
||||
await db.settings.update(MASTER_SETTINGS_KEY, {
|
||||
lastViewedClaimId: results.data[0].jwtId,
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
logger.error("Error with feed load:", e);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Feed Error",
|
||||
text: e.userMessage || "There was an error retrieving feed data.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
});
|
||||
if (this.feedData.length === 0 && !endOfResults) {
|
||||
// repeat until there's at least some data
|
||||
await this.updateAllFeed();
|
||||
}
|
||||
|
||||
this.isFeedLoading = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes feed results and adds them to feedData
|
||||
*/
|
||||
private async processFeedResults(records: GiveSummaryRecord[]) {
|
||||
for (const record of records) {
|
||||
const processedRecord = await this.processRecord(record);
|
||||
if (processedRecord) {
|
||||
this.feedData.push(processedRecord);
|
||||
}
|
||||
}
|
||||
this.feedPreviousOldestId = records[records.length - 1].jwtId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes a single record and returns it if it passes filters
|
||||
*/
|
||||
private async processRecord(record: GiveSummaryRecord): Promise<GiveRecordWithContactInfo | null> {
|
||||
const claim = this.extractClaim(record);
|
||||
const giverDid = this.extractGiverDid(claim);
|
||||
const recipientDid = this.extractRecipientDid(claim);
|
||||
|
||||
const fulfillsPlan = await this.getFulfillsPlan(record);
|
||||
if (!this.shouldIncludeRecord(record, fulfillsPlan)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const provider = this.extractProvider(claim);
|
||||
const providedByPlan = await this.getProvidedByPlan(provider);
|
||||
|
||||
return this.createFeedRecord(record, claim, giverDid, recipientDid, provider, fulfillsPlan, providedByPlan);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts claim from record, handling both direct and wrapped claims
|
||||
*/
|
||||
private extractClaim(record: GiveSummaryRecord) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return (record.fullClaim as any).claim || record.fullClaim;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts giver DID from claim
|
||||
*/
|
||||
private extractGiverDid(claim: any) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return claim.agent?.identifier || (claim.agent as any)?.did;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts recipient DID from claim
|
||||
*/
|
||||
private extractRecipientDid(claim: any) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return claim.recipient?.identifier || (claim.recipient as any)?.did;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets fulfills plan from cache
|
||||
*/
|
||||
private async getFulfillsPlan(record: GiveSummaryRecord) {
|
||||
return await getPlanFromCache(
|
||||
record.fulfillsPlanHandleId,
|
||||
this.axios,
|
||||
this.apiServer,
|
||||
this.activeDid,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if record should be included based on filters
|
||||
*/
|
||||
private shouldIncludeRecord(record: GiveSummaryRecord, fulfillsPlan: any): boolean {
|
||||
if (!this.isAnyFeedFilterOn) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let anyMatch = false;
|
||||
if (this.isFeedFilteredByVisible && containsNonHiddenDid(record)) {
|
||||
anyMatch = true;
|
||||
}
|
||||
|
||||
if (!anyMatch && this.isFeedFilteredByNearby && record.fulfillsPlanHandleId) {
|
||||
if (fulfillsPlan?.locLat && fulfillsPlan?.locLon) {
|
||||
anyMatch = this.latLongInAnySearchBox(fulfillsPlan.locLat, fulfillsPlan.locLon) ?? false;
|
||||
}
|
||||
}
|
||||
|
||||
return anyMatch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts provider from claim
|
||||
*/
|
||||
private extractProvider(claim: any) {
|
||||
return Array.isArray(claim.provider) ? claim.provider[0] : claim.provider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets provided by plan from cache
|
||||
*/
|
||||
private async getProvidedByPlan(provider: any) {
|
||||
return await getPlanFromCache(
|
||||
provider?.identifier as string,
|
||||
this.axios,
|
||||
this.apiServer,
|
||||
this.activeDid,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a feed record with contact info
|
||||
*/
|
||||
private createFeedRecord(
|
||||
record: GiveSummaryRecord,
|
||||
claim: any,
|
||||
giverDid: string,
|
||||
recipientDid: string,
|
||||
provider: any,
|
||||
fulfillsPlan: any,
|
||||
providedByPlan: any
|
||||
): GiveRecordWithContactInfo {
|
||||
return {
|
||||
...record,
|
||||
jwtId: record.jwtId,
|
||||
fullClaim: record.fullClaim,
|
||||
description: record.description || '',
|
||||
handleId: record.handleId,
|
||||
issuerDid: record.issuerDid,
|
||||
fulfillsPlanHandleId: record.fulfillsPlanHandleId,
|
||||
giver: didInfoForContact(
|
||||
giverDid,
|
||||
this.activeDid,
|
||||
contactForDid(giverDid, this.allContacts),
|
||||
this.allMyDids,
|
||||
),
|
||||
image: claim.image,
|
||||
issuer: didInfoForContact(
|
||||
record.issuerDid,
|
||||
this.activeDid,
|
||||
contactForDid(record.issuerDid, this.allContacts),
|
||||
this.allMyDids,
|
||||
),
|
||||
providerPlanHandleId: provider?.identifier as string,
|
||||
providerPlanName: providedByPlan?.name as string,
|
||||
recipientProjectName: fulfillsPlan?.name as string,
|
||||
receiver: didInfoForContact(
|
||||
recipientDid,
|
||||
this.activeDid,
|
||||
contactForDid(recipientDid, this.allContacts),
|
||||
this.allMyDids,
|
||||
),
|
||||
} as GiveRecordWithContactInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the last viewed claim ID in settings
|
||||
*/
|
||||
private async updateFeedLastViewedId(records: GiveSummaryRecord[]) {
|
||||
if (
|
||||
this.feedLastViewedClaimId == null ||
|
||||
this.feedLastViewedClaimId < records[0].jwtId
|
||||
) {
|
||||
await db.open();
|
||||
await db.settings.update(MASTER_SETTINGS_KEY, {
|
||||
lastViewedClaimId: records[0].jwtId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles feed error and shows notification
|
||||
*/
|
||||
private handleFeedError(e: any) {
|
||||
logger.error("Error with feed load:", e);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Feed Error",
|
||||
text: e.userMessage || "There was an error retrieving feed data.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve claims in reverse chronological order
|
||||
*
|
||||
|
||||
@@ -88,12 +88,8 @@ import { test, expect } from '@playwright/test';
|
||||
import { importUser, createUniqueStringsArray, createRandomNumbersArray } from './testUtils';
|
||||
|
||||
test('Record 9 new gifts', async ({ page }) => {
|
||||
const giftCount = 9; // because 10 has taken us above 30 seconds
|
||||
|
||||
// Standard text
|
||||
const giftCount = 9;
|
||||
const standardTitle = 'Gift ';
|
||||
|
||||
// Field value arrays
|
||||
const finalTitles = [];
|
||||
const finalNumbers = [];
|
||||
|
||||
@@ -101,21 +97,19 @@ test('Record 9 new gifts', async ({ page }) => {
|
||||
const uniqueStrings = await createUniqueStringsArray(giftCount);
|
||||
const randomNumbers = await createRandomNumbersArray(giftCount);
|
||||
|
||||
// Populate array with titles
|
||||
// Populate arrays
|
||||
for (let i = 0; i < giftCount; i++) {
|
||||
let loopTitle = standardTitle + uniqueStrings[i];
|
||||
finalTitles.push(loopTitle);
|
||||
let loopNumber = randomNumbers[i];
|
||||
finalNumbers.push(loopNumber);
|
||||
finalTitles.push(standardTitle + uniqueStrings[i]);
|
||||
finalNumbers.push(randomNumbers[i]);
|
||||
}
|
||||
|
||||
// Import user 00
|
||||
await importUser(page, '00');
|
||||
|
||||
// Record new gifts
|
||||
// Record new gifts with optimized waiting
|
||||
for (let i = 0; i < giftCount; i++) {
|
||||
// Record something given
|
||||
await page.goto('./');
|
||||
// Record gift
|
||||
await page.goto('./', { waitUntil: 'networkidle' });
|
||||
if (i === 0) {
|
||||
await page.getByTestId('closeOnboardingAndFinish').click();
|
||||
}
|
||||
@@ -123,11 +117,16 @@ test('Record 9 new gifts', async ({ page }) => {
|
||||
await page.getByPlaceholder('What was given').fill(finalTitles[i]);
|
||||
await page.getByRole('spinbutton').fill(finalNumbers[i].toString());
|
||||
await page.getByRole('button', { name: 'Sign & Send' }).click();
|
||||
|
||||
// Wait for success and dismiss
|
||||
await expect(page.getByText('That gift was recorded.')).toBeVisible();
|
||||
await page.locator('div[role="alert"] button > svg.fa-xmark').click(); // dismiss info alert
|
||||
await page.locator('div[role="alert"] button > svg.fa-xmark').click();
|
||||
|
||||
// Refresh home view and check gift
|
||||
await page.goto('./');
|
||||
await expect(page.locator('li').filter({ hasText: finalTitles[i] })).toBeVisible();
|
||||
// Verify gift in list with network idle wait
|
||||
await page.goto('./', { waitUntil: 'networkidle' });
|
||||
await expect(page.locator('ul#listLatestActivity li')
|
||||
.filter({ hasText: finalTitles[i] })
|
||||
.first())
|
||||
.toBeVisible({ timeout: 10000 });
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user