Сохранение участника и ошибки формы
Оглавление серии «Vue 3: кабинет сотрудников «Команда»»
Чтение уже различает данные и ожидание. Теперь дадим карточке редактор доступности, который сохраняет изменение через PATCH. Результат урока — подтверждаемая запись: чекбокс хранит черновик, кнопка запускает операцию, общая коллекция получает новую карточку только после успешного ответа. При отказе введённое значение остаётся, чтобы пользователь мог понять ситуацию и решить дальнейшее действие.
Не будем менять общую команду сразу при клике. Это подход с ожиданием подтверждения, удобный для доступности сотрудников: ошибочный экран может повлиять на распределение задач. Оптимистическое обновление потребовало бы правил отката, последовательности конкурирующих операций и обработки неоднозначного сетевого сбоя. В этом кабинете сначала важнее честно обозначить подтверждённое состояние.
PATCH и защита запроса
В src/api/team.js добавляется функция updateMember. Чтобы листинг оставался коротким, ниже указан точный добавляемый фрагмент; файл snippets/update-member.js хранит этот текст. Итоговый полный src/api/team.js уже присутствует в состоянии lesson-14 архива, поэтому при восстановлении заменяется целиком.
export async function updateMember(member, available) {
const token = document.querySelector('meta[name="csrf-token"]')?.content
if (!token) throw new ApiError('Не получен токен сессии. Откройте кабинет через сервер.')
const response = await fetch(`/api/members/${encodeURIComponent(member.id)}`, {
method: 'PATCH', credentials: 'same-origin',
headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'X-CSRF-TOKEN': token },
body: JSON.stringify({ available, version: member.version }),
})
const body = await jsonResponse(response)
const result = parseMember(body.data)
if (result.id !== member.id) throw new ApiError('Сервер вернул другую запись.')
return result
}
Токен поступает из HTML текущей сессии, а не из константы фронтенда. Базовый статический index.html его не содержит; чтение можно обсуждать отдельно, но запись требует серверной оболочки договора. Рекомендации Vue по безопасности подчёркивают необходимость согласовать клиент с серверными механизмами. Токен не заменяет проверку права изменения конкретного сотрудника.
В общем модуле добавьте импорт updateMember рядом с listMembers, функцию save перед useSharedTeam и save в возвращаемый объект. Это точный фрагмент новой операции; авторское состояние архива включает также полный team.js с внесёнными изменениями.
async function save(member, available) {
const result = await updateMember(member, available)
cancelLoad()
const index = members.value.findIndex(item => item.id === result.id)
if (index >= 0) members.value[index] = result
return result
}
Подменяется именно карточка из ответа, включая version. Если после сохранения оставить прежнюю версию, следующий запрос будет выглядеть устаревшим даже без чужого изменения. Нельзя синтезировать новое значение version простым увеличением у клиента: право утверждать серверную версию принадлежит серверу, а ответ может включать дополнительные нормализованные решения.
Перед заменой вызывается cancelLoad: ещё выполняющееся чтение могло получить коллекцию до записи и вернуть её позднее. Подтверждение PATCH лишает такой результат права заменить новую карточку. Новое чтение после подтверждения снова допустимо, поскольку должно обращаться уже к сохранённому состоянию сервера. Так согласуется чтение и запись одного общего источника.
Форма отдельного редактора
Создадим полный src/components/MemberEditor.vue. Редактор получает снимок участника, а не mutable-ссылку для прямого изменения. saving блокирует повторную отправку одного экземпляра, status показывает подтверждение, error не очищает draftAvailable.
<script setup>
import { ref } from 'vue'
import { useSharedTeam } from '../state/team.js'
const props = defineProps({ member: { type: Object, required: true } })
const { save } = useSharedTeam()
const draftAvailable = ref(props.member.available)
const saving = ref(false)
const error = ref('')
const status = ref('')
async function submit() {
if (saving.value) return
saving.value = true; error.value = ''; status.value = ''
try {
await save({ ...props.member }, draftAvailable.value)
status.value = 'Доступность подтверждена сервером.'
} catch (failure) {
error.value = failure.status === 422 ? 'Сервер отклонил поле доступности.'
: 'Изменение не подтверждено. Перед повтором обновите карточку.'
} finally { saving.value = false }
}
</script>
<template>
<form @submit.prevent="submit">
<label><input v-model="draftAvailable" type="checkbox" :disabled="saving"> Доступен для задачи</label>
<button type="submit" :disabled="saving">{{ saving ? 'Сохраняем' : 'Сохранить доступность' }}</button>
<p v-if="error" role="alert">{{ error }}</p>
<p v-if="status" role="status">{{ status }}</p>
</form>
</template>
Ниже полный новый MemberDetail.vue. Ключ редактора зависит только от id: изменение version после собственного подтверждения не уничтожает его сообщение. Переход на другого участника создаёт другой черновик.
<script setup>
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { useSharedTeam } from '../state/team.js'
import MemberEditor from '../components/MemberEditor.vue'
const route = useRoute()
const { members, loaded, loading, error, load } = useSharedTeam()
const member = computed(() => members.value.find(item => item.id === route.params.id))
</script>
<template>
<section :aria-busy="loading">
<p v-if="loading" role="status">Получаем участников.</p>
<p v-if="error" role="alert">{{ error }}</p>
<button type="button" @click="load">Обновить команду</button>
<article v-if="member"><h2>{{ member.name }}</h2><p>{{ member.role }}</p>
<p>Подтверждённое состояние: {{ member.available ? 'Доступен' : 'Занят' }}</p>
<MemberEditor :key="member.id" :member="member" />
</article>
<p v-else-if="loaded && !loading">Участник не найден.</p>
</section>
</template>
Ожидается, что изменение чекбокса не меняет верхнюю подтверждённую подпись. После удачного PATCH подпись получает новое available и редактор сообщает о подтверждении. При 422 подпись сохраняется, поле остаётся выбранным и пользователь видит отказ. Именно такое различие даёт смысл двум источникам: черновик выражает намерение, общая карточка отражает последний подтверждённый ответ.
Неоднозначный отказ
Сетевой сбой не всегда означает, что сервер ничего не записал. Он мог завершить изменение, а ответ не дошёл. Поэтому сообщение говорит «не подтверждено» и предлагает обновить запись перед повтором. Автоматический бесконечный повтор был бы неверным обещанием надёжности. Для операции установки булева значения идемпотентность проще, чем для приглашения, но версия всё равно важна: повтор старого снимка может конфликтовать с новым состоянием.
Полезный неправильный случай — очистить draftAvailable в catch или сразу присвоить available общей записи перед await. В первом случае пользователь теряет своё намерение, во втором получает ложную подтверждённую доступность. Оба варианта могут выглядеть гладко при удачной сети и проявиться только при отказе. Урок задаёт поведение заранее, а не выдаёт успешный ответ за единственный возможный путь.
Блокировка saving относится к одному экземпляру формы. Она не запрещает другую вкладку и не заменяет атомарное условие на сервере. Если две формы прочитали одну version, обе могут попытаться сохранить изменение. Следующий урок разберёт 409 и правило обновления черновика, сохранив серверную ответственность за сравнение версии. Перед продолжением проследите отдельно значение чекбокса, карточки, версии и подписи после 200, 422 и обрыва сети.
Основание выбранного значения
Редактор и подтверждённая карточка могут показывать разные состояния ещё до отправки. Это допустимо и должно быть понятно пользователю: верхняя подпись относится к сохранённым данным, чекбокс — к предложению изменения. Если убрать одну из подписей, человек может не увидеть, что его ввод пока не принят. Для множества полей различие обычно выражают кнопкой сохранения и отметкой несохранённого черновика; здесь оно показано прямо на одном значении.
В payload передаются только available и version. name и role не отправляются, потому что операция не разрешает их менять. Копирование всей карточки в JSON могло бы случайно расширить поверхность записи и создать ожидание, что сервер принимает любое поле. Узкий договор помогает одинаково ограничить клиент и сервер. В обработчике API следует проверять те же два поля независимо от того, что интерфейс предлагает только чекбокс.
Перед записью токен читается заново из оболочки. Это не защищает от любой смены сессии без перезагрузки страницы, однако избегает постоянной константы, не связанной с текущим HTML. Если сессия истекла, сервер вправе отказать даже при наличии строки токена. Сообщение о подтверждении появляется исключительно после корректной карточки в успешном ответе. Наличие cookies или выполненный fetch сами по себе недостаточны.
Наконец, кнопка обновления общего списка может работать одновременно с редактором. cancelLoad после подтверждения записи отменяет конкурирующее чтение, начавшееся раньше этого результата. Черновик сохраняется в своём экземпляре компонента. Последующий явный reload способен изменить props, поэтому следующий урок отделит основание ввода от постоянно обновляемого свойства. Такая связь уроков объясняет, зачем сначала была введена версия записи, а затем отдельный снимок основания.
Для самостоятельного воспроизведения результата «Сохранять изменение после подтверждения сервером» выберите lesson-14 в архиве исходников. Статус выполнения этого состояния: not_run.