@webitel/api-services 26.8.42 → 26.8.43

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webitel/api-services",
3
- "version": "26.8.42",
3
+ "version": "26.8.43",
4
4
  "private": false,
5
5
  "repository": {
6
6
  "type": "git",
@@ -0,0 +1,80 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import type { DataField } from '../../../../../gen/_models';
4
+ import { stringifyContactExtensionFilters } from '../stringifyContactExtensionFilters';
5
+
6
+ const fields: DataField[] = [
7
+ {
8
+ id: 'tee',
9
+ kind: 'string',
10
+ },
11
+ {
12
+ id: 'test',
13
+ kind: 'int32',
14
+ },
15
+ {
16
+ id: 'my_bool',
17
+ kind: 'bool',
18
+ },
19
+ {
20
+ id: 'ca',
21
+ kind: 'datetime',
22
+ },
23
+ {
24
+ id: 'my_priority',
25
+ kind: 'lookup',
26
+ },
27
+ {
28
+ id: 'tags',
29
+ kind: 'list',
30
+ },
31
+ ];
32
+
33
+ describe('stringifyContactExtensionFilters', () => {
34
+ it('builds a CEL expression by field kind', () => {
35
+ expect(
36
+ stringifyContactExtensionFilters(
37
+ {
38
+ tee: '123',
39
+ test: 42,
40
+ my_bool: false,
41
+ ca: {
42
+ from: 1700000000000,
43
+ },
44
+ my_priority: '1',
45
+ tags: [
46
+ '1',
47
+ '2',
48
+ ],
49
+ },
50
+ fields,
51
+ ),
52
+ ).toBe(
53
+ 'tee == "123" && test == 42 && my_bool == false && ca >= 1700000000000 && my_priority.id == "1" && (tags.exists(x, x.id == "1") || tags.exists(x, x.id == "2"))',
54
+ );
55
+ });
56
+
57
+ it('skips a list filter with no selected items', () => {
58
+ expect(
59
+ stringifyContactExtensionFilters(
60
+ {
61
+ tee: '123',
62
+ tags: [],
63
+ },
64
+ fields,
65
+ ),
66
+ ).toBe('tee == "123"');
67
+ });
68
+
69
+ it('returns undefined when no extension filter is applied', () => {
70
+ expect(
71
+ stringifyContactExtensionFilters(
72
+ {
73
+ page: 1,
74
+ tee: null,
75
+ },
76
+ fields,
77
+ ),
78
+ ).toBeUndefined();
79
+ });
80
+ });
@@ -0,0 +1,88 @@
1
+ import type { DataField } from '../../../../gen/_models';
2
+ import { TypeKind } from '../../../../gen/_models/typeKind';
3
+ import {
4
+ type DatetimeRangeValue,
5
+ type NormalizeDatetimeValueParam,
6
+ normalizeDatetimeRange,
7
+ } from '../../../../scripts';
8
+ import type { ApiParams } from '../../_shared/types';
9
+
10
+ type ScalarFilterValue = string | number | boolean;
11
+
12
+ type LookupFilterValue =
13
+ | string
14
+ | number
15
+ | {
16
+ id: string | number;
17
+ };
18
+
19
+ type DatetimeFilterValue = NormalizeDatetimeValueParam | DatetimeRangeValue;
20
+
21
+ type ExtensionFilterValue =
22
+ | ScalarFilterValue
23
+ | LookupFilterValue
24
+ | LookupFilterValue[]
25
+ | DatetimeFilterValue;
26
+
27
+ const quote = (value: ScalarFilterValue) => JSON.stringify(String(value));
28
+
29
+ const lookupId = (value: LookupFilterValue) =>
30
+ typeof value === 'object' ? value.id : value;
31
+
32
+ const toListCondition = (
33
+ id: string,
34
+ value: LookupFilterValue | LookupFilterValue[],
35
+ ) => {
36
+ const items = [
37
+ value,
38
+ ].flat();
39
+ if (!items.length) return '';
40
+
41
+ const conditions = items.map(
42
+ (item) => `${id}.exists(x, x.id == ${quote(lookupId(item))})`,
43
+ );
44
+ return `(${conditions.join(' || ')})`;
45
+ };
46
+
47
+ const toDatetimeCondition = (id: string, value: DatetimeFilterValue) => {
48
+ const { from, to } = normalizeDatetimeRange(value) ?? {};
49
+ return [
50
+ from && `${id} >= ${from}`,
51
+ to && `${id} <= ${to}`,
52
+ ]
53
+ .filter(Boolean)
54
+ .join(' && ');
55
+ };
56
+
57
+ const toCondition = (
58
+ id: string,
59
+ kind: DataField['kind'],
60
+ value: ExtensionFilterValue,
61
+ ) => {
62
+ switch (kind) {
63
+ case TypeKind.String:
64
+ case TypeKind.Richtext:
65
+ return `${id} == ${quote(value as ScalarFilterValue)}`;
66
+ case TypeKind.Lookup:
67
+ return `${id}.id == ${quote(lookupId(value as LookupFilterValue))}`;
68
+ case TypeKind.List:
69
+ return toListCondition(
70
+ id,
71
+ value as LookupFilterValue | LookupFilterValue[],
72
+ );
73
+ case TypeKind.Datetime:
74
+ return toDatetimeCondition(id, value as DatetimeFilterValue);
75
+ default:
76
+ return `${id} == ${value}`;
77
+ }
78
+ };
79
+
80
+ export const stringifyContactExtensionFilters = (
81
+ params: ApiParams,
82
+ extensionFields: DataField[] = [],
83
+ ) =>
84
+ extensionFields
85
+ .filter(({ id = '' }) => params[id] != null)
86
+ .map(({ id = '', kind }) => toCondition(id, kind, params[id]))
87
+ .filter(Boolean)
88
+ .join(' && ') || undefined;
@@ -12,6 +12,7 @@ import {
12
12
  notify,
13
13
  sanitizeToWire,
14
14
  snakeToCamel,
15
+ translateError,
15
16
  } from '../../transformers';
16
17
  import { generatePermissionsApi } from '../_shared/generatePermissionsApi';
17
18
  import type {
@@ -20,6 +21,7 @@ import type {
20
21
  DeleteItemParams,
21
22
  GetItemParams,
22
23
  } from '../_shared/types';
24
+ import { stringifyContactExtensionFilters } from './_internals/stringifyContactExtensionFilters';
23
25
  import { ContactsSearchMode } from './enums/ContactsSearchMode';
24
26
 
25
27
  const baseUrl = '/contacts';
@@ -118,20 +120,30 @@ const getList = async (params: ApiParams) => {
118
120
  ]);
119
121
 
120
122
  try {
121
- const response = await getContacts().searchContacts({
122
- ...searchParams,
123
- sort: searchParams.sort
124
- ? [
125
- searchParams.sort,
126
- ]
127
- : [
128
- '+name',
129
- ],
130
- fields: [
131
- 'mode',
132
- ...(searchParams.fields || []),
133
- ],
134
- });
123
+ const response = await getContacts().searchContacts(
124
+ {
125
+ ...searchParams,
126
+ sort: searchParams.sort
127
+ ? [
128
+ searchParams.sort,
129
+ ]
130
+ : [
131
+ '+name',
132
+ ],
133
+ fields: [
134
+ 'mode',
135
+ ...(searchParams.fields || []),
136
+ ],
137
+ },
138
+ {
139
+ params: {
140
+ filters: stringifyContactExtensionFilters(
141
+ params,
142
+ params.extensionFields,
143
+ ),
144
+ },
145
+ },
146
+ );
135
147
 
136
148
  const { items, next } = applyTransform(
137
149
  {
@@ -152,6 +164,7 @@ const getList = async (params: ApiParams) => {
152
164
  };
153
165
  } catch (err) {
154
166
  throw applyTransform(err, [
167
+ translateError,
155
168
  notify,
156
169
  ]);
157
170
  }
@@ -0,0 +1,38 @@
1
+ import { afterEach, describe, expect, it } from 'vitest';
2
+ import { createI18n } from 'vue-i18n';
3
+
4
+ import { config, setConfig } from '../../../../config/config';
5
+ import translateError from '../translateError.transformer';
6
+
7
+ const reservedFieldError = () => ({
8
+ response: {
9
+ data: {
10
+ id: 'contacts.search.filters.reserved_field',
11
+ detail:
12
+ "contacts: field 'list' has a reserved name and cannot be used in filters; rename it",
13
+ } as {
14
+ id: string;
15
+ detail: string;
16
+ translation?: string;
17
+ },
18
+ },
19
+ });
20
+
21
+ describe('translateError', () => {
22
+ afterEach(() => {
23
+ config.i18n = null;
24
+ });
25
+
26
+ it('translates contacts reserved field error with the field code', () => {
27
+ setConfig({
28
+ i18n: createI18n({
29
+ legacy: false,
30
+ locale: 'uk',
31
+ }),
32
+ });
33
+
34
+ expect(translateError(reservedFieldError()).response.data.translation).toBe(
35
+ 'Поле "list" має зарезервоване ім\'я. Перейменуйте його в розділі Персоналізація, щоб увімкнути фільтрацію',
36
+ );
37
+ });
38
+ });
@@ -9,15 +9,27 @@ const BACKEND_ERRORS_PREFIX = 'backendErrors';
9
9
  // Converts snake_case error IDs from backend to camelCase keys
10
10
  // Looks up translations in api-services locale files under 'backendErrors' prefix
11
11
  // Adds the translated message to err.response.data.translation for use by notify transformer
12
+ interface TranslatableErrorData {
13
+ id?: string;
14
+ detail?: string;
15
+ translation?: string;
16
+ }
17
+
12
18
  interface TranslatableError {
13
19
  response?: {
14
- data?: {
15
- id?: string;
16
- translation?: string;
17
- };
20
+ data?: TranslatableErrorData;
18
21
  };
19
22
  }
20
23
 
24
+ const errorTranslationParams: Record<
25
+ string,
26
+ (data: TranslatableErrorData) => Record<string, unknown>
27
+ > = {
28
+ 'contacts.search.filters.reserved_field': ({ detail }) => ({
29
+ field: detail?.match(/'([^']+)'/)?.[1],
30
+ }),
31
+ };
32
+
21
33
  const translateError = <T extends TranslatableError>(err: T): T => {
22
34
  const errorId = err?.response?.data?.id;
23
35
  if (!errorId) return err;
@@ -33,7 +45,7 @@ const translateError = <T extends TranslatableError>(err: T): T => {
33
45
  // unify, so narrow to the composition-API Composer we run with.
34
46
  const translation = (i18n.global as Composer).t(
35
47
  fullKey,
36
- {},
48
+ errorTranslationParams[errorId]?.(err.response?.data ?? {}) ?? {},
37
49
  {
38
50
  missingWarn: false,
39
51
  fallbackWarn: false,
@@ -2,6 +2,14 @@ import type { MessageContext } from 'vue-i18n';
2
2
 
3
3
  export default {
4
4
  backendErrors: {
5
+ contacts: {
6
+ search: {
7
+ filters: {
8
+ reservedField:
9
+ '"{field}" is a reserved name. Rename the field in Personalization to enable filtering',
10
+ },
11
+ },
12
+ },
5
13
  app: {
6
14
  auditForm: {
7
15
  isValid: {
@@ -2,6 +2,14 @@ import type { MessageContext } from 'vue-i18n';
2
2
 
3
3
  export default {
4
4
  backendErrors: {
5
+ contacts: {
6
+ search: {
7
+ filters: {
8
+ reservedField:
9
+ '"{field}" es un nombre reservado. Cambie el nombre del campo en Personalización para habilitar el filtrado',
10
+ },
11
+ },
12
+ },
5
13
  app: {
6
14
  auditForm: {
7
15
  isValid: {
@@ -2,6 +2,14 @@ import type { MessageContext } from 'vue-i18n';
2
2
 
3
3
  export default {
4
4
  backendErrors: {
5
+ contacts: {
6
+ search: {
7
+ filters: {
8
+ reservedField:
9
+ '"{field}" — резервтелген атау. Сүзгілеуді қосу үшін өрісті Жекелендіру бөлімінде қайта атаңыз',
10
+ },
11
+ },
12
+ },
5
13
  app: {
6
14
  auditForm: {
7
15
  isValid: {
@@ -2,6 +2,14 @@ import type { MessageContext } from 'vue-i18n';
2
2
 
3
3
  export default {
4
4
  backendErrors: {
5
+ contacts: {
6
+ search: {
7
+ filters: {
8
+ reservedField:
9
+ '"{field}" to zarezerwowana nazwa. Zmień nazwę pola w sekcji Personalizacja, aby włączyć filtrowanie',
10
+ },
11
+ },
12
+ },
5
13
  app: {
6
14
  auditForm: {
7
15
  isValid: {
@@ -2,6 +2,14 @@ import type { MessageContext } from 'vue-i18n';
2
2
 
3
3
  export default {
4
4
  backendErrors: {
5
+ contacts: {
6
+ search: {
7
+ filters: {
8
+ reservedField:
9
+ '"{field}" este un nume rezervat. Redenumiți câmpul în Personalizare pentru a activa filtrarea',
10
+ },
11
+ },
12
+ },
5
13
  app: {
6
14
  auditForm: {
7
15
  isValid: {
@@ -2,6 +2,14 @@ import type { MessageContext } from 'vue-i18n';
2
2
 
3
3
  export default {
4
4
  backendErrors: {
5
+ contacts: {
6
+ search: {
7
+ filters: {
8
+ reservedField:
9
+ 'Поле "{field}" имеет зарезервированное имя. Переименуйте его в разделе Персонализация, чтобы включить фильтрацию',
10
+ },
11
+ },
12
+ },
5
13
  app: {
6
14
  auditForm: {
7
15
  isValid: {
@@ -2,6 +2,14 @@ import type { MessageContext } from 'vue-i18n';
2
2
 
3
3
  export default {
4
4
  backendErrors: {
5
+ contacts: {
6
+ search: {
7
+ filters: {
8
+ reservedField:
9
+ 'Поле "{field}" має зарезервоване ім\'я. Перейменуйте його в розділі Персоналізація, щоб увімкнути фільтрацію',
10
+ },
11
+ },
12
+ },
5
13
  app: {
6
14
  auditForm: {
7
15
  isValid: {
@@ -2,6 +2,14 @@ import type { MessageContext } from 'vue-i18n';
2
2
 
3
3
  export default {
4
4
  backendErrors: {
5
+ contacts: {
6
+ search: {
7
+ filters: {
8
+ reservedField:
9
+ '"{field}" zaxiralangan nom. Filtrlashni yoqish uchun maydonni Shaxsiylashtirish bo\'limida qayta nomlang',
10
+ },
11
+ },
12
+ },
5
13
  app: {
6
14
  auditForm: {
7
15
  isValid: {
@@ -2,6 +2,14 @@ import type { MessageContext } from 'vue-i18n';
2
2
 
3
3
  export default {
4
4
  backendErrors: {
5
+ contacts: {
6
+ search: {
7
+ filters: {
8
+ reservedField:
9
+ '"{field}" là tên dành riêng. Hãy đổi tên trường trong mục Cá nhân hóa để bật lọc',
10
+ },
11
+ },
12
+ },
5
13
  app: {
6
14
  auditForm: {
7
15
  isValid: {