Skip to content
This repository was archived by the owner on Apr 18, 2024. It is now read-only.

Commit 05e3ade

Browse files
authored
fix: LSDV-4726: DateTime min/max (#1348)
Add red highlight for invalid dates. Validate all (global and per-region) dates before submit. * Check `validate()` in every tag, not only required Labels' validate() was broken anyway, so better to remove it. And TimeSeriesLabels` required could not be checked because that's not a classification. * Add `validate()` for DateTime Check per-regions as well. It's important to go through results and check their values. And because values are formatted but min/max is in ISO, we have to convert values to ISO. * Add little comments * Fix Required — validate only if required=true * Fix linting * Fix validate() result — should be true/false only * Add test for DateTime validation; fix current one * Fix ISO conversion with shifted timezones; fix tests * Fix min/max checks for only=year
1 parent 6cc186d commit 05e3ade

File tree

10 files changed

+299
-61
lines changed

10 files changed

+299
-61
lines changed

e2e/codecept.conf.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ module.exports.config = {
2929
chromium: process.env.CHROMIUM_EXECUTABLE_PATH ? {
3030
executablePath: process.env.CHROMIUM_EXECUTABLE_PATH,
3131
} : {},
32+
// to test date shifts because of timezone. (see date-time.test.js)
33+
// Paris is in +1/+2 timezone, so date with midnight (00:00)
34+
// will be always in previous day in ISO
35+
timezoneId: 'Europe/Paris',
3236
trace: false,
3337
keepTraceForPassedTests: false,
3438
},

e2e/fragments/AtDateTime.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
const { I } = inject();
2+
3+
module.exports = {
4+
_dateInputId: '__test_date_format__',
5+
/**
6+
* Browsers use system date format for input[type=date], not locale,
7+
* and inputs don't provide any API to get this format or user input,
8+
* only the final value in ISO format.
9+
* So we fill in some specific input and check the result.
10+
* Depending on system settings, entered digits can go to different places,
11+
* generating one of 4 possible date formats —year can only be at the beginning
12+
* or at the end.
13+
* We focus only on formats with full numeric day, month and year (DD and YYYY).
14+
* @returns {Promise<string>} date format of y, m, d (e.g. 'ymd')
15+
*/
16+
async detectDateFormat() {
17+
// create invisible date input
18+
await I.executeScript(({ id }) => {
19+
const date = document.createElement('input');
20+
21+
date.type = 'date';
22+
date.id = id;
23+
Object.assign(date.style, {
24+
position: 'absolute',
25+
top: 0,
26+
opacity: 0,
27+
});
28+
29+
document.body.appendChild(date);
30+
}, { id: this._dateInputId });
31+
32+
I.fillField(`#${this._dateInputId}`, '01020304');
33+
34+
const format = await I.executeScript(({ id }) => {
35+
const date = document.getElementById(id) as HTMLInputElement;
36+
const value = date.value; // always ISO format
37+
38+
switch (value) {
39+
case '0102-04-03': return 'ydm';
40+
case '0102-03-04': return 'ymd';
41+
case '0304-02-01': return 'dmy';
42+
case '0304-01-02': return 'mdy';
43+
default: return 'ymd';
44+
}
45+
}, { id: this._dateInputId });
46+
47+
// remove this input
48+
await I.executeScript(({ id }) => {
49+
(document.getElementById(id) as HTMLInputElement).remove();
50+
}, { id: this._dateInputId });
51+
52+
return format;
53+
},
54+
};

e2e/helpers/DateTime.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
/**
2+
* Simple date formatter
3+
* @param {string} value date in ISO format
4+
* @param {string} format combinations of y, m, d (e.g. 'ymd')
5+
* @returns {string} formatted date
6+
*/
7+
export const formatDateValue = (value: string, format: string) => {
8+
const [y, m, d] = value.split('-');
9+
let text = '';
10+
11+
for (const char of format) {
12+
if (char === 'y') text += y;
13+
if (char === 'm') text += m;
14+
if (char === 'd') text += d;
15+
}
16+
return text;
17+
};

e2e/tests/date-time.test.js

Lines changed: 127 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
const { serialize, selectText } = require('./helpers');
2-
31
const assert = require('assert');
2+
const { formatDateValue } = require('../helpers/DateTime');
3+
const { serialize, selectText } = require('./helpers');
44

55
Feature('Date Time');
66

@@ -12,61 +12,160 @@ const config = `<View>
1212
<Label value="event" background="orange"/>
1313
</Labels>
1414
<Text name="text" value="$text"/>
15+
<DateTime name="created" toName="text" required="true" only="date" format="%d.%m.%Y" min="1988-01-13" max="1999-12-31"/>
1516
<View visibleWhen="region-selected">
1617
<Header>Date in this fragment, required, stored as ISO date</Header>
1718
<DateTime name="date" toName="text" perRegion="true" only="date" required="true" format="%Y-%m-%d"/>
1819
<Header>Year this happened, but stored also as ISO date</Header>
19-
<DateTime name="year" toName="text" perRegion="true" only="year" format="%Y-%m-%d"/>
20+
<DateTime name="year" toName="text" perRegion="true" only="year" format="%Y-%m-%d" min="2020" max="2022"/>
2021
</View>
2122
</View>
2223
`;
2324

24-
25-
2625
const data = {
2726
text: 'Albert Einstein (/ˈaɪnstaɪn/ EYEN-styne;[6] German: [ˈalbɛʁt ˈʔaɪnʃtaɪn] (listen); 14 March 1879 – 18 April 1955) was a German-born theoretical physicist,[7] widely acknowledged to be one of the greatest and most influential physicists of all time. Einstein is best known for developing the theory of relativity, but he also made important contributions to the development of the theory of quantum mechanics. Relativity and quantum mechanics are together the two pillars of modern physics.[3][8] His mass–energy equivalence formula E = mc2, which arises from relativity theory, has been dubbed "the world\'s most famous equation".[9] His work is also known for its influence on the philosophy of science.[10][11] He received the 1921 Nobel Prize in Physics "for his services to theoretical physics, and especially for his discovery of the law of the photoelectric effect",[12] a pivotal step in the development of quantum theory. His intellectual achievements and originality resulted in "Einstein" becoming synonymous with "genius".[13]',
2827
};
2928

30-
const annotations = [
31-
{ label: 'birth', rangeStart: 83, rangeEnd: 96, text: '14 March 1879', date: '03141879', dateValue: '1879-03-14', year: '2022' },
32-
{ label: 'death', rangeStart: 99, rangeEnd: 112, text: '18 April 1955', date: '04181955', dateValue: '1955-04-18', year: '2021' },
33-
{ label: 'event', rangeStart: 728, rangeEnd: 755, text: '1921 Nobel Prize in Physics', date: '10101921', dateValue: '1921-10-10', year: '2020' },
29+
const createdDate = {
30+
incorrectMin: '1988-01-12',
31+
correctMin: '1988-01-13',
32+
incorrectMax: '2000-01-01',
33+
correctMax: '1999-12-31',
34+
result: '31.12.1999',
35+
};
36+
37+
const regions = [
38+
{ label: 'birth', rangeStart: 83, rangeEnd: 96, text: '14 March 1879', dateValue: '1879-03-14', year: '2022' },
39+
{ label: 'death', rangeStart: 99, rangeEnd: 112, text: '18 April 1955', dateValue: '1955-04-18', year: '2021' },
40+
{ label: 'event', rangeStart: 728, rangeEnd: 755, text: '1921 Nobel Prize in Physics', dateValue: '1921-10-10', year: '2020' },
3441
];
3542

3643
const params = { config, data };
3744

38-
Scenario('Check DateTime holds state between annotations and saves result', async function({ I, LabelStudio }) {
39-
45+
Scenario('Check DateTime holds state between annotations and saves result', async function({ I, AtDateTime, AtLabels, AtSidebar, LabelStudio }) {
4046
I.amOnPage('/');
4147

4248
LabelStudio.init(params);
4349

44-
annotations.forEach(annotation => {
45-
I.click(locate('span').withText(annotation.label));
46-
I.executeScript(selectText, {
50+
// detect format used for html5 date inputs
51+
const format = await AtDateTime.detectDateFormat();
52+
53+
I.say(`System format is ${format}`);
54+
55+
////// GLOBAL
56+
I.say('Check validation of required global date control');
57+
I.updateAnnotation();
58+
I.see('DateTime "created" is required');
59+
I.click('OK');
60+
61+
const checks = {
62+
incorrect: [
63+
[createdDate.incorrectMin, 'min date is 1988-01-13'],
64+
[createdDate.incorrectMax, 'max date is 1999-12-31'],
65+
],
66+
correct: [
67+
[createdDate.correctMin],
68+
[createdDate.correctMax],
69+
],
70+
};
71+
72+
for (const [incorrect, error] of checks.incorrect) {
73+
I.fillField('input[type=date]', formatDateValue(incorrect, format));
74+
I.updateAnnotation();
75+
I.see('is not valid');
76+
I.see(error);
77+
I.click('OK');
78+
assert.strictEqual(await I.grabCssPropertyFrom('[type=date]', 'border-color'), 'rgb(255, 0, 0)');
79+
}
80+
81+
for (const [correct] of checks.correct) {
82+
I.fillField('input[type=date]', formatDateValue(correct, format));
83+
I.updateAnnotation();
84+
I.dontSee('Warning');
85+
I.dontSee('is not valid');
86+
}
87+
88+
// this value will be asserted at the end
89+
I.fillField('input[type=date]', formatDateValue(createdDate.correctMax, format));
90+
91+
////// PER-REGION
92+
I.say('Create regions but leave dates empty');
93+
for (const region of regions) {
94+
AtLabels.clickLabel(region.label);
95+
AtLabels.seeSelectedLabel(region.label);
96+
await I.executeScript(selectText, {
4797
selector: '.lsf-htx-richtext',
48-
rangeStart: annotation.rangeStart,
49-
rangeEnd: annotation.rangeEnd,
98+
rangeStart: region.rangeStart,
99+
rangeEnd: region.rangeEnd,
50100
});
51-
I.click(locate('li').withText(annotation.text));
52-
I.fillField('input[type=date]', annotation.date);
53-
I.selectOption('select[name=year-year]', annotation.year);
54-
I.click(locate('li').withText(annotation.text));
101+
I.pressKey('Escape');
102+
// to prevent from double-click region handling (its timeout is 0.45s)
103+
I.wait(0.5);
104+
}
105+
106+
I.say('Try to submit and observe validation errors about per-regions');
107+
I.updateAnnotation();
108+
I.see('DateTime "date" is required');
109+
I.click('OK');
110+
111+
// invalid region is selected on validation to reveal per-region control with error
112+
AtSidebar.seeSelectedRegion(regions[0].label);
113+
I.fillField('input[name=date-date]', formatDateValue(regions[0].dateValue, format));
114+
I.updateAnnotation();
115+
// next region with empty required date is selected and error is shown
116+
I.see('DateTime "date" is required');
117+
I.click('OK');
118+
AtSidebar.seeSelectedRegion(regions[1].label);
119+
120+
I.say('Fill all per-region date fields and check it\'s all good');
121+
regions.forEach(region => {
122+
I.click(locate('li').withText(region.text));
123+
I.fillField('input[name=date-date]', formatDateValue(region.dateValue, format));
124+
});
125+
126+
I.click(locate('li').withText(regions[0].text));
127+
// less than min
128+
I.selectOption('select[name=year-year]', '1999');
129+
assert.strictEqual('', await I.grabValueFrom('select[name=year-year]'));
130+
// more than max
131+
I.selectOption('select[name=year-year]', '2023');
132+
assert.strictEqual('', await I.grabValueFrom('select[name=year-year]'));
133+
// exactly the same as max, should be correct
134+
I.selectOption('select[name=year-year]', '2022');
135+
assert.strictEqual('2022', await I.grabValueFrom('select[name=year-year]'));
136+
I.pressKey('Escape');
137+
138+
regions.forEach(region => {
139+
I.click(locate('li').withText(region.text));
140+
I.selectOption('select[name=year-year]', region.year);
55141
});
56142

57-
annotations.forEach(annotation => {
58-
I.click(locate('li').withText(annotation.text));
59-
I.seeInField('input[type=date]', annotation.dateValue);
60-
I.seeInField('select[name=year-year]', annotation.year);
143+
I.updateAnnotation();
144+
I.dontSee('Warning');
145+
I.dontSee('is required');
61146

147+
regions.forEach(region => {
148+
I.click(locate('li').withText(region.text));
149+
// important to see that per-regions change their values
150+
I.seeInField('input[name=date-date]', region.dateValue);
151+
I.seeInField('select[name=year-year]', region.year);
62152
});
153+
63154
const results = await I.executeScript(serialize);
64155

65-
results.filter(result => result.value.labels).forEach((result, index) => {
66-
const input = annotations[index];
67-
const expected = { end: input.rangeEnd, labels: [input.label], start: input.rangeStart, text: input.text };
156+
results.filter(result => result.value.start).forEach(result => {
157+
const input = regions.find(reg => reg.text === result.value.text);
158+
const expected = { end: input.rangeEnd, start: input.rangeStart, text: input.text };
159+
160+
switch (result.from_name) {
161+
case 'label': expected.labels = [input.label]; break;
162+
case 'date': expected.datetime = input.dateValue; break;
163+
// year is formatted in config to be an ISO date
164+
case 'year': expected.datetime = input.year + '-01-01'; break;
165+
}
68166

69-
assert.deepEqual(result.value, expected);
167+
assert.deepStrictEqual(result.value, expected);
70168
});
71169

170+
assert.strictEqual(results[0].value.datetime, createdDate.result);
72171
});

src/mixins/Required.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,15 @@ const RequiredMixin = types
77
})
88
.actions(self => ({
99
validate() {
10+
if (!self.required) return true;
11+
1012
if (self.perregion) {
1113
// validating when choices labeling is done per region,
1214
// for example choice may be required to be selected for
1315
// every bbox
1416
const objectTag = self.annotation.names.get(self.toname);
1517

18+
// if regions don't meet visibility conditions skip validation
1619
for (const reg of objectTag.regs) {
1720
const s = reg.results.find(s => s.from_name === self);
1821

src/regions/Result.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,12 +159,13 @@ const Result = types
159159
if (label && !self.area.hasLabel(label)) return false;
160160
}
161161

162+
// picks leaf's (last item in a path) value for Taxonomy or usual Choice value for Choices
162163
const innerResults = (r) =>
163164
r.map(s => Array.isArray(s) ? s.at(-1) : s);
164165

165166
const isChoiceSelected = () => {
166167
const tagName = control.whentagname;
167-
const choiceValues = control.whenchoicevalue ? control.whenchoicevalue.split(',') : null;
168+
const choiceValues = control.whenchoicevalue?.split(',') ?? null;
168169
const results = self.annotation.results.filter(r => ['choices', 'taxonomy'].includes(r.type) && r !== self);
169170

170171
if (tagName) {

src/stores/Annotation/Annotation.js

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -465,16 +465,14 @@ export const Annotation = types
465465
let ok = true;
466466

467467
self.traverseTree(function(node) {
468-
if (node.required === true) {
469-
ok = node.validate();
470-
if (ok === false) {
471-
ok = false;
472-
return TRAVERSE_STOP;
473-
}
468+
ok = node.validate?.();
469+
if (ok === false) {
470+
return TRAVERSE_STOP;
474471
}
475472
});
476473

477-
return ok;
474+
// should be true or false
475+
return ok ?? true;
478476
},
479477

480478
traverseTree(cb) {

0 commit comments

Comments
 (0)