fix(admin): G2 — error/success handling in AdminSupplierPricesView save
axios.patch теперь в try/catch с extractErrorMessage() helper. Per-row ошибки — reactive errorMessages: Record<number, string> отображаются как v-icon mdi-alert-circle с v-tooltip рядом с кнопкой «Сохранить». Success — v-snackbar (3s timeout, color=success, bottom-right) с именем поставщика. Retry на той же строке очищает предыдущий error перед новым axios.patch. load() тоже обёрнут — fetchError ref + v-alert warning сверху таблицы. +3 Vitest specs (save error / save success / retry clears error). Регрессий 0. Closes audit ID G2 from docs/superpowers/specs/2026-05-15-portal-audit-design.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,20 @@
|
||||
<template>
|
||||
<div class="admin-supplier-prices-view">
|
||||
<h1 class="text-h4 mb-6">Цены поставщиков (закупка)</h1>
|
||||
|
||||
<v-alert
|
||||
v-if="fetchError"
|
||||
type="warning"
|
||||
variant="tonal"
|
||||
class="mb-4"
|
||||
density="compact"
|
||||
data-testid="suppliers-fetch-error"
|
||||
closable
|
||||
@click:close="fetchError = null"
|
||||
>
|
||||
{{ fetchError }}
|
||||
</v-alert>
|
||||
|
||||
<v-card elevation="1">
|
||||
<v-data-table :headers="headers" :items="suppliers" density="comfortable" class="numeric-tnum">
|
||||
<template #[`item.cost_rub`]="{ item }">
|
||||
@@ -38,27 +52,52 @@
|
||||
/>
|
||||
</template>
|
||||
<template #[`item.actions`]="{ item }">
|
||||
<v-btn size="small" color="primary" :loading="!!saving[item.id]" @click="save(item)">
|
||||
Сохранить
|
||||
</v-btn>
|
||||
<div class="d-flex flex-column align-end ga-1">
|
||||
<v-btn size="small" color="primary" :loading="!!saving[item.id]" @click="save(item)">
|
||||
Сохранить
|
||||
</v-btn>
|
||||
<v-tooltip v-if="errorMessages[item.id]" location="left">
|
||||
<template #activator="{ props: tooltipProps }">
|
||||
<v-icon
|
||||
v-bind="tooltipProps"
|
||||
color="error"
|
||||
size="small"
|
||||
:data-testid="`supplier-error-${item.id}`"
|
||||
>
|
||||
mdi-alert-circle
|
||||
</v-icon>
|
||||
</template>
|
||||
<span>{{ errorMessages[item.id] }}</span>
|
||||
</v-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</v-card>
|
||||
|
||||
<v-snackbar
|
||||
v-model="successToastOpen"
|
||||
:timeout="3000"
|
||||
color="success"
|
||||
location="bottom right"
|
||||
data-testid="supplier-success-toast"
|
||||
>
|
||||
{{ successToastText }}
|
||||
</v-snackbar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, reactive } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { extractErrorMessage } from '../../api/client';
|
||||
|
||||
/**
|
||||
* SaaS-admin → Цены поставщиков (Plan 4 Task 10).
|
||||
* SaaS-admin → Цены поставщиков (Plan 4 Task 10, Sprint 1 G2 error handling).
|
||||
*
|
||||
* Backend: AdminSuppliersController (GET/PATCH).
|
||||
* Палитра Forest + JetBrains Mono для tnum-цифр.
|
||||
*
|
||||
* defineExpose ниже — для Vitest unit-тестов (`load`/`save`/`suppliers`/
|
||||
* `saving`). На прод-сборку это не влияет.
|
||||
* defineExpose ниже — для Vitest unit-тестов.
|
||||
*/
|
||||
|
||||
interface SupplierRow {
|
||||
@@ -72,6 +111,10 @@ interface SupplierRow {
|
||||
|
||||
const suppliers = ref<SupplierRow[]>([]);
|
||||
const saving = reactive<Record<number, boolean>>({});
|
||||
const errorMessages = reactive<Record<number, string>>({});
|
||||
const fetchError = ref<string | null>(null);
|
||||
const successToastOpen = ref(false);
|
||||
const successToastText = ref('');
|
||||
|
||||
const headers = [
|
||||
{ title: 'Code', key: 'code', sortable: false, width: 80 },
|
||||
@@ -83,18 +126,28 @@ const headers = [
|
||||
];
|
||||
|
||||
async function load(): Promise<void> {
|
||||
const { data } = await axios.get('/api/admin/suppliers');
|
||||
suppliers.value = data.data;
|
||||
fetchError.value = null;
|
||||
try {
|
||||
const { data } = await axios.get('/api/admin/suppliers');
|
||||
suppliers.value = data.data;
|
||||
} catch (err) {
|
||||
fetchError.value = extractErrorMessage(err, 'Не удалось загрузить список поставщиков.');
|
||||
}
|
||||
}
|
||||
|
||||
async function save(s: SupplierRow): Promise<void> {
|
||||
saving[s.id] = true;
|
||||
delete errorMessages[s.id]; // очистить предыдущую ошибку перед retry
|
||||
try {
|
||||
await axios.patch(`/api/admin/suppliers/${s.id}`, {
|
||||
cost_rub: s.cost_rub,
|
||||
quality_score: s.quality_score,
|
||||
is_active: s.is_active,
|
||||
});
|
||||
successToastText.value = `Сохранено: ${s.name} (${s.code}).`;
|
||||
successToastOpen.value = true;
|
||||
} catch (err) {
|
||||
errorMessages[s.id] = extractErrorMessage(err, 'Не удалось сохранить изменения.');
|
||||
} finally {
|
||||
saving[s.id] = false;
|
||||
}
|
||||
@@ -102,7 +155,7 @@ async function save(s: SupplierRow): Promise<void> {
|
||||
|
||||
onMounted(load);
|
||||
|
||||
defineExpose({ load, save, suppliers, saving });
|
||||
defineExpose({ load, save, suppliers, saving, errorMessages, fetchError, successToastOpen, successToastText });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import axios from 'axios';
|
||||
@@ -80,3 +80,91 @@ describe('AdminSupplierPricesView', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('AdminSupplierPricesView error handling (Sprint 1 G2)', () => {
|
||||
beforeEach(() => {
|
||||
// axios.isAxiosError is auto-mocked as vi.fn() by vi.mock('axios').
|
||||
// We need it to return true so extractErrorMessage() can read response.data.message.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(axios.isAxiosError as any).mockReturnValue(true);
|
||||
});
|
||||
afterEach(() => {
|
||||
// Cleanup mockReturnValue to prevent leak into other describe blocks (review I-1).
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('save() shows per-row errorMessage when axios.patch rejects', async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(axios.get as any).mockResolvedValue({
|
||||
data: {
|
||||
data: [
|
||||
{ id: 1, code: 'B1', name: 'Supplier 1', cost_rub: '120.00', quality_score: '8.50', is_active: true },
|
||||
],
|
||||
},
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(axios.patch as any).mockRejectedValue({
|
||||
response: { status: 422, data: { message: 'cost_rub must be non-negative' } },
|
||||
});
|
||||
const wrapper = mount(AdminSupplierPricesView, { global: { plugins: [vuetify] } });
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
const row = { id: 1, code: 'B1', name: 'Supplier 1', cost_rub: '-5.00', quality_score: '8.50', is_active: true };
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await (wrapper.vm as any).save(row);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const vm = wrapper.vm as any;
|
||||
expect(vm.errorMessages[1]).toContain('cost_rub');
|
||||
expect(vm.saving[1]).toBe(false);
|
||||
});
|
||||
|
||||
it('save() shows successMessage on 200', async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(axios.get as any).mockResolvedValue({
|
||||
data: {
|
||||
data: [
|
||||
{ id: 2, code: 'B2', name: 'Supplier 2', cost_rub: '100.00', quality_score: '9.00', is_active: true },
|
||||
],
|
||||
},
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(axios.patch as any).mockResolvedValue({ data: { ok: true } });
|
||||
const wrapper = mount(AdminSupplierPricesView, { global: { plugins: [vuetify] } });
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
const row = { id: 2, code: 'B2', name: 'Supplier 2', cost_rub: '110.00', quality_score: '9.00', is_active: true };
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await (wrapper.vm as any).save(row);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const vm = wrapper.vm as any;
|
||||
expect(vm.successToastOpen).toBe(true);
|
||||
expect(vm.successToastText).toContain('Supplier 2');
|
||||
expect(vm.errorMessages[2]).toBeUndefined();
|
||||
expect(vm.saving[2]).toBe(false);
|
||||
});
|
||||
|
||||
it('save() clears previous error on successful retry', async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(axios.get as any).mockResolvedValue({
|
||||
data: { data: [{ id: 3, code: 'B3', name: 'Supplier 3', cost_rub: '100.00', quality_score: '8.00', is_active: true }] },
|
||||
});
|
||||
// First call fails
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(axios.patch as any).mockRejectedValueOnce({
|
||||
response: { status: 500, data: { message: 'transient' } },
|
||||
});
|
||||
// Second call succeeds
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(axios.patch as any).mockResolvedValueOnce({ data: { ok: true } });
|
||||
|
||||
const wrapper = mount(AdminSupplierPricesView, { global: { plugins: [vuetify] } });
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
const row = { id: 3, code: 'B3', name: 'Supplier 3', cost_rub: '100.00', quality_score: '8.00', is_active: true };
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await (wrapper.vm as any).save(row);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
expect((wrapper.vm as any).errorMessages[3]).toContain('transient');
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await (wrapper.vm as any).save(row);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
expect((wrapper.vm as any).errorMessages[3]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user