Import and export

  • Working screens
  • components: 9
  • packages: 1

The first screen here where an action takes time, and the only one that admits a job can half-succeed.

Reviewed 2026-09-06

On this page
Full width

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.

Packages:

  • @feugene/granularity

Entry point

The same file that runs here — examples/data-management/App.vue. No second copy of the code exists.

examples/data-management/App.vue
<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>

Build this with Granularity