Основная кнопка действия
Первый экран умеет показывать выбор и адаптировать представление. Теперь нужна основная кнопка, доступность которой отражает состояние действия. Для «Учебного плана» нельзя разрешать сохранение, пока нет подтверждённой собственной сессии, нет изменений или уже идёт запрос. Результат урока — самостоятельный модуль управления кнопкой хоста и обычной кнопкой документа. Он принимает явный серверный callback; рабочую сессию и backend здесь не подменяем локальным успехом.
Telegram предоставляет MainButton как объект основной нижней кнопки; в актуальной документации его общий тип называется BottomButton. Методы видимости, текста, доступности и прогресса описаны в справочнике. Имя WebApp.MainButton остаётся способом доступа. Версия клиента и наличие методов проверяются перед использованием. Если нужного набора нет, обычная HTML-кнопка выполняет тот же договор, а не другую упрощённую операцию.
Состояние и операция
Модуль commit.js получает host, реальную кнопку документа, область сообщения, функцию getState и асинхронную submit. GetState возвращает {authenticated, dirty}; эти признаки определяет приложение. Submit обязан выполнить серверный договор и вернуть подтверждённый объект с числовой version. Без этого callback модуль не является готовым сохранением. В текущем читательном экране authenticated остаётся false, поэтому действие не обещает запись личного плана.
Ниже полный модуль. Он сам защищает от повторного нажатия до завершения текущего вызова и снимает созданные обработчики:
export function bindCommit({host, button, message, getState, submit}) {
const native = host?.MainButton;
const capable = native && typeof host.isVersionAtLeast === 'function' &&
host.isVersionAtLeast('6.1') && ['onClick', 'offClick', 'setText', 'show', 'hide', 'enable', 'disable']
.every(name => typeof native[name] === 'function');
let busy = false;
let disposed = false;
function paint() {
if (disposed) return;
const state = getState();
const allowed = Boolean(state.authenticated && state.dirty && !busy);
button.hidden = Boolean(capable);
button.disabled = !allowed;
if (capable) {
native.setText('Сохранить план').show();
allowed ? native.enable() : native.disable();
if (typeof native.showProgress === 'function' && typeof native.hideProgress === 'function') {
busy ? native.showProgress() : native.hideProgress();
}
}
}
async function activate() {
const state = getState();
if (disposed || busy || !state.authenticated || !state.dirty) return;
busy = true;
paint();
message.textContent = 'Сохраняем план…';
try {
const result = await submit();
if (disposed) return;
if (!Number.isInteger(result?.version) || result.version < 1) throw new Error('Invalid confirmation');
message.textContent = `План сохранён, версия ${result.version}.`;
} catch {
if (!disposed) message.textContent = 'Сохранение не подтверждено. Изменения остаются в форме.';
} finally {
busy = false;
paint();
}
}
button.addEventListener('click', activate);
if (capable) native.onClick(activate);
paint();
return {
refresh: paint,
dispose() {
disposed = true;
button.hidden = true;
button.disabled = true;
button.removeEventListener('click', activate);
if (capable) {
native.offClick(activate);
if (typeof native.hideProgress === 'function') native.hideProgress();
native.hide();
}
}
};
}
Один источник доступности
Проверка allowed управляет обоими представлениями, а guard внутри activate повторяет условие. Отключённая кнопка помогает человеку, но не заменяет защиту операции: callback может быть вызван другим способом или состояние изменится между рисованием и нажатием. Признак busy устанавливается до await, поэтому быстрый второй клик не стартует ещё один вызов в этом экземпляре модуля. Это локальная защита, не серверная идемпотентность и не защита двух одновременно открытых приложений.
Dirty означает отличие текущего выбора от подтверждённого серверного состояния. После успешного ответа submit должен обновить это состояние и version, затем getState вернёт актуальное значение. Сам модуль не угадывает, какой курс принят сервером, и не записывает данные в localStorage. Если submit просто возвращает вымышленный {version:1}, сообщение будет технически показано, но договор нарушен. Такой fake допустим лишь в отдельно обозначенном сценарии и не доказывает настоящее сохранение.
Для ordinary-button в отдельном UI-сценарии используйте <button type="button">Сохранить план</button> и существующий p с role status. В базовый первый экран этот модуль не подключается автоматически, пока не подготовлена сессия и серверный callback. Архив обозначает его как самостоятельную границу подключения. Это позволяет читать полный код и не скрывать отсутствие backend за кнопкой, которая кажется действующей. Сохраняемые значения и проверка ответа будут разобраны в API-уроке.
Прогресс и подтверждение
Индикатор прогресса сообщает, что операция выполняется, а не что она уже успешна. Если submit отказал, поле выбора не очищается. Важное сообщение говорит «не подтверждено», поскольку потеря ответа может означать, что сервер успел сохранить данные. Нельзя автоматически обещать «ничего не записано» по сетевому исключению. Поздний ответ, повтор и проверка состояния потребуют серверного идентификатора операции и версии, которые рассмотрим дальше.
Полученная version проверяется как часть минимального подтверждения. Рабочий API дополнительно должен вернуть и проверить соответствующие выбранному плану поля; эту ответственность несёт submit-адаптер, не UI-кнопка. Если ответ относится к другой операции, его нельзя принять только потому, что в нём есть положительное число. Здесь мы определяем контракт зависимости, а не выдаём небольшую проверку version за полную схему безопасного API.
Серверное подтверждение также не следует связывать с анимацией прогресса. Она может исчезнуть по отказу, закрытию экрана или успешному ответу; эти причины различаются текстом и авторитетным состоянием. Если сеть пропала после отправки, приложение оставляет выбор и предлагает проверить результат, а не бесконечно показывает spinner. У нашего модуля submit отвечает за ограничение времени; bindCommit не создаёт дополнительный конкурентный таймер, который мог бы отменить ожидание и одновременно продолжить менять состояние через старый callback.
Для нескольких видов ошибок одной общей строки достаточно только на этом учебном уровне. Позднее API различит отсутствие сессии, неверную частоту, конфликт version и временный отказ. UI-кнопка не должна угадывать причину по наличию слова в exception.message. Адаптер возвратит понятный тип ошибки, а форма свяжет его с конкретным полем либо общим действием. Это развитие сохраняет тот же guard от повторного клика, расширяя обратную связь без размножения обработчиков.
Завершение жизни экрана
Dispose снимает ровно тот activate, который был зарегистрирован, скрывает нативную кнопку и прекращает обновление этого представления. Уже отправленная операция при этом не отменяется автоматически. Даже отменённый fetch не гарантировал бы отмену сохранения на сервере. Поэтому следующий экран должен уметь заново прочитать авторитетное состояние. Старый callback после dispose не меняет текст сообщения и не возвращает кнопку на новый экран.
Один владелец MainButton важен: если два одновременно живущих модуля вызывают setText и onClick, внешний вид и действие расходятся. В нашем проекте экран хранит handle и вызывает dispose перед заменой представления. Не подписывайте новый сохранитель при каждом изменении select. Вместо этого обновите draft и вызовите refresh. Это отличает изменение состояния от повторной установки механизма, сохраняет выбранный курс и предсказуемое число запросов.
Ожидаемые условия
Матрица включает отсутствие сессии, отсутствие изменений, один успешный ответ, отказ и два быстрых клика. В первых двух случаях действие недоступно, в третьем показана подтверждённая version, при отказе изменения остаются, а два клика дают один локальный вызов. На клиенте без MainButton используется HTML-кнопка. Эти результаты не наблюдались: JavaScript, Telegram и API здесь не запускались. В серверном контракте локальная блокировка не заменяет права владельца и идемпотентность.
Следующий урок согласует навигацию между каталогом и экраном выбора. При переходе назад исчезновение кнопки не должно потерять несохранённые данные или отправить повторную операцию. Мы будем использовать один маршрут состояния и отдельный lifecycle кнопки, сохраняя чёткую связь действия с конкретным экраном.