Skip to content

Commit

Permalink
feat: support modifying budget (#675)
Browse files Browse the repository at this point in the history
  • Loading branch information
sidvishnoi authored Oct 24, 2024
1 parent 20abede commit 721f0ab
Show file tree
Hide file tree
Showing 4 changed files with 206 additions and 25 deletions.
4 changes: 4 additions & 0 deletions src/background/services/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,10 @@ export class Background {
return success(undefined);
}

case 'UPDATE_BUDGET':
await this.openPaymentsService.updateBudget(message.payload);
return success(undefined);

case 'ADD_FUNDS':
await this.openPaymentsService.addFunds(message.payload);
await this.browser.alarms.clear(ALARM_RESET_OUT_OF_FUNDS);
Expand Down
46 changes: 44 additions & 2 deletions src/background/services/openPayments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,11 @@ import {
withResolvers,
type ErrorWithKeyLike,
} from '@/shared/helpers';
import type { AddFundsPayload, ConnectWalletPayload } from '@/shared/messages';
import type {
AddFundsPayload,
ConnectWalletPayload,
UpdateBudgetPayload,
} from '@/shared/messages';
import {
DEFAULT_RATE_OF_PAY,
MAX_RATE_OF_PAY,
Expand Down Expand Up @@ -116,6 +120,7 @@ const enum GrantResult {
const enum InteractionIntent {
CONNECT = 'connect',
FUNDS = 'funds',
BUDGET_UPDATE = 'budget_update',
}

export class OpenPaymentsService {
Expand Down Expand Up @@ -459,6 +464,43 @@ export class OpenPaymentsService {
await this.storage.setState({ out_of_funds: false });
}

async updateBudget({ amount, recurring }: UpdateBudgetPayload) {
const { walletAddress, ...existingGrants } = await this.storage.get([
'walletAddress',
'oneTimeGrant',
'recurringGrant',
]);

await this.completeGrant(
amount,
walletAddress!,
recurring,
InteractionIntent.BUDGET_UPDATE,
);

// Revoke all existing grants.
// Note: Clear storage only if new grant type is not same as previous grant
// type (as completeGrant already sets new grant state)
if (existingGrants.oneTimeGrant) {
await this.cancelGrant(existingGrants.oneTimeGrant.continue);
if (recurring) {
this.storage.set({
oneTimeGrant: null,
oneTimeGrantSpentAmount: '0',
});
}
}
if (existingGrants.recurringGrant) {
await this.cancelGrant(existingGrants.recurringGrant.continue);
if (!recurring) {
this.storage.set({
recurringGrant: null,
recurringGrantSpentAmount: '0',
});
}
}
}

private async completeGrant(
amount: string,
walletAddress: WalletAddress,
Expand All @@ -479,7 +521,7 @@ export class OpenPaymentsService {
amount: transformedAmount,
}).catch((err) => {
if (isInvalidClientError(err)) {
if (intent === InteractionIntent.CONNECT) {
if (intent !== InteractionIntent.FUNDS) {
throw new ErrorWithKey('connectWallet_error_invalidClient');
}
const msg = this.t('connectWallet_error_invalidClient');
Expand Down
171 changes: 148 additions & 23 deletions src/popup/components/Settings/Budget.tsx
Original file line number Diff line number Diff line change
@@ -1,46 +1,151 @@
import React from 'react';
import { Input } from '@/popup/components/ui/Input';
import { Switch } from '@/popup/components/ui/Switch';
import { getNextOccurrence } from '@/shared/helpers';
import { Button } from '@/popup/components/ui/Button';
import { InputAmount } from '@/popup/components/InputAmount';
import { ErrorMessage } from '@/popup/components/ErrorMessage';
import { ErrorWithKeyLike, getNextOccurrence } from '@/shared/helpers';
import { getCurrencySymbol, transformBalance } from '@/popup/lib/utils';
import type { PopupState } from '@/popup/lib/context';
import {
useMessage,
useTranslation,
type PopupState,
} from '@/popup/lib/context';
import type { Response, UpdateBudgetPayload } from '@/shared/messages';

type Props = Pick<PopupState, 'balance' | 'grants' | 'walletAddress'>;

export const BudgetScreen = ({ grants, walletAddress, balance }: Props) => {
const message = useMessage();
return (
<div className="space-y-8">
<BudgetAmount walletAddress={walletAddress} grants={grants} />
<RemainingBalance walletAddress={walletAddress} balance={balance} />
<BudgetAmount
walletAddress={walletAddress}
grants={grants}
handleChange={(payload) => message.send('UPDATE_BUDGET', payload)}
onBudgetChanged={() => {
// TODO: send user to the settings/budget page, but with new data
window.location.reload();
}}
/>
</div>
);
};

type BudgetAmountProps = Pick<PopupState, 'grants' | 'walletAddress'>;
type BudgetAmountProps = {
grants: PopupState['grants'];
walletAddress: PopupState['walletAddress'];
handleChange: (payload: UpdateBudgetPayload) => Promise<Response>;
onBudgetChanged: () => void;
};

type ErrorInfo = { message: string; info?: ErrorWithKeyLike };
type ErrorsParams = 'amount' | 'root';
type Errors = Record<ErrorsParams, ErrorInfo | null>;

const BudgetAmount = ({ grants, walletAddress }: BudgetAmountProps) => {
const budget = transformBalance(
grants.recurring?.value ?? grants.oneTime!.value,
walletAddress.assetScale,
const BudgetAmount = ({
grants,
walletAddress,
handleChange,
onBudgetChanged,
}: BudgetAmountProps) => {
const t = useTranslation();

const toErrorInfo = React.useCallback(
(err?: string | ErrorWithKeyLike | null): ErrorInfo | null => {
if (!err) return null;
if (typeof err === 'string') return { message: err };
return { message: t(err), info: err };
},
[t],
);

const renewDate = grants.recurring?.interval
? getNextOccurrence(grants.recurring.interval)
: null;
const originalValues = {
walletAddressUrl: walletAddress.id,
amount: transformBalance(
grants.recurring?.value ?? grants.oneTime!.value,
walletAddress.assetScale,
),
recurring: !!grants.recurring?.interval,
};

const [amount, setAmount] = React.useState(originalValues.amount);
const [recurring, setRecurring] = React.useState(originalValues.recurring);
const [errors, setErrors] = React.useState<Errors>({
amount: null,
root: null,
});

const [isSubmitting, setIsSubmitting] = React.useState(false);
const [changed, setChanged] = React.useState({
amount: false,
recurring: false,
});

const onSubmit = async (ev: React.FormEvent<HTMLFormElement>) => {
ev.preventDefault();
setErrors({ amount: null, root: null });
setIsSubmitting(true);
try {
const res = await handleChange({
walletAddressUrl: walletAddress.id,
amount,
recurring,
});
if (!res.success) {
setErrors((prev) => ({
...prev,
root: toErrorInfo(res.error || res.message),
}));
} else {
setChanged({ amount: false, recurring: false });
}
onBudgetChanged();
} catch (error) {
setErrors((prev) => ({ ...prev, root: toErrorInfo(error) }));
}
setIsSubmitting(false);
};

const renewDate = React.useMemo(() => {
let interval;
if (!changed.amount && !changed.recurring) {
interval = grants.recurring?.interval;
}
if (!interval) {
if (changed.recurring && !recurring) {
interval = undefined;
} else if ((changed.recurring && recurring) || changed.amount) {
interval = `R/${new Date().toISOString()}/P1M`;
} else if (grants.recurring?.interval) {
interval = grants.recurring.interval;
}
}
return interval ? getNextOccurrence(interval) : null;
}, [changed.amount, changed.recurring, grants.recurring, recurring]);

return (
<div className="space-y-2">
<form className="space-y-2" onSubmit={onSubmit}>
<div className="flex items-center gap-4">
<Input
<InputAmount
id="budgetAmount"
label="Budget amount"
walletAddress={walletAddress}
className="max-w-56"
addOn={
<span className="text-weak">
{getCurrencySymbol(walletAddress.assetCode)}
</span>
}
value={budget}
disabled={true}
amount={amount}
onChange={(amount) => {
setErrors((prev) => ({ ...prev, amount: null }));
setAmount(amount);
setChanged((prev) => ({
...prev,
amount: amount !== originalValues.amount,
}));
}}
onError={(err) => {
setErrors((prev) => ({ ...prev, amount: toErrorInfo(err) }));
}}
errorMessage={errors.amount?.message}
/>
<div>
<span
Expand All @@ -51,8 +156,15 @@ const BudgetAmount = ({ grants, walletAddress }: BudgetAmountProps) => {
</span>
<Switch
label="Monthly"
checked={!!grants.recurring?.interval}
disabled={true}
checked={recurring}
onChange={(ev) => {
const checked = ev.currentTarget.checked;
setRecurring(checked);
setChanged((prev) => ({
...prev,
recurring: originalValues.recurring !== checked,
}));
}}
/>
</div>
</div>
Expand All @@ -73,7 +185,20 @@ const BudgetAmount = ({ grants, walletAddress }: BudgetAmountProps) => {
.
</p>
)}
</div>

<div className="space-y-1">
{errors.root?.message && <ErrorMessage error={errors.root.message} />}

<Button
type="submit"
className="w-full"
disabled={(!changed.amount && !changed.recurring) || isSubmitting}
loading={isSubmitting}
>
Submit changes
</Button>
</div>
</form>
);
};

Expand Down
10 changes: 10 additions & 0 deletions src/shared/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,12 @@ export interface UpdateRateOfPayPayload {
rateOfPay: string;
}

export interface UpdateBudgetPayload {
walletAddressUrl: ConnectWalletPayload['walletAddressUrl'];
amount: ConnectWalletPayload['amount'];
recurring: ConnectWalletPayload['recurring'];
}

export type PopupToBackgroundMessage = {
GET_CONTEXT_DATA: {
input: never;
Expand All @@ -119,6 +125,10 @@ export type PopupToBackgroundMessage = {
input: null | ConnectWalletPayload;
output: void;
};
UPDATE_BUDGET: {
input: UpdateBudgetPayload;
output: void;
};
RECONNECT_WALLET: {
input: never;
output: never;
Expand Down

0 comments on commit 721f0ab

Please sign in to comment.