@webitel/api-services 0.1.62 → 0.1.64

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": "0.1.62",
3
+ "version": "0.1.64",
4
4
  "private": false,
5
5
  "repository": {
6
6
  "type": "git",
@@ -30,7 +30,7 @@ const getOnlineSkillsList = async (params: ApiParams) => {
30
30
  sanitize(listFieldsToSend),
31
31
  (params) => ({
32
32
  ...params,
33
- skipDefault: params.skipDefault || true,
33
+ skipDefault: params.skipDefault ?? true,
34
34
  fields: [
35
35
  'id',
36
36
  ...(params.fields || [
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Schemas live in a package that has no access to the app's i18n, so a custom
3
+ * rule carries the message key in the issue params instead of a message.
4
+ * `configureZod` (`@webitel/ui-sdk/validations`) resolves it to
5
+ * `validation.<key>` at parse time.
6
+ *
7
+ * Never pass `message` alongside it: an explicit message wins over the global
8
+ * error map, and the raw key ends up in the ui.
9
+ *
10
+ * @example
11
+ * z.number().refine((v) => v < 1440, i18nIssue('hourRange'))
12
+ * ctx.addIssue({ code: 'custom', path: ['start'], ...i18nIssue('hourRange') })
13
+ */
14
+ export const i18nIssue = (key: string) => ({
15
+ params: {
16
+ i18nKey: key,
17
+ },
18
+ });
@@ -1,5 +1,13 @@
1
1
  import { describe, expect, it } from 'vitest';
2
- import { calendarSchema } from '../calendar.validations';
2
+ import type { z } from 'zod';
3
+ import {
4
+ calendarSchema,
5
+ getCalendarDayRangeIssues,
6
+ } from '../calendar.validations';
7
+
8
+ /** custom rules carry their message key in params, see `i18nIssue` */
9
+ const issueKey = (issue: z.core.$ZodIssue) =>
10
+ issue.code === 'custom' ? issue.params?.i18nKey : undefined;
3
11
 
4
12
  const validTimezone = {
5
13
  id: 'tz-1',
@@ -255,14 +263,14 @@ describe('calendarSchema', () => {
255
263
  expect(
256
264
  result.error.issues.some(
257
265
  (issue) =>
258
- issue.message === 'timerangeStartLessThanEnd' &&
266
+ issueKey(issue) === 'timerangeStartLessThanEnd' &&
259
267
  issue.path.join('.') === 'accepts.0.start',
260
268
  ),
261
269
  ).toBe(true);
262
270
  expect(
263
271
  result.error.issues.some(
264
272
  (issue) =>
265
- issue.message === 'timerangeStartLessThanEnd' &&
273
+ issueKey(issue) === 'timerangeStartLessThanEnd' &&
266
274
  issue.path.join('.') === 'accepts.0.end',
267
275
  ),
268
276
  ).toBe(true);
@@ -287,7 +295,7 @@ describe('calendarSchema', () => {
287
295
  expect(
288
296
  result.error.issues.some(
289
297
  (issue) =>
290
- issue.message === 'timerangeStartLessThanEnd' &&
298
+ issueKey(issue) === 'timerangeStartLessThanEnd' &&
291
299
  issue.path.join('.') === 'accepts.0.start',
292
300
  ),
293
301
  ).toBe(true);
@@ -316,7 +324,7 @@ describe('calendarSchema', () => {
316
324
  expect(result.success).toBe(false);
317
325
  if (!result.success) {
318
326
  const intersectIssues = result.error.issues.filter(
319
- (issue) => issue.message === 'timerangeNotIntersect',
327
+ (issue) => issueKey(issue) === 'timerangeNotIntersect',
320
328
  );
321
329
  expect(intersectIssues.length).toBeGreaterThan(0);
322
330
  expect(
@@ -371,7 +379,7 @@ describe('calendarSchema', () => {
371
379
  if (!result.success) {
372
380
  expect(
373
381
  result.error.issues.some(
374
- (issue) => issue.message === 'timerangeNotIntersect',
382
+ (issue) => issueKey(issue) === 'timerangeNotIntersect',
375
383
  ),
376
384
  ).toBe(true);
377
385
  }
@@ -439,7 +447,7 @@ describe('calendarSchema', () => {
439
447
  expect(
440
448
  result.error.issues.some(
441
449
  (issue) =>
442
- issue.message === 'hourRange' &&
450
+ issueKey(issue) === 'hourRange' &&
443
451
  issue.path.join('.') === 'accepts.0.end',
444
452
  ),
445
453
  ).toBe(true);
@@ -524,3 +532,81 @@ describe('validations barrel export', () => {
524
532
  expect(typeof index.calendarSchema.parse).toBe('function');
525
533
  });
526
534
  });
535
+
536
+ describe('getCalendarDayRangeIssues', () => {
537
+ const row = (day: number, start: number, end: number) => ({
538
+ day,
539
+ disabled: false,
540
+ start,
541
+ end,
542
+ });
543
+
544
+ it('reports nothing for valid rows', () => {
545
+ expect(
546
+ getCalendarDayRangeIssues([
547
+ row(0, 540, 541),
548
+ row(0, 600, 660),
549
+ row(1, 540, 1200),
550
+ ]),
551
+ ).toEqual([]);
552
+ });
553
+
554
+ it('reports both ends of a row whose start is not before its end', () => {
555
+ expect(
556
+ getCalendarDayRangeIssues([
557
+ row(0, 600, 540),
558
+ ]),
559
+ ).toEqual([
560
+ {
561
+ index: 0,
562
+ prop: 'start',
563
+ key: 'timerangeStartLessThanEnd',
564
+ },
565
+ {
566
+ index: 0,
567
+ prop: 'end',
568
+ key: 'timerangeStartLessThanEnd',
569
+ },
570
+ ]);
571
+ });
572
+
573
+ it('reports every row of an overlap on the same day', () => {
574
+ const issues = getCalendarDayRangeIssues([
575
+ row(0, 540, 720),
576
+ row(0, 600, 780),
577
+ ]);
578
+
579
+ expect(issues.every(({ key }) => key === 'timerangeNotIntersect')).toBe(
580
+ true,
581
+ );
582
+ expect(issues.map(({ index, prop }) => `${index}.${prop}`).sort()).toEqual([
583
+ '0.end',
584
+ '0.start',
585
+ '1.end',
586
+ '1.start',
587
+ ]);
588
+ });
589
+
590
+ it('does not treat the same hours on different days as an overlap', () => {
591
+ expect(
592
+ getCalendarDayRangeIssues([
593
+ row(0, 540, 720),
594
+ row(2, 540, 720),
595
+ ]),
596
+ ).toEqual([]);
597
+ });
598
+
599
+ it('reports a minute outside the day', () => {
600
+ expect(
601
+ getCalendarDayRangeIssues([
602
+ row(0, 0, 24 * 60),
603
+ ]),
604
+ ).toEqual([
605
+ {
606
+ index: 0,
607
+ prop: 'end',
608
+ key: 'hourRange',
609
+ },
610
+ ]);
611
+ });
612
+ });
@@ -1,22 +1,18 @@
1
1
  import type { EngineCalendar } from '@webitel/api-services/gen/models';
2
2
  import { z } from 'zod';
3
+ import { i18nIssue } from '../_shared/i18nIssue';
3
4
  import { requiredLookupSchema } from '../_shared/lookup.validations';
4
5
  import type { ZodShape } from '../types';
5
6
 
6
- const HOUR_RANGE_MESSAGE = 'hourRange';
7
- const TIMERANGE_START_LESS_THAN_END_MESSAGE = 'timerangeStartLessThanEnd';
8
- const TIMERANGE_NOT_INTERSECT_MESSAGE = 'timerangeNotIntersect';
7
+ const HOUR_RANGE_KEY = 'hourRange';
8
+ const TIMERANGE_START_LESS_THAN_END_KEY = 'timerangeStartLessThanEnd';
9
+ const TIMERANGE_NOT_INTERSECT_KEY = 'timerangeNotIntersect';
9
10
 
10
11
  /** Legacy hourRange: value >= 0 && value < 1440 */
11
12
  const dayMinuteSchema = z
12
13
  .number()
13
14
  .int()
14
- .min(0, {
15
- message: HOUR_RANGE_MESSAGE,
16
- })
17
- .max(24 * 60 - 1, {
18
- message: HOUR_RANGE_MESSAGE,
19
- });
15
+ .refine((value) => value >= 0 && value < 24 * 60, i18nIssue(HOUR_RANGE_KEY));
20
16
 
21
17
  type AcceptOfDayUi = {
22
18
  day: number;
@@ -24,12 +20,6 @@ type AcceptOfDayUi = {
24
20
  end: number;
25
21
  };
26
22
 
27
- export type CalendarAcceptOfDayUiForValidation = AcceptOfDayUi;
28
-
29
- export const getCalendarAcceptsIntersectingIndices = (
30
- items: CalendarAcceptOfDayUiForValidation[],
31
- ) => getIntersectingIndices(items);
32
-
33
23
  const getIntersectingIndices = (items: AcceptOfDayUi[]) => {
34
24
  const indices = new Set<number>();
35
25
  const indicesByDay = new Map<number, number[]>();
@@ -84,17 +74,17 @@ const acceptOfDayUiSchema = z
84
74
  if (item.start >= item.end) {
85
75
  ctx.addIssue({
86
76
  code: 'custom',
87
- message: TIMERANGE_START_LESS_THAN_END_MESSAGE,
88
77
  path: [
89
78
  'start',
90
79
  ],
80
+ ...i18nIssue(TIMERANGE_START_LESS_THAN_END_KEY),
91
81
  });
92
82
  ctx.addIssue({
93
83
  code: 'custom',
94
- message: TIMERANGE_START_LESS_THAN_END_MESSAGE,
95
84
  path: [
96
85
  'end',
97
86
  ],
87
+ ...i18nIssue(TIMERANGE_START_LESS_THAN_END_KEY),
98
88
  });
99
89
  }
100
90
  });
@@ -105,23 +95,66 @@ const acceptsOfDayUiArraySchema = z
105
95
  getIntersectingIndices(items).forEach((index) => {
106
96
  ctx.addIssue({
107
97
  code: 'custom',
108
- message: TIMERANGE_NOT_INTERSECT_MESSAGE,
109
98
  path: [
110
99
  index,
111
100
  'start',
112
101
  ],
102
+ ...i18nIssue(TIMERANGE_NOT_INTERSECT_KEY),
113
103
  });
114
104
  ctx.addIssue({
115
105
  code: 'custom',
116
- message: TIMERANGE_NOT_INTERSECT_MESSAGE,
117
106
  path: [
118
107
  index,
119
108
  'end',
120
109
  ],
110
+ ...i18nIssue(TIMERANGE_NOT_INTERSECT_KEY),
121
111
  });
122
112
  });
123
113
  });
124
114
 
115
+ export type CalendarDayRangeIssue = {
116
+ index: number;
117
+ prop: string;
118
+ /** message key, resolve as `validation.<key>` */
119
+ key: string;
120
+ };
121
+
122
+ /**
123
+ * The same rules as the card schema, reported per row so a form can mark the
124
+ * offending inputs while the user types.
125
+ *
126
+ * A regle field status cannot serve that: with a standard schema, cross-row
127
+ * verdicts (overlapping ranges) are only re-derived by a full `$validate()`,
128
+ * so a row the user has not touched keeps a message that is no longer true.
129
+ */
130
+ export const getCalendarDayRangeIssues = (
131
+ items: unknown,
132
+ ): CalendarDayRangeIssue[] => {
133
+ const result = acceptsOfDayUiArraySchema.safeParse(items);
134
+
135
+ if (result.success) return [];
136
+
137
+ return result.error.issues.flatMap((issue) => {
138
+ const [index, prop] = issue.path;
139
+ const key =
140
+ issue.code === 'custom' && typeof issue.params?.i18nKey === 'string'
141
+ ? issue.params.i18nKey
142
+ : undefined;
143
+
144
+ if (typeof index !== 'number' || typeof prop !== 'string' || !key) {
145
+ return [];
146
+ }
147
+
148
+ return [
149
+ {
150
+ index,
151
+ prop,
152
+ key,
153
+ },
154
+ ];
155
+ });
156
+ };
157
+
125
158
  const exceptUiSchema = z.object({
126
159
  name: z.string().optional(),
127
160
  date: z