Import and export
The first screen here where an action takes time, and the only one that admits a job can half-succeed.
What this proves
Every other example on this site answers instantly. This one does not, and everything hard about it comes from that: the screen has to say what is happening, what will be left behind, and what to do when the answer is neither success nor failure.
Press Import CSV and watch it run. It finishes the way imports actually finish.
“Done” has four states, not two
The job ends finished with errors: 797 rows written, 3 rejected. That is the ordinary outcome of importing a real file, and almost every import screen hides it behind a green tick — because a spinner has two exits and nobody drew the third.
Four states are honest here: running, finished, finished with errors, and cancelled. The last two are the ones that need words rather than a colour.
Cancelling is not undoing
The confirmation says so before you press it: rows already written stay written. Stopping a job halfway leaves the data in a state that nothing on the screen can describe as “before” — so the screen describes it as what it is.
The same sentence is missing from most cancel dialogs, and the reader learns the truth afterwards, from support.
A failure is not a message, it is a task
“7 rows failed” tells you a problem exists somewhere. This screen lists which rows, which field, and why — two addresses in one cell, unknown code “UK” — expected “GB” — and offers to retry only those, or take them away as a file.
A rejected row is still waiting. Until the screen says what to do with it, the import is not finished; it is abandoned.
The job outlives the tab
Written under the progress bar: the job runs on the server, and closing the tab does not stop it. Whether that is true is the backend’s business, but saying it is the screen’s — otherwise the reader guesses, and guesses wrong in both directions.
Components used
The list is built from frontmatter and checked against the source: a component added to the code and forgotten in the header fails the gate.
GrCarddataGrDataTabledataGrBadgefeedbackGrButtonactionsGrProgressBarfeedbackGrStepsnavigationGrAlertfeedbackGrDescriptionListdataGrConfirmDialogoverlays
Packages:
@feugene/granularity
Entry point
The same file that runs here — examples/data-management/App.vue. No second copy of the code exists.
<script setup lang="ts">
/**
* `/examples/data-management` — импорт и экспорт, работа C2.
*
* Первый экран раздела, где действие не мгновенно. Всё трудное здесь — про
* **время** и про то, что операция после себя оставляет.
*
* Три утверждения, вокруг которых он собран:
*
* 1. «Готово» — не два состояния, а четыре. Частичный успех бывает чаще
* полного, и почти всякий интерфейс импорта его прячет за зелёной галочкой.
* 2. Остановка не отменяет. Записанные строки остаются записанными, и это
* обязано быть написано **до** нажатия, а не после.
* 3. Отказ обязан быть действием. «7 строк не прошли» — это не сообщение, пока
* рядом нет ответа на вопрос «какие и что теперь».
*/
import { computed, onBeforeUnmount, reactive, ref } from 'vue'
import type { GrDataColumn } from '@feugene/granularity/components/GrDataTable'
import { GrDataTable } from '@feugene/granularity/components/GrDataTable'
import { GrCard } from '@feugene/granularity/components/GrCard'
import { GrBadge } from '@feugene/granularity/components/GrBadge'
import { GrButton } from '@feugene/granularity/components/GrButton'
import { GrProgressBar } from '@feugene/granularity/components/GrProgressBar'
import { GrSteps } from '@feugene/granularity/components/GrSteps'
import { GrAlert } from '@feugene/granularity/components/GrAlert'
import { GrDescriptionList } from '@feugene/granularity/components/GrDescriptionList'
import { GrConfirmDialog } from '@feugene/granularity/components/GrConfirmDialog'
import type { DataJob, DataManagementData } from './data'
const props = defineProps<{ data: DataManagementData }>()
const jobs = reactive<DataJob[]>(props.data.seed.map(job => ({ ...job, failures: [...job.failures] })))
const selectedId = ref<number | null>(props.data.seed[0]?.id ?? null)
const stopping = ref(false)
/** Попытка запустить второе задание, пока идёт первое. Отвечает словами. */
const busy = ref(false)
let ticker: ReturnType<typeof setInterval> | undefined
let nextId = props.data.seed.length + 1
onBeforeUnmount(() => clearInterval(ticker))
const selected = computed(() => jobs.find(job => job.id === selectedId.value))
const running = computed(() => jobs.find(job => job.state === 'running'))
/** Форма слова по числу: правило здесь, слова — в содержимом. */
function plural(n: number, forms: [string, string, string]): string {
const [one, few, many] = forms
const mod100 = n % 100
const mod10 = n % 10
if (mod100 >= 11 && mod100 <= 14)
return many
if (mod10 === 1)
return one
if (mod10 >= 2 && mod10 <= 4)
return few
return many
}
const STATE_TONE = { running: 'info', partial: 'warning', done: 'success', cancelled: 'neutral' } as const
function percent(job: DataJob) {
return job.total === 0 ? 0 : Math.round((job.written / job.total) * 100)
}
/** Этап определяется ходом, а не отдельным полем: два источника разошлись бы. */
function stageOf(job: DataJob) {
const done = percent(job)
if (done < 15)
return 'validate'
return done < 90 ? 'write' : 'index'
}
function start(kind: DataJob['kind']) {
if (running.value) {
busy.value = true
window.setTimeout(() => (busy.value = false), 4000)
return
}
busy.value = false
const job: DataJob = {
id: nextId++,
kind,
subject: kind === 'import' ? 'orders-2026-09.csv' : props.data.seed[1].subject,
state: 'running',
total: kind === 'import' ? 800 : 24,
written: 0,
startedAt: '—',
failures: [],
}
jobs.unshift(job)
selectedId.value = job.id
/**
* Дальше меняется `live`, а не `job`.
*
* `reactive` отдаёт **прокси**, и шаблон читает именно его. Правка исходного
* объекта доходит до данных, но не до отрисовки: задание шло, полоса стояла
* на нуле, экран молчал. Ошибка тихая — ни исключения, ни предупреждения, —
* и нашёл её прогон, а не глаз.
*/
const live = jobs[0]
clearInterval(ticker)
ticker = setInterval(() => {
const step = Math.max(1, Math.round(live.total / 12))
live.written = Math.min(live.total, live.written + step)
if (live.written >= live.total) {
clearInterval(ticker)
// Импорт заканчивается частичным успехом намеренно: это и есть обычный
// исход, а не редкая беда.
if (kind === 'import') {
live.state = 'partial'
live.written = live.total - 3
live.failures = props.data.seed[0].failures.slice(0, 3)
}
else {
live.state = 'done'
}
}
}, 260)
}
function confirmStop() {
const job = running.value
clearInterval(ticker)
if (job)
job.state = 'cancelled'
stopping.value = false
}
const columns = computed<GrDataColumn[]>(() => {
const label = props.data.columns
return [
{ key: 'subject', label: label.subject },
{ key: 'state', label: label.state },
{ key: 'progress', label: label.progress },
{ key: 'startedAt', label: label.started },
]
})
const failureColumns = computed<GrDataColumn[]>(() => {
const label = props.data.failureColumns
return [
{ key: 'row', label: label.row, align: 'right' },
{ key: 'field', label: label.field },
{ key: 'reason', label: label.reason },
]
})
const facts = computed(() => {
const job = selected.value
if (!job)
return []
const label = props.data.details.facts
return [
{ label: label.kind, value: props.data.kinds[job.kind] },
{ label: label.subject, value: job.subject },
{ label: label.total, value: String(job.total) },
{ label: label.written, value: String(job.written) },
{ label: label.failed, value: String(job.failures.length) },
{ label: label.started, value: job.startedAt },
]
})
</script>
<template>
<div class="screen">
<header>
<p class="screen__brand">{{ data.brand }}</p>
<h1 class="screen__title">{{ data.title }}</h1>
<p class="screen__subtitle">{{ data.subtitle }}</p>
</header>
<div class="screen__actions">
<!--
Кнопки не гаснут, пока идёт задание, — то же правило, что на экране
входа: погашенная кнопка прячет причину, по которой она погашена.
Нажатие отвечает словами.
-->
<GrButton @click="start('import')">{{ data.startImport }}</GrButton>
<GrButton variant="outline" @click="start('export')">{{ data.startExport }}</GrButton>
<p class="screen__hint">{{ data.importHint }}</p>
</div>
<GrAlert v-if="busy" tone="info" live="polite" :title="data.oneAtATime" />
<div class="screen__split">
<GrCard class="screen__panel">
<div class="screen__scroll">
<GrDataTable :rows="jobs" :columns="columns" row-key="id" @row-click="({ row }) => selectedId = Number(row.id)">
<template #cell-subject="{ row }">
<button type="button" class="screen__link" @click="selectedId = Number(row.id)">
{{ row.subject }}
</button>
</template>
<template #cell-state="{ row }">
<GrBadge size="sm" :tone="STATE_TONE[row.state as DataJob['state']]">
{{ data.states[row.state as DataJob['state']] }}
</GrBadge>
</template>
<!--
Ход показывается числом и полосой. Полоса без числа отвечает
«примерно столько», а на вопрос «сколько ещё ждать» отвечает
только число.
-->
<template #cell-progress="{ row }">
<span class="screen__progress">
<GrProgressBar
:value="percent(row as DataJob)"
:tone="row.state === 'partial' ? 'warning' : 'primary'"
:aria-label="`${row.subject}`"
size="sm"
/>
<span class="screen__percent">{{ percent(row as DataJob) }}%</span>
</span>
</template>
</GrDataTable>
</div>
</GrCard>
<GrCard class="screen__panel">
<p v-if="!selected" class="screen__hint">{{ data.details.empty }}</p>
<template v-else>
<template v-if="selected.state === 'running'">
<GrSteps :model-value="stageOf(selected)" :steps="data.details.stages" variant="compact" />
<GrProgressBar
:value="percent(selected)"
:aria-label="selected.subject"
/>
<!-- То, что обязано быть написано до нажатия «Остановить». -->
<p class="screen__hint">{{ data.survives }}</p>
<GrButton variant="outline" tone="danger" @click="stopping = true">
{{ data.details.cancel }}
</GrButton>
</template>
<GrAlert
v-else-if="selected.state === 'partial'"
tone="warning"
:title="data.partialTitle"
:description="data.partialLead"
/>
<GrDescriptionList :items="facts" label-width="11rem" divided />
<!--
Отказ становится действием: видно, какие строки, почему, и что с
ними делать. «7 строк не прошли» без этого — не сообщение, а
уведомление о том, что где-то есть проблема.
-->
<template v-if="selected.failures.length">
<p class="screen__failuresTitle">
{{ selected.failures.length }} {{ plural(selected.failures.length, data.rowForms) }}
</p>
<div class="screen__scroll">
<GrDataTable :rows="selected.failures" :columns="failureColumns" row-key="row" />
</div>
<div class="screen__failureActions">
<GrButton size="sm">{{ data.details.retry }}</GrButton>
<GrButton size="sm" variant="outline">{{ data.details.download }}</GrButton>
</div>
</template>
</template>
</GrCard>
</div>
<GrConfirmDialog
v-model="stopping"
:title="data.details.cancelTitle"
:description="data.details.cancelDescription"
:confirm-text="data.details.cancelConfirm"
:cancel-text="data.details.cancelKeep"
confirm-tone="danger"
@confirm="confirmStop"
/>
</div>
</template>
<style scoped>
.screen {
display: grid;
align-content: start;
gap: var(--gr-space-4);
min-height: 100dvh;
padding: var(--gr-space-4);
background: var(--gr-bg);
color: var(--gr-fg);
}
.screen__brand {
margin: 0;
color: var(--gr-muted-fg);
font-size: var(--gr-text-sm);
}
.screen__title {
margin: var(--gr-space-1) 0 0;
font-size: var(--gr-text-xl);
font-weight: 600;
}
.screen__subtitle {
margin: var(--gr-space-1) 0 0;
color: var(--gr-muted-fg);
font-size: var(--gr-text-sm);
}
.screen__actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--gr-space-3);
}
.screen__hint {
margin: 0;
color: var(--gr-muted-fg);
font-size: var(--gr-text-xs);
}
.screen__split {
display: grid;
gap: var(--gr-space-3);
}
.screen__panel {
display: grid;
align-content: start;
gap: var(--gr-space-4);
padding: var(--gr-space-4);
}
.screen__scroll {
overflow-x: auto;
}
.screen__link {
background: none;
border: 0;
padding: 0;
color: var(--gr-primary-text);
font: inherit;
text-align: start;
text-decoration: underline;
text-decoration-thickness: from-font;
cursor: pointer;
}
.screen__progress {
display: flex;
align-items: center;
gap: var(--gr-space-2);
min-width: 8rem;
}
.screen__percent {
font-variant-numeric: tabular-nums;
font-size: var(--gr-text-xs);
color: var(--gr-muted-fg);
}
.screen__failuresTitle {
margin: 0;
font-weight: 600;
font-size: var(--gr-text-sm);
}
.screen__failureActions {
display: flex;
flex-wrap: wrap;
gap: var(--gr-space-2);
}
@media (min-width: 768px) {
.screen {
padding: var(--gr-space-5);
}
}
@media (min-width: 1200px) {
.screen__split {
grid-template-columns: minmax(0, 3fr) minmax(0, 2fr);
align-items: start;
}
}
</style>