From 77421721c85bc393b407198b9513c089f1a0404c Mon Sep 17 00:00:00 2001 From: Sid Vishnoi <8426945+sidvishnoi@users.noreply.github.com> Date: Mon, 23 Sep 2024 21:44:39 +0530 Subject: [PATCH] refactor: introduce `ErrorWithKey` (translation keys as error code) --- src/background/services/background.ts | 5 ++ src/popup/lib/context.tsx | 10 +++- src/shared/helpers.ts | 70 +++++++++++++++++++++++---- src/shared/messages.ts | 2 + 4 files changed, 76 insertions(+), 11 deletions(-) diff --git a/src/background/services/background.ts b/src/background/services/background.ts index 885a6e20..13b3c948 100644 --- a/src/background/services/background.ts +++ b/src/background/services/background.ts @@ -4,6 +4,7 @@ import { failure, getNextOccurrence, getWalletInformation, + isErrorWithKey, success, } from '@/shared/helpers'; import { OpenPaymentsClientError } from '@interledger/open-payments/dist/client/error'; @@ -250,6 +251,10 @@ export class Background { return; } } catch (e) { + if (isErrorWithKey(e)) { + this.logger.error(message.action, e); + return failure({ key: e.key, substitutions: e.substitutions }); + } if (e instanceof OpenPaymentsClientError) { this.logger.error(message.action, e.message, e.description); return failure( diff --git a/src/popup/lib/context.tsx b/src/popup/lib/context.tsx index a89a840e..91fcadda 100644 --- a/src/popup/lib/context.tsx +++ b/src/popup/lib/context.tsx @@ -1,6 +1,10 @@ import React, { type PropsWithChildren } from 'react'; import type { Browser } from 'webextension-polyfill'; -import { tFactory, type Translation } from '@/shared/helpers'; +import { + tFactory, + type IErrorWithKey, + type Translation, +} from '@/shared/helpers'; import type { DeepNonNullable, PopupStore } from '@/shared/types'; import { BACKGROUND_TO_POPUP_CONNECTION_NAME as CONNECTION_NAME, @@ -153,7 +157,9 @@ export const BrowserContextProvider = ({ // #endregion // #region Translation -const TranslationContext = React.createContext((v: string) => v); +const TranslationContext = React.createContext( + (v: string | IErrorWithKey) => (typeof v === 'string' ? v : v.key), +); export const useTranslation = () => React.useContext(TranslationContext); diff --git a/src/shared/helpers.ts b/src/shared/helpers.ts index 83128f5a..591c253e 100644 --- a/src/shared/helpers.ts +++ b/src/shared/helpers.ts @@ -9,6 +9,9 @@ import { parse, toSeconds } from 'iso8601-duration'; import type { Browser } from 'webextension-polyfill'; import type { Storage, RepeatingInterval, AmountValue } from './types'; +export type TranslationKeys = + keyof typeof import('../_locales/en/messages.json'); + export const cn = (...inputs: CxOptions) => { return twMerge(cx(inputs)); }; @@ -53,6 +56,44 @@ export const getWalletInformation = async ( return json; }; +/** + * Error object with key and substitutions based on `_locales/[lang]/messages.json` + */ +export interface IErrorWithKey { + key: Extract; + // Could be empty, but required for checking if an object follows this interface + substitutions: string[]; +} + +export class ErrorWithKey + extends Error + implements IErrorWithKey +{ + constructor( + public readonly key: IErrorWithKey['key'], + public readonly substitutions: IErrorWithKey['substitutions'] = [], + ) { + super(key); + } +} + +/** + * Same as {@linkcode ErrorWithKey} but creates plain object instead of Error + * instance. + * Easier than creating object ourselves, but more performant than Error. + */ +export const errorWithKey = ( + key: IErrorWithKey['key'], + substitutions: IErrorWithKey['substitutions'] = [], +) => ({ key, substitutions }); + +export const isErrorWithKey = (err: any): err is IErrorWithKey => { + return ( + err instanceof ErrorWithKey || + (typeof err.key === 'string' && Array.isArray(err.substitutions)) + ); +}; + export const success = ( payload: TPayload, ): SuccessResponse => ({ @@ -60,9 +101,11 @@ export const success = ( payload, }); -export const failure = (message: string) => ({ - success: false, - message, +export const failure = (message: string | IErrorWithKey) => ({ + success: false as const, + ...(typeof message === 'string' + ? { message } + : { error: message, message: message.key }), }); export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); @@ -199,19 +242,28 @@ export function bigIntMax(a: T, b: T): T { return BigInt(a) > BigInt(b) ? a : b; } -export type TranslationKeys = - keyof typeof import('../_locales/en/messages.json'); - export type Translation = ReturnType; export function tFactory(browser: Pick) { /** * Helper over calling cumbersome `this.browser.i18n.getMessage(key)` with * added benefit that it type-checks if key exists in message.json */ - return ( + function t( key: T, - substitutions?: string | string[], - ) => browser.i18n.getMessage(key, substitutions); + substitutions?: string[], + ): string; + function t(err: IErrorWithKey): string; + function t( + key: T | IErrorWithKey, + substitutions?: string[], + ): string { + if (typeof key === 'string') { + return browser.i18n.getMessage(key, substitutions); + } + const err = key; + return browser.i18n.getMessage(err.key, err.substitutions); + } + return t; } type Primitive = string | number | boolean | null | undefined; diff --git a/src/shared/messages.ts b/src/shared/messages.ts index 960d3a33..ceb7db1c 100644 --- a/src/shared/messages.ts +++ b/src/shared/messages.ts @@ -4,6 +4,7 @@ import type { } from '@interledger/open-payments'; import type { Browser } from 'webextension-polyfill'; import type { AmountValue, Storage } from '@/shared/types'; +import type { IErrorWithKey } from '@/shared/helpers'; import type { PopupState } from '@/popup/lib/context'; // #region MessageManager @@ -15,6 +16,7 @@ export interface SuccessResponse { export interface ErrorResponse { success: false; message: string; + error?: IErrorWithKey; } export type Response =