vimp-engine 0.14.4 → 0.15.0
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/client/components/model/Auth.js +7 -14
- package/src/client/components/view/Auth.js +29 -19
- package/src/client/lib/authParams.js +41 -0
- package/src/client/lib/formBuilder.js +362 -26
- package/src/client/main.js +28 -22
- package/src/client/style.css +7 -11
- package/src/client/views/gameShell.js +1 -1
- package/src/client/views/includes/auth.pug +1 -1
- package/src/client/views/includes/lobby.pug +6 -5
- package/src/devtools/contract/index.js +28 -5
- package/src/devtools/contract/result.js +8 -2
- package/src/devtools/contract/rules/b5-room-form.js +17 -1
- package/src/devtools/contract/rules/c10-auth-schema.js +26 -3
- package/src/devtools/contract/rules/c4-component-dependencies.js +42 -11
- package/src/devtools/contract/rules/c6-stat-columns.js +91 -16
- package/src/host/PortMachine.js +17 -1
- package/src/lib/formOptions.js +16 -0
- package/src/lib/formPattern.js +16 -0
- package/src/lib/validators.js +112 -7
package/package.json
CHANGED
|
@@ -31,21 +31,14 @@ export default class AuthModel {
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
// обновление данных
|
|
34
|
-
//
|
|
34
|
+
// формат поля проверяет formBuilder.collectFormErrors перед сабмитом —
|
|
35
|
+
// единственный контур валидации формы (раньше здесь была вторая,
|
|
36
|
+
// параллельная проверка по options.regExp, которая молча зануляла
|
|
37
|
+
// значение и падала на строке из JSON: у неё нет .test)
|
|
35
38
|
update(data) {
|
|
36
|
-
const { name } = data;
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
if (regExp) {
|
|
41
|
-
if (regExp.test(value)) {
|
|
42
|
-
this._data[name] = value;
|
|
43
|
-
} else {
|
|
44
|
-
this._data[name] = value = '';
|
|
45
|
-
}
|
|
46
|
-
} else {
|
|
47
|
-
this._data[name] = value;
|
|
48
|
-
}
|
|
39
|
+
const { name, value } = data;
|
|
40
|
+
|
|
41
|
+
this._data[name] = value;
|
|
49
42
|
|
|
50
43
|
this.publisher.emit('form', { name, value });
|
|
51
44
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import Publisher from '../../../lib/Publisher.js';
|
|
2
|
-
import { buildForm,
|
|
2
|
+
import { bindLiveErrors, buildForm, renderFormErrors } from '../../lib/formBuilder.js';
|
|
3
3
|
|
|
4
4
|
// Singleton AuthView
|
|
5
5
|
|
|
@@ -24,19 +24,29 @@ export default class AuthView {
|
|
|
24
24
|
this._enter = document.getElementById(elems.enterId);
|
|
25
25
|
this._fieldsContainer = document.getElementById(elems.fieldsId);
|
|
26
26
|
this._fields = new Map();
|
|
27
|
+
this._descriptors = [];
|
|
27
28
|
|
|
28
29
|
this.publisher = new Publisher();
|
|
29
30
|
|
|
30
31
|
this._renderTexts(elems, texts);
|
|
31
32
|
this._renderFields(elems, params);
|
|
32
33
|
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
|
|
34
|
+
// ошибки видны по ходу правки (см. bindLiveErrors), а не по клику:
|
|
35
|
+
// до сабмита — по тронутым полям, после — по всей форме
|
|
36
|
+
this._liveErrors = bindLiveErrors(this._fieldsContainer, this._error, () => ({
|
|
37
|
+
descriptors: this._descriptors,
|
|
38
|
+
fields: this._fields,
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
// форма заполнена; клиентская проверка (required/regExp/min/max) —
|
|
42
|
+
// сервер всё равно валидирует своими validators (renderError), это лишь
|
|
43
|
+
// UX-фильтр до отправки
|
|
36
44
|
this._enter.onclick = () => {
|
|
37
|
-
if (
|
|
38
|
-
|
|
45
|
+
if (this._liveErrors.arm().length) {
|
|
46
|
+
return;
|
|
39
47
|
}
|
|
48
|
+
|
|
49
|
+
authView.publisher.emit('enter');
|
|
40
50
|
};
|
|
41
51
|
|
|
42
52
|
this._mPublic.on('form', 'renderData', this);
|
|
@@ -65,6 +75,11 @@ export default class AuthView {
|
|
|
65
75
|
...descriptorRest,
|
|
66
76
|
}));
|
|
67
77
|
|
|
78
|
+
this._descriptors = descriptors;
|
|
79
|
+
// params сюда приходят чужие (PS_AUTH_DATA, тот же массив main.js
|
|
80
|
+
// отдаёт следом в AuthCtrl.init) и остаются нетронутыми: значение поля
|
|
81
|
+
// с единственным вариантом закрепляет main.js (resolveForcedValue) до
|
|
82
|
+
// разветвления на solo-путь, а не эта форма
|
|
68
83
|
this._fields = buildForm(descriptors, container, {}, ({ name, value }) => {
|
|
69
84
|
this.publisher.emit('input', { name, value });
|
|
70
85
|
});
|
|
@@ -146,22 +161,17 @@ export default class AuthView {
|
|
|
146
161
|
renderData(data) {
|
|
147
162
|
const { name, value } = data;
|
|
148
163
|
|
|
149
|
-
|
|
164
|
+
// блок ошибок ведёт bindLiveErrors — он перевалидирует форму по 'input',
|
|
165
|
+
// то есть по ходу правки. Чистка, стоявшая здесь, висела на событии
|
|
166
|
+
// модели, а оно приходит из 'change' (у text-инпута — blur): игрок,
|
|
167
|
+
// перейдя из починенного поля в следующее, терял ошибки полей, которых
|
|
168
|
+
// ещё не касался
|
|
150
169
|
this._fields.get(name)?.setValue(value);
|
|
151
170
|
}
|
|
152
171
|
|
|
153
|
-
// отображает ошибки
|
|
172
|
+
// отображает серверные ошибки (PS_AUTH_DATA/validators) — тот же рендер,
|
|
173
|
+
// что и у клиентской collectFormErrors (formBuilder.renderFormErrors)
|
|
154
174
|
renderError(data) {
|
|
155
|
-
this._error
|
|
156
|
-
|
|
157
|
-
data.forEach(item => {
|
|
158
|
-
const name = item.name.toUpperCase();
|
|
159
|
-
const err = item.error;
|
|
160
|
-
const line = document.createElement('div');
|
|
161
|
-
|
|
162
|
-
line.textContent = err ? `${name}: ${err}` : `${name} is not correctly!`;
|
|
163
|
-
|
|
164
|
-
this._error.appendChild(line);
|
|
165
|
-
});
|
|
175
|
+
renderFormErrors(this._error, data);
|
|
166
176
|
}
|
|
167
177
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { resolveForcedValue } from './formBuilder.js';
|
|
2
|
+
|
|
3
|
+
// Значения полей auth-формы до её постройки (PS_AUTH_DATA.params).
|
|
4
|
+
// Вынесено из socketMethods[PS_AUTH_DATA] (main.js), чтобы этот код был
|
|
5
|
+
// накрыт тестом: solo-путь (boot.autoAuth) отвечает хосту вообще без формы и
|
|
6
|
+
// берёт значения ровно отсюда, то есть ошибка здесь не видна ни в одном
|
|
7
|
+
// тесте формы.
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Правит `param.value` на месте: сначала память клиента
|
|
11
|
+
* (`localStorage[storage]`), затем схема. Порядок важен — поле с
|
|
12
|
+
* единственным вариантом форма не показывает и править не даёт, поэтому
|
|
13
|
+
* устаревший `localStorage` от версии игры, где вариантов было больше,
|
|
14
|
+
* обязан быть перекрыт: иначе он уедет на хост и получит отказ от
|
|
15
|
+
* игровых validators, а поправить его игроку нечем.
|
|
16
|
+
*
|
|
17
|
+
* Мутация, а не копия: тот же массив main.js отдаёт следом в
|
|
18
|
+
* `AuthCtrl.init` (оттуда он попадает в `AuthModel._data`) и в
|
|
19
|
+
* `defaultsFrom(params)` на solo-пути.
|
|
20
|
+
*
|
|
21
|
+
* @param {Array<Object>} params - PS_AUTH_DATA.params.
|
|
22
|
+
* @param {Object} [storage] - Хранилище значений, по умолчанию localStorage.
|
|
23
|
+
* @returns {Array<Object>} Тот же массив.
|
|
24
|
+
*/
|
|
25
|
+
export function normalizeAuthParams(params, storage = localStorage) {
|
|
26
|
+
params.forEach(param => {
|
|
27
|
+
const descriptor = param.options ?? {};
|
|
28
|
+
|
|
29
|
+
if (descriptor.storage) {
|
|
30
|
+
param.value = storage[descriptor.storage] || param.value || '';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const forced = resolveForcedValue(descriptor);
|
|
34
|
+
|
|
35
|
+
if (forced !== undefined) {
|
|
36
|
+
param.value = forced;
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
return params;
|
|
41
|
+
}
|
|
@@ -1,12 +1,23 @@
|
|
|
1
|
+
import { anchorPattern } from '../../lib/formPattern.js';
|
|
2
|
+
import { normalizeOptions } from '../../lib/formOptions.js';
|
|
3
|
+
|
|
1
4
|
// Общий билдер полей форм (room-форма и auth-форма используют один
|
|
2
5
|
// контракт дескрипторов — docs/en/plugin-api.md, раздел "Form schema").
|
|
3
6
|
// control: 'select'|'text'|'checkbox'|'radio'; все контролы — нативные
|
|
4
|
-
// элементы формы, без визуальной кастомизации.
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
// элементы формы, без визуальной кастомизации. Валидация — не нативная
|
|
8
|
+
// (никаких браузерных попапов): collectFormErrors/renderFormErrors ниже
|
|
9
|
+
// собирают и рисуют ошибки в разметку (#lobby-error/#auth-error).
|
|
10
|
+
// Проверяются только поля, у которых есть строка в DOM: ошибка на скрытом
|
|
11
|
+
// поле игроку не видна и исправить её нечем — она просто запирает форму.
|
|
12
|
+
|
|
13
|
+
// контролы, у которых есть список вариантов: держим одним списком, чтобы
|
|
14
|
+
// resolveForcedValue не расходился с builders при добавлении нового
|
|
15
|
+
const OPTION_CONTROLS = ['select', 'radio'];
|
|
16
|
+
|
|
17
|
+
// числовое text-поле: unit задан или numeric:true. Правило одно на билдер и
|
|
18
|
+
// на валидацию, поэтому живёт в одном месте
|
|
19
|
+
function isNumeric(descriptor) {
|
|
20
|
+
return descriptor.numeric === true || descriptor.unit !== undefined;
|
|
10
21
|
}
|
|
11
22
|
|
|
12
23
|
// 'source' — спец-источник вариантов из каталога движка (например карты),
|
|
@@ -19,6 +30,32 @@ function resolveOptions(descriptor, ctx) {
|
|
|
19
30
|
return normalizeOptions(descriptor.options);
|
|
20
31
|
}
|
|
21
32
|
|
|
33
|
+
// ровно один резолвнутый вариант — единственно возможное значение поля:
|
|
34
|
+
// строка формы для него не рендерится (игроку нечего выбирать и нечем
|
|
35
|
+
// поправить рассинхрон), поэтому ничто извне (устаревший localStorage,
|
|
36
|
+
// default из схемы, серверное PS_AUTH_DATA) не должно иметь возможности
|
|
37
|
+
// его переопределить — native <select>.value на несуществующем option
|
|
38
|
+
// молча схлопывается в '' без единой ошибки (vimp-snakes/model)
|
|
39
|
+
function forcedValue(options) {
|
|
40
|
+
return options.length === 1 ? options[0].value : undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// то же правило вне формы: solo-путь (boot.autoAuth) отвечает хосту без
|
|
44
|
+
// всякой формы и должен прийти ровно к тому же значению, что и она
|
|
45
|
+
export function resolveForcedValue(descriptor, ctx = {}) {
|
|
46
|
+
if (!OPTION_CONTROLS.includes(descriptor.control)) {
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const forced = forcedValue(resolveOptions(descriptor, ctx));
|
|
51
|
+
|
|
52
|
+
// форма отдала бы строку: и <option>.value, и <input type=radio>.value —
|
|
53
|
+
// DOM-свойства, они всегда строки. Нестроковое значение (options: [1, 2])
|
|
54
|
+
// validateAuth отбивает «Property must be a string» (lib/validators.js), а
|
|
55
|
+
// строки поля в DOM нет — игроку нечем это поправить
|
|
56
|
+
return forced === undefined ? undefined : String(forced);
|
|
57
|
+
}
|
|
58
|
+
|
|
22
59
|
// unit:'s' — значение хранится в мс, показывается/редактируется в секундах
|
|
23
60
|
function toDisplay(descriptor, value) {
|
|
24
61
|
return descriptor.unit === 's' ? value / 1000 : value;
|
|
@@ -30,10 +67,12 @@ function toStored(descriptor, value) {
|
|
|
30
67
|
|
|
31
68
|
function buildSelect(descriptor, ctx) {
|
|
32
69
|
const el = document.createElement('select');
|
|
70
|
+
const options = resolveOptions(descriptor, ctx);
|
|
71
|
+
const forced = forcedValue(options);
|
|
33
72
|
|
|
34
73
|
el.name = descriptor.name;
|
|
35
74
|
|
|
36
|
-
|
|
75
|
+
options.forEach(({ value, label }) => {
|
|
37
76
|
const option = document.createElement('option');
|
|
38
77
|
|
|
39
78
|
option.value = value;
|
|
@@ -41,10 +80,20 @@ function buildSelect(descriptor, ctx) {
|
|
|
41
80
|
el.appendChild(option);
|
|
42
81
|
});
|
|
43
82
|
|
|
83
|
+
if (forced !== undefined) {
|
|
84
|
+
el.value = forced;
|
|
85
|
+
}
|
|
86
|
+
|
|
44
87
|
return {
|
|
45
88
|
el,
|
|
89
|
+
singleOption: forced !== undefined,
|
|
90
|
+
noOptions: options.length === 0,
|
|
46
91
|
getValue: () => el.value,
|
|
47
92
|
setValue(value) {
|
|
93
|
+
if (forced !== undefined) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
48
97
|
el.value = value;
|
|
49
98
|
},
|
|
50
99
|
onChange(cb) {
|
|
@@ -58,11 +107,15 @@ function buildSelect(descriptor, ctx) {
|
|
|
58
107
|
// вместо превращения его в 0 на сабмите
|
|
59
108
|
function buildText(descriptor) {
|
|
60
109
|
const el = document.createElement('input');
|
|
61
|
-
const numeric = descriptor
|
|
110
|
+
const numeric = isNumeric(descriptor);
|
|
62
111
|
|
|
63
112
|
el.type = 'text';
|
|
64
113
|
el.name = descriptor.name;
|
|
65
114
|
|
|
115
|
+
// pattern/required — семантика контрола (a11y, автозаполнение), НЕ
|
|
116
|
+
// валидация: обе формы — обычные div, не <form>, и reportValidity()
|
|
117
|
+
// движок не зовёт (см. collectFormErrors). maxLength — единственный из
|
|
118
|
+
// трёх с реальным эффектом: он ограничивает сам ввод
|
|
66
119
|
if (descriptor.regExp) {
|
|
67
120
|
el.pattern = descriptor.regExp;
|
|
68
121
|
}
|
|
@@ -79,7 +132,11 @@ function buildText(descriptor) {
|
|
|
79
132
|
|
|
80
133
|
const getValue = () => {
|
|
81
134
|
if (!numeric) {
|
|
82
|
-
|
|
135
|
+
// то же, что лобби уже делает с именем комнаты (main.js: name.trim()):
|
|
136
|
+
// незначащие пробелы — опечатка ввода, а не значение. Валидация
|
|
137
|
+
// смотрит на ту же строку (validateField), иначе она разрешала бы то,
|
|
138
|
+
// что потом отбивает хост (isValidName не терпит крайних пробелов)
|
|
139
|
+
return el.value.trim();
|
|
83
140
|
}
|
|
84
141
|
|
|
85
142
|
const n = Number(el.value);
|
|
@@ -91,6 +148,11 @@ function buildText(descriptor) {
|
|
|
91
148
|
return {
|
|
92
149
|
el,
|
|
93
150
|
getValue,
|
|
151
|
+
// «сырая» строка поля, до конвертации единицы и отката к default:
|
|
152
|
+
// единственный способ отличить пустой ввод от валидного значения (у
|
|
153
|
+
// числового поля getValue() подменяет пустоту дефолтом). Часть контракта
|
|
154
|
+
// поля — валидации незачем знать, из какого элемента оно построено
|
|
155
|
+
getRaw: () => el.value,
|
|
94
156
|
setValue(value) {
|
|
95
157
|
el.value = numeric ? toDisplay(descriptor, value) : (value ?? '');
|
|
96
158
|
},
|
|
@@ -131,8 +193,12 @@ function buildRadio(descriptor, ctx) {
|
|
|
131
193
|
const el = document.createElement('div');
|
|
132
194
|
const groupName = `field-radio-${descriptor.name}-${++idSeq}`;
|
|
133
195
|
const inputs = [];
|
|
196
|
+
const options = resolveOptions(descriptor, ctx);
|
|
197
|
+
// см. forcedValue: ровно один вариант — принудительное и неизменяемое
|
|
198
|
+
// значение, строка скрыта и поправить рассинхрон нечем
|
|
199
|
+
const forced = forcedValue(options);
|
|
134
200
|
|
|
135
|
-
|
|
201
|
+
options.forEach(({ value, label }) => {
|
|
136
202
|
const wrap = document.createElement('span');
|
|
137
203
|
const input = document.createElement('input');
|
|
138
204
|
|
|
@@ -151,12 +217,22 @@ function buildRadio(descriptor, ctx) {
|
|
|
151
217
|
inputs.push(input);
|
|
152
218
|
});
|
|
153
219
|
|
|
220
|
+
if (forced !== undefined) {
|
|
221
|
+
inputs[0].checked = true;
|
|
222
|
+
}
|
|
223
|
+
|
|
154
224
|
const getValue = () => inputs.find(input => input.checked)?.value;
|
|
155
225
|
|
|
156
226
|
return {
|
|
157
227
|
el,
|
|
228
|
+
singleOption: forced !== undefined,
|
|
229
|
+
noOptions: options.length === 0,
|
|
158
230
|
getValue,
|
|
159
231
|
setValue(value) {
|
|
232
|
+
if (forced !== undefined) {
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
|
|
160
236
|
inputs.forEach(input => {
|
|
161
237
|
input.checked = input.value === String(value);
|
|
162
238
|
});
|
|
@@ -205,30 +281,268 @@ export function mergeRoomDefaults(descriptors, roomDefaults) {
|
|
|
205
281
|
}));
|
|
206
282
|
}
|
|
207
283
|
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
|
|
214
|
-
|
|
284
|
+
// суффикс подписи поля: диапазон (min/max, если задан хотя бы один из них),
|
|
285
|
+
// иначе просто единица (unit:'s') — та же строка, что и раньше, "(s)".
|
|
286
|
+
// Полноценные HTML5 min/max тут не годятся: числовые поля — type=text
|
|
287
|
+
// (unit-конвертация мс<->с сама по себе не native-совместима), поэтому
|
|
288
|
+
// диапазон — только подсказка + вход в collectFormErrors ниже
|
|
289
|
+
function buildLabelSuffix(descriptor) {
|
|
290
|
+
const { min, max, unit } = descriptor;
|
|
291
|
+
|
|
292
|
+
if (min === undefined && max === undefined) {
|
|
293
|
+
return unit === 's' ? ' (s)' : '';
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const range = `${min ?? '…'}–${max ?? '…'}`;
|
|
297
|
+
|
|
298
|
+
return unit === 's' ? ` (s, ${range})` : ` (${range})`;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// скомпилированные паттерны полей: строка regExp -> RegExp, либо null, если
|
|
302
|
+
// она не компилируется. Кэш на модуль — дескрипторы приезжают из манифеста и
|
|
303
|
+
// живут всю сессию, а сабмит иначе пересобирал бы RegExp на каждое поле
|
|
304
|
+
const patterns = new Map();
|
|
305
|
+
|
|
306
|
+
// regExp приезжает из манифеста игры строкой: невалидный паттерн — дефект
|
|
307
|
+
// схемы, а не повод убить сабмит. Без перехвата SyntaxError уходит из
|
|
308
|
+
// collectFormErrors в обработчик клика, кнопка перестаёт делать что-либо и
|
|
309
|
+
// игрок не видит ни строки (нативный `pattern` вёл себя ровно наоборот:
|
|
310
|
+
// нечитаемый паттерн браузер игнорирует). Контракт-чекер ловит это раньше —
|
|
311
|
+
// правило B5, roomForm; якорение общее с ним (lib/formPattern.js)
|
|
312
|
+
function fieldPattern(regExp) {
|
|
313
|
+
if (!patterns.has(regExp)) {
|
|
314
|
+
let pattern = null;
|
|
315
|
+
|
|
316
|
+
try {
|
|
317
|
+
pattern = anchorPattern(regExp);
|
|
318
|
+
} catch (e) {
|
|
319
|
+
console.error(`formBuilder: invalid regExp "${regExp}" — ${e.message}`);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
patterns.set(regExp, pattern);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return patterns.get(regExp);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// ошибка одного поля, либо null — required/regExp/maxlength (не-numeric
|
|
329
|
+
// text)/min/max (numeric text); сообщения не зависят от локали браузера, в
|
|
330
|
+
// отличие от бывшего reportValidity(). regExp сверяется с "сырой" строкой
|
|
331
|
+
// поля (field.getRaw()) — тем же значением, которое раньше матчил нативный
|
|
332
|
+
// pattern: у числового text-поля getValue() отдаёт число в единице
|
|
333
|
+
// хранения (мс для unit:'s'), а не отображаемую строку, так что regExp
|
|
334
|
+
// (сгенерированный build-game-manifest.js диапазон вроде "^([1-8])$" для
|
|
335
|
+
// maxPlayers — единственная граница у манифестов без min/max) проверяется
|
|
336
|
+
// отдельно от min/max, а не вместо них
|
|
337
|
+
function validateField(descriptor, field) {
|
|
338
|
+
// резолв вариантов дал пустой список: допустимого значения у поля нет
|
|
339
|
+
// вовсе — дефект схемы или каталога (buildForm уже написал console.error).
|
|
340
|
+
// Отдельным сообщением, а не через required: заполнить такое поле нечем, и
|
|
341
|
+
// «required» повело бы игрока туда, где ничего нет. Проверяется первой —
|
|
342
|
+
// required у поля может и не стоять (ни одна игра не ставит его на `map`),
|
|
343
|
+
// а пустая строка не должна уезжать на хост молча
|
|
344
|
+
if (field.noOptions) {
|
|
345
|
+
return 'no options available';
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const value = field.getValue();
|
|
349
|
+
const isText = descriptor.control === 'text';
|
|
350
|
+
// у числового поля getValue() уже откатился к default (чтобы пустая
|
|
351
|
+
// строка не уехала нулём), поэтому пустоту видно только по сырой строке.
|
|
352
|
+
// trim: Number(' ') === 0, то есть без него пробел прошёл бы нулём — и
|
|
353
|
+
// наоборот, ' 8 ' игрок считает валидной восьмёркой. У нечислового поля
|
|
354
|
+
// это ровно то же, что вернёт getValue(): обе стороны обязаны смотреть на
|
|
355
|
+
// одну строку, иначе клиент разрешает то, что отбивает хост
|
|
356
|
+
const raw = isText ? (field.getRaw?.() ?? '').trim() : undefined;
|
|
357
|
+
const isEmpty = isText
|
|
358
|
+
? raw === ''
|
|
359
|
+
: value === undefined || value === null || value === '';
|
|
360
|
+
const numeric = isText && isNumeric(descriptor);
|
|
361
|
+
|
|
362
|
+
// числовое поле всегда несёт число и «необязательным» быть не может:
|
|
363
|
+
// пустой ввод getValue() подменяет default'ом (чтобы не уехал нолём), и
|
|
364
|
+
// без этой ветки пустой Max players молча создавал бы комнату с дефолтом
|
|
365
|
+
if ((descriptor.required || numeric) && isEmpty) {
|
|
366
|
+
return 'required';
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
if (isEmpty) {
|
|
370
|
+
return null;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
if (numeric) {
|
|
374
|
+
// без этой проверки нечисловой ввод молча подменялся бы default'ом
|
|
375
|
+
// из getValue() и уезжал на хост как «валидный»
|
|
376
|
+
if (!Number.isFinite(Number(raw))) {
|
|
377
|
+
return 'must be a number';
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const displayValue = toDisplay(descriptor, value);
|
|
381
|
+
|
|
382
|
+
// min/max раньше regExp: тот же диапазон regExp кодирует точным
|
|
383
|
+
// паттерном (rangeToPattern), но «must be ≤ 32» говорит игроку то же,
|
|
384
|
+
// что подсказка в подписи поля, а «invalid format» — ничего
|
|
385
|
+
if (descriptor.min !== undefined && displayValue < descriptor.min) {
|
|
386
|
+
return `must be ≥ ${descriptor.min}`;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
if (descriptor.max !== undefined && displayValue > descriptor.max) {
|
|
390
|
+
return `must be ≤ ${descriptor.max}`;
|
|
391
|
+
}
|
|
392
|
+
} else if (
|
|
393
|
+
isText &&
|
|
394
|
+
descriptor.maxlength !== undefined &&
|
|
395
|
+
String(value).length > descriptor.maxlength
|
|
396
|
+
) {
|
|
397
|
+
return `must be at most ${descriptor.maxlength} characters`;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Проверяется последним: ловит то, что диапазон пропускает (дробное,
|
|
401
|
+
// ведущий ноль). Нечитаемый паттерн — не ограничение (fieldPattern уже
|
|
402
|
+
// назвал дефект в консоли), поле проходит
|
|
403
|
+
if (isText && descriptor.regExp) {
|
|
404
|
+
const pattern = fieldPattern(descriptor.regExp);
|
|
405
|
+
|
|
406
|
+
if (pattern && !pattern.test(raw)) {
|
|
407
|
+
return 'invalid format';
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
return null;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// валидирует всю форму перед сабмитом (замена бывшего reportFormValidity/
|
|
415
|
+
// native reportValidity): порядок ошибок — порядок дескрипторов.
|
|
416
|
+
// Поля без строки в DOM (hidden, единственный вариант) пропускаются: их
|
|
417
|
+
// ошибку игрок не видит и исправить не может — она бы заперла форму
|
|
418
|
+
// навсегда (нативный reportValidity обходил только контролы в DOM и до них
|
|
419
|
+
// тоже не добирался). label — подпись из формы, чтобы имя в ошибке
|
|
420
|
+
// совпадало с тем, что игрок видит рядом с полем
|
|
421
|
+
export function collectFormErrors(descriptors, fields) {
|
|
422
|
+
const errors = [];
|
|
423
|
+
|
|
424
|
+
descriptors.forEach(descriptor => {
|
|
425
|
+
const field = fields.get(descriptor.name);
|
|
426
|
+
|
|
427
|
+
if (!field || field.rendered === false) {
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const error = validateField(descriptor, field);
|
|
432
|
+
|
|
433
|
+
if (error) {
|
|
434
|
+
errors.push({
|
|
435
|
+
name: descriptor.name,
|
|
436
|
+
label: descriptor.label || descriptor.name,
|
|
437
|
+
error,
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
return errors;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// рисует ошибки формы в разметку — общий рендер для #lobby-error и
|
|
446
|
+
// #auth-error (и для клиентской collectFormErrors, и для серверных ошибок
|
|
447
|
+
// PS_AUTH_DATA/validators, чей формат уже {name, error})
|
|
448
|
+
export function renderFormErrors(container, errors) {
|
|
215
449
|
if (!container) {
|
|
216
|
-
return
|
|
450
|
+
return;
|
|
217
451
|
}
|
|
218
452
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
453
|
+
container.textContent = '';
|
|
454
|
+
|
|
455
|
+
(errors || []).forEach(({ name, label, error }) => {
|
|
456
|
+
const line = document.createElement('div');
|
|
457
|
+
// серверные ошибки (PS_AUTH_DATA/validators) приезжают без label —
|
|
458
|
+
// для них, как и раньше, остаётся имя поля
|
|
459
|
+
const title = String(label || name).toUpperCase();
|
|
460
|
+
|
|
461
|
+
line.textContent = error ? `${title}: ${error}` : `${title} is not correctly!`;
|
|
462
|
+
|
|
463
|
+
container.appendChild(line);
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// какому полю принадлежит событие ввода: у radio-группы field.el — обёртка
|
|
468
|
+
// вокруг нескольких input'ов, а их собственный name сгенерирован и с именем
|
|
469
|
+
// поля не совпадает, поэтому ищем по вхождению узла, а не по name
|
|
470
|
+
function fieldNameOf(fields, target) {
|
|
471
|
+
for (const [name, field] of fields) {
|
|
472
|
+
if (field.el === target || field.el.contains?.(target)) {
|
|
473
|
+
return name;
|
|
222
474
|
}
|
|
223
475
|
}
|
|
224
476
|
|
|
225
|
-
return
|
|
477
|
+
return undefined;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// перевалидация формы по ходу правки — вместо «одна ошибка на клик».
|
|
481
|
+
//
|
|
482
|
+
// Показывается не всё подряд: до сабмита — только поля, которых игрок уже
|
|
483
|
+
// касался. Неверное значение он видит сразу, как ввёл (а не после клика по
|
|
484
|
+
// кнопке), но «required» на поле, которое ещё не заполняли, не шумит.
|
|
485
|
+
// Сабмит (arm) снимает фильтр: клик — это ответ за всю форму целиком.
|
|
486
|
+
//
|
|
487
|
+
// Строка уходит из блока, когда починено именно её поле, а не всё разом по
|
|
488
|
+
// первому нажатию клавиши — иначе игрок с тремя ошибками теряет список,
|
|
489
|
+
// начав править первую.
|
|
490
|
+
//
|
|
491
|
+
// 'input' — единственное событие по ходу ввода ('change' у text-инпута
|
|
492
|
+
// приходит на blur, то есть уже после клика по кнопке); слушатель
|
|
493
|
+
// делегированный и вешается один раз: контейнер постоянен, пересобираются
|
|
494
|
+
// только поля. Пустой список тоже рендерится — так из блока уходят и
|
|
495
|
+
// не-формные строки (отказ загрузки плагина в лобби).
|
|
496
|
+
export function bindLiveErrors(container, errorContainer, getForm) {
|
|
497
|
+
let touched = new Set();
|
|
498
|
+
let all = false;
|
|
499
|
+
|
|
500
|
+
const render = () => {
|
|
501
|
+
const { descriptors, fields } = getForm();
|
|
502
|
+
const errors = collectFormErrors(descriptors, fields).filter(
|
|
503
|
+
error => all || touched.has(error.name),
|
|
504
|
+
);
|
|
505
|
+
|
|
506
|
+
renderFormErrors(errorContainer, errors);
|
|
507
|
+
|
|
508
|
+
return errors;
|
|
509
|
+
};
|
|
510
|
+
|
|
511
|
+
container?.addEventListener('input', event => {
|
|
512
|
+
const name = fieldNameOf(getForm().fields, event.target);
|
|
513
|
+
|
|
514
|
+
if (name !== undefined) {
|
|
515
|
+
touched.add(name);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
render();
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
return {
|
|
522
|
+
// сабмит: показываем и то, чего игрок не трогал. Возвращает отрисованные
|
|
523
|
+
// ошибки, чтобы вызывающему не пришлось собирать их вторым проходом
|
|
524
|
+
arm: () => {
|
|
525
|
+
all = true;
|
|
526
|
+
|
|
527
|
+
return render();
|
|
528
|
+
},
|
|
529
|
+
// форма пересобрана (смена игры) — она снова ничья: ни одного тронутого
|
|
530
|
+
// поля, ни одного сабмита
|
|
531
|
+
disarm: () => {
|
|
532
|
+
all = false;
|
|
533
|
+
touched = new Set();
|
|
534
|
+
|
|
535
|
+
return render();
|
|
536
|
+
},
|
|
537
|
+
};
|
|
226
538
|
}
|
|
227
539
|
|
|
228
540
|
// собирает форму (упорядоченный массив дескрипторов = порядок полей) в
|
|
229
541
|
// контейнер: одна .form-row на дескриптор (.form-label + контрол); onChange,
|
|
230
|
-
// если передан, подписывается на все поля разом. hidden:true
|
|
231
|
-
//
|
|
542
|
+
// если передан, подписывается на все поля разом. hidden:true, а также
|
|
543
|
+
// select/radio с единственным резолвнутым вариантом (singleOption) — поле
|
|
544
|
+
// строится и участвует в сабмите (fields Map), но строка не попадает в DOM
|
|
545
|
+
// и помечается field.rendered = false (collectFormErrors такие пропускает)
|
|
232
546
|
export function buildForm(descriptors, container, ctx = {}, onChange) {
|
|
233
547
|
const fields = new Map();
|
|
234
548
|
|
|
@@ -252,10 +566,32 @@ export function buildForm(descriptors, container, ctx = {}, onChange) {
|
|
|
252
566
|
|
|
253
567
|
fields.set(descriptor.name, field);
|
|
254
568
|
|
|
255
|
-
|
|
569
|
+
// пустой список вариантов — дефект схемы или каталога, а не «нечего
|
|
570
|
+
// выбирать»: значения у поля нет вовсе. Такое поле остаётся видимым, а
|
|
571
|
+
// валидация даёт ему собственное 'no options available' (см.
|
|
572
|
+
// validateField) — молча спрятать его значит отправить на хост пустую
|
|
573
|
+
// строку и получить отказ уже от игровых validators
|
|
574
|
+
if (field.noOptions) {
|
|
575
|
+
console.error(
|
|
576
|
+
`formBuilder: field "${descriptor.name}" resolved to no options`,
|
|
577
|
+
);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
if (descriptor.hidden || field.singleOption) {
|
|
581
|
+
field.rendered = false;
|
|
582
|
+
|
|
583
|
+
// строки нет — ошибку показать некому (её не видно и не исправить), но
|
|
584
|
+
// и пустая строка не значение: пусть ключ вообще не попадёт в сабмит,
|
|
585
|
+
// на хосте останется roomDefaults/значение схемы
|
|
586
|
+
if (field.noOptions) {
|
|
587
|
+
fields.delete(descriptor.name);
|
|
588
|
+
}
|
|
589
|
+
|
|
256
590
|
return;
|
|
257
591
|
}
|
|
258
592
|
|
|
593
|
+
field.rendered = true;
|
|
594
|
+
|
|
259
595
|
const row = document.createElement('div');
|
|
260
596
|
|
|
261
597
|
row.className = 'form-row';
|
|
@@ -266,7 +602,7 @@ export function buildForm(descriptors, container, ctx = {}, onChange) {
|
|
|
266
602
|
const label = document.createElement(field.labelFor ? 'label' : 'span');
|
|
267
603
|
|
|
268
604
|
label.className = 'form-label';
|
|
269
|
-
label.textContent = (descriptor.label || descriptor.name) + (descriptor
|
|
605
|
+
label.textContent = (descriptor.label || descriptor.name) + buildLabelSuffix(descriptor);
|
|
270
606
|
|
|
271
607
|
if (field.labelFor) {
|
|
272
608
|
label.htmlFor = field.labelFor;
|