Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(errors): properly translate missing field error #217

Merged
merged 5 commits into from
Apr 16, 2024
Merged
Show file tree
Hide file tree
Changes from 2 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
44 changes: 44 additions & 0 deletions apps/delivery-options/src/composables/useLanguage.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,57 @@ describe('useLanguage', () => {
[{key: 'my_string'}, 'My String'],
[{key: 'no_match'}, 'no_match'],
[{key: 'my_string', plain: true}, 'my_string'],
[
{
key: 'string_with_args',
args: {
field: {key: 'number', plain: true},
status: 'my_string',
},
},
'My number is all My String',
],
[
{
key: 'string_with_args',
args: {
field: {
key: 'city',
plain: true,
},
},
},
'My city is all {status}',
],
[
{
key: 'string_with_args',
args: {
field: {
key: 'string_with_args',
args: {
field: {
key: 'number',
plain: true,
},
},
},
status: {
key: 'recursive',
plain: true,
},
},
},
'My My number is all {status} is all recursive',
],
] satisfies [AnyTranslatable, string][])('can translate %s to %s', (translatable, expected) => {
expect.assertions(1);

const {setStrings, translate} = useLanguage();

setStrings({
my_string: 'My String',
string_with_args: 'My {field} is all {status}',
});

expect(translate(translatable)).toBe(expected);
Expand Down
26 changes: 25 additions & 1 deletion apps/delivery-options/src/composables/useLanguage.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
import {computed, type ComputedRef, reactive} from 'vue';
import {get} from 'radash';
import {useMemoize, isDef} from '@vueuse/core';
import {useLogger, type AnyTranslatable, resolveTranslatable, isTranslatable} from '@myparcel-do/shared';
import {
useLogger,
type AnyTranslatable,
resolveTranslatable,
isTranslatable,
type TranslatableWithArgs,
} from '@myparcel-do/shared';
import {isOfType} from '@myparcel/ts-utils';
import {useConfigStore} from '../stores';

interface UseLanguage {
Expand Down Expand Up @@ -35,6 +42,23 @@ const translate = useMemoize((translatable: AnyTranslatable): string => {
return resolvedKey;
}

const replacers = translation.match(/\{(.+?)}/g);

if (replacers?.length && isOfType<TranslatableWithArgs>(translatable, 'args')) {
replacers.reverse();

return replacers.reduce((string, match) => {
const argKey = match.slice(1, -1);
const matchingArg = translatable?.args?.[argKey];

if (!matchingArg) {
return string;
}

return string.replace(match, translate(matchingArg));
}, translation);
}

return translation;
});

Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,16 @@
<template>
<ul>
<li
v-for="error in (exceptions as ErrorResponse['errors'])"
:key="error.message"
v-for="error in exceptions"
:key="error.code"
class="before:mp-absolute before:mp-bg-red-500 before:mp-h-full before:mp-inset-0 before:mp-w-1 mp-bg-opacity-5 mp-bg-red-500 mp-border mp-overflow-hidden mp-px-6 mp-py-4 mp-relative mp-rounded-lg">
<span v-text="translate(`error${error.code}`)" />
<span v-text="translate(error.label)" />
</li>
</ul>
</template>

<script lang="ts" setup>
import {useApiExceptions} from '@myparcel-do/shared';
import {type ErrorResponse} from '@myparcel/sdk';
import {useLanguage} from '../../composables';

const {exceptions} = useApiExceptions();
Expand Down
101 changes: 101 additions & 0 deletions libs/shared/src/composables/useApiExceptions.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import {describe, it, expect, beforeEach} from 'vitest';
import {ApiException} from '@myparcel/sdk';
import {type TranslatableWithArgs} from '../types';
import {ERROR_INVALID_POSTAL_CODE, IGNORED_ERRORS, ERROR_MISSING_REQUIRED_PARAMETER, ERROR_REPLACE_MAP} from '../data';
import {useApiExceptions} from './useApiExceptions';

const createException = (code: string | number, message = 'error'): ApiException => {
return new ApiException({
message: 'error',
request_id: '...',
errors: [{code: Number(code), message}],
});
};

describe('useApiExceptions', () => {
beforeEach(() => {
useApiExceptions().clear();
});

it('adds exceptions', () => {
const {addException, exceptions, hasExceptions} = useApiExceptions();
const exception = createException(ERROR_INVALID_POSTAL_CODE);

addException(['test'], exception);

expect(hasExceptions.value).toBe(true);
expect(exceptions.value).toEqual([
{
code: ERROR_INVALID_POSTAL_CODE,
label: `error${ERROR_INVALID_POSTAL_CODE}`,
},
]);

addException(['test'], createException(ERROR_INVALID_POSTAL_CODE));

// Expect no duplicates
expect(exceptions.value).toHaveLength(1);
});

it.each(IGNORED_ERRORS)('ignores exceptions with code %s', (code) => {
const {addException, exceptions, hasExceptions} = useApiExceptions();
const exception = createException(code);

addException(['someRequest'], exception);

expect(hasExceptions.value).toBe(false);
expect(exceptions.value).toEqual([]);
});

it.each(Object.entries(ERROR_REPLACE_MAP))(`replaces error code %d with %d`, (code, replacement) => {
const {addException, exceptions, hasExceptions} = useApiExceptions();
const exception = createException(code);

addException(['test'], exception);

expect(hasExceptions.value).toBe(true);
expect(exceptions.value).toEqual([
{
code: replacement,
label: `error${replacement}`,
},
]);
});

it('can clear exceptions', () => {
const {addException, clear, hasExceptions, exceptions} = useApiExceptions();
const exception = createException(ERROR_INVALID_POSTAL_CODE);

addException(['test'], exception);

expect(hasExceptions.value).toBe(true);

clear();

expect(hasExceptions.value).toBe(false);
expect(exceptions.value).toEqual([]);
});

it(`adds arguments to exception with code ${ERROR_MISSING_REQUIRED_PARAMETER}`, () => {
const {addException, exceptions, hasExceptions} = useApiExceptions();
const exception = createException(ERROR_MISSING_REQUIRED_PARAMETER, 'city is required');

addException(['test'], exception);

expect(hasExceptions.value).toBe(true);
expect(exceptions.value).toEqual([
{
code: ERROR_MISSING_REQUIRED_PARAMETER,
label: {
key: `error${ERROR_MISSING_REQUIRED_PARAMETER}`,
args: {
field: {
key: 'city',
plain: true,
},
},
} satisfies TranslatableWithArgs,
},
]);
});
});
42 changes: 36 additions & 6 deletions libs/shared/src/composables/useApiExceptions.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,48 @@
import {ref, type Ref, computed, type ComputedRef} from 'vue';
import {useMemoize} from '@vueuse/core';
import {type ApiException, type ErrorResponse} from '@myparcel/sdk';
import {type RequestKey} from '../types';
import {IGNORED_ERRORS} from '../data';
import {type RequestKey, type AnyTranslatable} from '../types';
import {IGNORED_ERRORS, ERROR_MISSING_REQUIRED_PARAMETER, ERROR_REPLACE_MAP} from '../data';

const exceptions = ref<ErrorResponse['errors']>([]);
const exceptions = ref<ParsedError[]>([]);

type ParsedError = {
code: number;
label: AnyTranslatable;
};

interface UseErrors {
exceptions: Ref<ErrorResponse['errors']>;
exceptions: Ref<ParsedError[]>;
hasExceptions: ComputedRef<boolean>;
addException(requestKey: RequestKey, exception: ApiException): void;
clear(): void;
}

const parseError = (error: ErrorResponse['errors'][number]): ParsedError => {
const resolvedCode = ERROR_REPLACE_MAP[error.code] ?? error.code;

const resolvedError: ParsedError = {
code: resolvedCode,
label: `error${resolvedCode}`,
};

if (resolvedCode === ERROR_MISSING_REQUIRED_PARAMETER) {
const words = error.message.split(' ');

resolvedError.label = {
key: `error${resolvedCode}`,
args: {
field: {
key: words[0],
plain: true,
},
},
};
}

return resolvedError;
};

export const useApiExceptions = useMemoize((): UseErrors => {
const clear = (): void => {
exceptions.value = [];
Expand All @@ -30,11 +60,11 @@ export const useApiExceptions = useMemoize((): UseErrors => {
return;
}

exceptions.value.push(error);
exceptions.value.push(parseError(error));
});
},
clear,
exceptions,
hasExceptions,
} as UseErrors;
} satisfies UseErrors;
});
6 changes: 6 additions & 0 deletions libs/shared/src/data/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ export const ERROR_NO_DELIVERY_OPTIONS_FOUND = 3721;

export const ERROR_UNSUPPORTED_CARRIER = 3728;

export const ERROR_ADDRESS_CAN_NOT_BE_SPLIT = 3731;

export const ERROR_WADDEN_ISLANDS = 3753;

/**
Expand All @@ -31,3 +33,7 @@ export const IGNORED_ERRORS = Object.freeze([
ERROR_INVALID_COUNTRY_CODE,
ERROR_INVALID_CARRIER_PLATFORM_COMBINATION,
]);

export const ERROR_REPLACE_MAP: Record<number, number> = Object.freeze({
[ERROR_ADDRESS_CAN_NOT_BE_SPLIT]: ERROR_ADDRESS_UNKNOWN,
});
6 changes: 5 additions & 1 deletion libs/shared/src/types/language.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,8 @@ export interface Translatable extends BaseTranslatable {
plain?: false;
}

export type AnyTranslatable = string | Translatable | Untranslatable;
export interface TranslatableWithArgs extends Translatable {
args: Record<string, AnyTranslatable>;
}

export type AnyTranslatable = string | Translatable | TranslatableWithArgs | Untranslatable;
Loading