Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions .storybook/stories/BorrowerProfile/LoanProgress.stories.js
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ export const PayingBack = () => ({
template: `
<loan-progress
loan-status="payingBack"
status-label="Paying back"
:progress-percent="0.60"
money-left="400.00"
:loading="false"
Expand All @@ -101,7 +102,7 @@ export const PayingBackDelinquent = () => ({
template: `
<loan-progress
loan-status="payingBack"
:is-delinquent="true"
status-label="Paying back delinquent"
:progress-percent="0.60"
money-left="400.00"
:loading="false"
Expand All @@ -116,6 +117,7 @@ export const Ended = () => ({
template: `
<loan-progress
loan-status="ended"
status-label="Repaid"
:progress-percent="1"
money-left="0.00"
:loading="false"
Expand All @@ -129,7 +131,7 @@ export const EndedWithCurrencyLoss = () => ({
template: `
<loan-progress
loan-status="ended"
:has-currency-exchange-loss="true"
status-label="Repaid with currency loss"
:progress-percent="1"
money-left="0.00"
:loading="false"
Expand All @@ -144,6 +146,7 @@ export const Defaulted = () => ({
template: `
<loan-progress
loan-status="defaulted"
status-label="Ended in default"
:progress-percent="0.45"
money-left="550.00"
:loading="false"
Expand All @@ -157,6 +160,7 @@ export const Refunded = () => ({
template: `
<loan-progress
loan-status="refunded"
status-label="Refunded"
:progress-percent="1"
money-left="0.00"
:loading="false"
Expand All @@ -170,6 +174,7 @@ export const InactiveExpired = () => ({
template: `
<loan-progress
loan-status="inactiveExpired"
status-label="Inactive expired"
:progress-percent="0"
money-left="500.00"
:loading="false"
Expand All @@ -183,6 +188,7 @@ export const Reviewed = () => ({
template: `
<loan-progress
loan-status="reviewed"
status-label="Reviewed"
:progress-percent="0"
money-left="1000.00"
:loading="false"
Expand All @@ -196,6 +202,7 @@ export const Deleted = () => ({
template: `
<loan-progress
loan-status="deleted"
status-label="Deleted"
:progress-percent="0"
money-left="1000.00"
:loading="false"
Expand All @@ -209,6 +216,7 @@ export const Issue = () => ({
template: `
<loan-progress
loan-status="issue"
status-label="Issue"
:progress-percent="0.20"
money-left="800.00"
:loading="false"
Expand All @@ -217,6 +225,20 @@ export const Issue = () => ({
`,
});

export const MissingStatusLabel = () => ({
components: { LoanProgress },
template: `
<loan-progress
loan-status="reviewed"
:progress-percent="0"
money-left="1000.00"
:loading="false"
:loan-id="123"
/>
`,
});
MissingStatusLabel.storyName = 'Missing Status Label (falls back to the raw status)';

export const Loading = () => ({
components: { LoanProgress },
template: `
Expand Down
30 changes: 7 additions & 23 deletions src/components/BorrowerProfile/LoanProgress.vue
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@
</div>
<div v-else class="tw-flex tw-flex-auto">
<span class="tw-text-h3 tw-block tw-m-0">
{{ isDelinquent ? 'Paying back delinquent' : 'Paying back' }}
{{ displayStatusLabel }}
</span>
<div class="tw-flex-auto tw-text-right">
<p class="tw-text-h3 tw-m-0" data-testid="bp-summary-amount-to-go">
Expand Down Expand Up @@ -154,7 +154,7 @@
</template>
<div v-else class="tw-w-full">
<p class="tw-text-h3 tw-m-0" data-testid="bp-summary-status-label">
{{ statusLabel }}
{{ displayStatusLabel }}
</p>
</div>
</figcaption>
Expand Down Expand Up @@ -192,13 +192,9 @@ export default {
type: String,
default: 'fundraising',
},
isDelinquent: {
type: Boolean,
default: false,
},
hasCurrencyExchangeLoss: {
type: Boolean,
default: false,
statusLabel: {
type: String,
default: '',
},
numberOfLenders: {
type: Number,
Expand Down Expand Up @@ -246,20 +242,8 @@ export default {
routeId() {
return this.loanId ? this.loanId : this.$route.params.id;
},
statusLabel() {
if (this.loanStatus === 'ended' && this.hasCurrencyExchangeLoss) {
return 'Repaid with currency loss';
}
const labels = {
ended: 'Repaid',
defaulted: 'Defaulted',
refunded: 'Refunded',
inactiveExpired: 'Inactive',
reviewed: 'Under review',
deleted: 'Deleted',
issue: 'Issue',
};
return labels[this.loanStatus] || this.loanStatus;
displayStatusLabel() {
return this.statusLabel || this.loanStatus;
},
},
};
Expand Down
11 changes: 7 additions & 4 deletions src/components/BorrowerProfile/MinimalBorrowerProfile.vue
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
:progress-percent="progressPercent"
:loading="isSummaryLoading"
:loan-status="loanStatus"
:status-label="statusLabel"
:is-live-loan-ad="isLiveLoanAd"
/>
</div>
Expand Down Expand Up @@ -123,6 +124,7 @@ export const minimalProfileFragment = gql`fragment minimalProfileFields on LoanB
id
name
status
statusLabel
use
anonymizationLevel
loanAmount
Expand Down Expand Up @@ -242,10 +244,8 @@ export default {
rows: null,
isVisitor: true,
loanRowsCount: 4,
// Initialize from the loan prop (populated by the parent page's routingQuery,
// which carries shareMetaFragment fields including name and country). Without
// this, SSR renders with loanData={} and head() produces the broken
// "undefined from undefined's loan has been funded!" title
// Seeded from the loan prop, which the parent page reads back from this
// component's own prefetched query.
loanData: this.loan?.id ? { ...this.loan } : {},
};
},
Expand Down Expand Up @@ -273,6 +273,9 @@ export default {
}
return this.loanData?.status ?? 'funded';
},
statusLabel() {
return this.loanData?.statusLabel ?? '';
},
progressPercent() {
if (this.loanStatus === 'funded') {
return 1;
Expand Down
13 changes: 4 additions & 9 deletions src/components/BorrowerProfile/SummaryCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,7 @@
:progress-percent="effectiveProgressPercent"
:time-left="timeLeft"
:loan-status="inPfp ? 'pfp' : status"
:is-delinquent="delinquent"
:has-currency-exchange-loss="hasCurrencyExchangeLoss"
:status-label="statusLabel"
:number-of-lenders="numLenders"
:pfp-min-lenders="pfpMinLenders"
:loading="isLoading"
Expand Down Expand Up @@ -167,8 +166,7 @@ export const summaryCardFragment = gql`fragment summaryCardFields on LoanBasic {
}
name
status
delinquent
hasCurrencyExchangeLossLenders
statusLabel
use
anonymizationLevel
borrowerCount
Expand Down Expand Up @@ -286,11 +284,8 @@ export default {
status() {
return this.loan?.status ?? '';
},
delinquent() {
return this.loan?.delinquent ?? false;
},
hasCurrencyExchangeLoss() {
return this.loan?.hasCurrencyExchangeLossLenders ?? false;
statusLabel() {
return this.loan?.statusLabel ?? '';
},
use() {
return this.loan?.fullLoanUse ?? '';
Expand Down
26 changes: 17 additions & 9 deletions src/pages/BorrowerProfile/BorrowerProfile.vue
Original file line number Diff line number Diff line change
Expand Up @@ -350,24 +350,32 @@ export default {
},
result(result) {
const routingLoan = result?.data?.lend?.loan ?? {};
// Prefer the enriched full-profile entry; minimal-view paths fall back to routingLoan below.
let fullLoan = null;
let fullMy = null;
const isVolunteer = !!result?.data?.my?.userAccount?.volunteerId;
// Read back whichever child query preFetch ran.
const childQuery = showFullView(
routingLoan.status,
Number(routingLoan.unreservedAmount ?? 0),
routingLoan.userProperties?.isPrivileged ?? false,
isVolunteer,
this.$route?.query,
) ? fullProfileQuery : minimalProfileQuery;
let childLoan = null;
let childMy = null;
if (routingLoan.id) {
try {
const cached = this.apollo.readQuery({
query: fullProfileQuery,
query: childQuery,
variables: { loanId: routingLoan.id },
});
fullLoan = cached?.lend?.loan;
fullMy = cached?.my;
childLoan = cached?.lend?.loan;
childMy = cached?.my;
} catch {
// Not in cache; fall back below.
}
}
this.loan = fullLoan ?? routingLoan;
this.loan = childLoan ?? routingLoan;
this.routingLoan = routingLoan;
this.isVolunteer = !!result?.data?.my?.userAccount?.volunteerId;
this.isVolunteer = isVolunteer;
this.inviterName = this.inviterIsGuestOrAnonymous
? '' : result?.data?.community?.lender?.name ?? '';
this.itemsInBasket = result?.data?.shop?.basket?.items?.values ?? [];
Expand All @@ -377,7 +385,7 @@ export default {
// SSR initial rail state from the account preference (localStorage is reconciled
// client-side in FullBorrowerProfile); null for anon, so this stays false.
this.initialShowDetailsInRail = resolveRailPreference({
accountPref: readAccountRailPreference(fullMy?.userPreferences),
accountPref: readAccountRailPreference(childMy?.userPreferences),
local: null,
});
},
Expand Down
107 changes: 107 additions & 0 deletions test/unit/specs/pages/BorrowerProfile/BorrowerProfile.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -137,3 +137,110 @@ describe('BorrowerProfile.apollo.preFetch', () => {
expect(getChildProfileOperationName(client)).toBe('minimalBorrowerProfileData');
});
});

describe('BorrowerProfile.apollo.result', () => {
const makeRoutingLoan = (status, { isPrivileged = false, unreservedAmount = '400' } = {}) => ({
id: 12345,
status,
unreservedAmount,
userProperties: { isPrivileged },
});

const makeCtx = ({ cached = null, routeQuery = {} } = {}) => ({
apollo: {
readQuery: vi.fn(() => cached),
},
$route: { query: routeQuery },
inviterIsGuestOrAnonymous: false,
expRegionList: [],
});

const invokeResult = (ctx, loan, { volunteerId = null } = {}) => {
BorrowerProfile.apollo.result.call(ctx, {
data: { lend: { loan }, my: { userAccount: { volunteerId } } },
});
};

const readQueryOperation = ctx => getOperationName(ctx.apollo.readQuery.mock.calls[0][0].query);

it.each([
{
title: 'a fundraising loan with shares remaining',
loan: makeRoutingLoan('fundraising'),
options: {},
routeQuery: {},
expected: 'fullBorrowerProfileData',
},
{
title: 'a fully-reserved funded loan',
loan: makeRoutingLoan('funded', { unreservedAmount: '0' }),
options: {},
routeQuery: {},
expected: 'minimalBorrowerProfileData',
},
{
title: 'a privileged viewer on a reviewed loan',
loan: makeRoutingLoan('reviewed', { isPrivileged: true }),
options: {},
routeQuery: {},
expected: 'fullBorrowerProfileData',
},
{
title: 'a volunteer viewer on a fully-reserved loan',
loan: makeRoutingLoan('funded', { unreservedAmount: '0' }),
options: { volunteerId: 987 },
routeQuery: {},
expected: 'fullBorrowerProfileData',
},
{
title: 'a minimal=false override on a fully-reserved loan',
loan: makeRoutingLoan('funded', { unreservedAmount: '0' }),
options: {},
routeQuery: { minimal: 'false' },
expected: 'fullBorrowerProfileData',
},
])('reads back $expected for $title', ({
loan, options, routeQuery, expected,
}) => {
const ctx = makeCtx({ routeQuery });

invokeResult(ctx, loan, options);

expect(readQueryOperation(ctx)).toBe(expected);
});

it('surfaces the cached minimal-profile loan rather than the routing loan', () => {
const loan = makeRoutingLoan('ended', { unreservedAmount: '0' });
const ctx = makeCtx({
cached: { lend: { loan: { id: 12345, status: 'ended', statusLabel: 'Repaid' } } },
});

invokeResult(ctx, loan);

expect(ctx.loan.statusLabel).toBe('Repaid');
expect(ctx.routingLoan).toEqual(loan);
});

it('carries the cached account rail preference into the SSR initial state', () => {
const loan = makeRoutingLoan('fundraising');
const ctx = makeCtx({
cached: {
lend: { loan: { id: 12345, status: 'fundraising' } },
my: { id: 1, userPreferences: { id: 2, preferences: '{"showLoanDetailsInRail":true}' } },
},
});

invokeResult(ctx, loan);

expect(ctx.initialShowDetailsInRail).toBe(true);
});

it('falls back to the routing loan when the child query is not cached', () => {
const loan = makeRoutingLoan('ended', { unreservedAmount: '0' });
const ctx = makeCtx();

invokeResult(ctx, loan);

expect(ctx.loan).toEqual(loan);
});
});
Loading