@webitel/api-services 0.1.62 → 0.1.63
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 +1 -1
- package/src/validations/_shared/i18nIssue.ts +18 -0
- package/src/validations/calendar/__tests__/calendar.validations.spec.ts +93 -7
- package/src/validations/calendar/calendar.validations.ts +52 -19
- package/types/.tsbuildinfo +1 -1
- package/types/validations/_shared/i18nIssue.d.ts +18 -0
- package/types/validations/calendar/calendar.validations.d.ts +14 -7
package/package.json
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
7
|
-
const
|
|
8
|
-
const
|
|
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
|
-
.
|
|
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
|