Управление доступом
Админка, где действуют над записями: фильтр, выбор, правка — и честный ответ, к чему выбор относится.
Что он доказывает
Дашборд отвечает на вопрос «что происходит». Этот экран — на вопрос «что я с этим сделаю», и всё трудное в нём живёт в действиях, а не в раскладке.
Он работает, а не изображает работу: поиск действительно отбирает, фильтр по роли действительно сужает, выбор действительно считается, а пустое состояние действительно наступает, когда под условие не подходит никто. Пример, в котором поле ввода ничего не делает, учит ровно обратному тому, чему должен.
Ошибка, вокруг которой он построен
Отметьте двенадцать строк. Сузьте фильтр до трёх. Нажмите «приостановить». Кого вы только что приостановили?
В большинстве админок — все двенадцать, включая девять, которых на экране больше нет. Выбор переживает фильтр молча, и интерфейс об этом не говорит. Здесь счётчик пересекается с видимым списком, а рядом с числом стоит фраза о том, к чему выбор относится. Ни одна из половин не украшение: число без фразы по-прежнему оставляет читателя гадать, какие именно двенадцать.
Почему панель, а не модальное окно
Правка доступа — задача побочная: посмотрел строку, поменял одно поле, вернулся к списку. Модальное окно забирает список, чтобы показать одну запись, и заставляет помнить, откуда пришёл. Боковая панель оставляет таблицу на виду.
Правило не в том, что «панели лучше». Оно в том, что модальное окно — для решения, которое обязано завершиться прежде, чем продолжится что-либо ещё: удаление, подтверждение, оплата. Смена роли — не тот случай.
Выключенное поле называет причину
Выбор роли у владельца выключен, и прямо под ним стоит причина: в пространстве без владельца некому вернуть доступ. Выключенный элемент без объяснения неотличим от сломанного, и следующее движение читателя — нажать посильнее.
«Пусто» и «ничего не нашлось» — разное
GrEmptyState здесь говорит «под фильтр никто не подходит», а не «людей пока
нет». Первое — про фильтр и лечится кнопкой; второе — про пространство и
лечится приглашением. Показать второе вместо первого значит отправить читателя
решать задачу, которой у него нет.
Использованные компоненты
Список строится из frontmatter и сверяется с исходником: компонент, добавленный в код и забытый в шапке, роняет гейт.
GrBreadcrumbsНавигацияGrInputФормыGrSelectФормыGrButtonДействияGrDataTableДанныеGrBadgeОбратная связьGrAvatarДанныеGrDropdownMenuСлоиGrEmptyStateОбратная связьGrDrawerСлоиGrFormFieldФормы
Пакеты:
@feugene/granularity
Точка входа
Тот же файл, который здесь исполняется, — examples/admin/App.vue. Отдельной копии кода не существует.
<script setup lang="ts">
/**
* `/examples/admin` — управление доступом, работа C2.
*
* Дашборд отвечает на вопрос «что происходит». Этот экран — на вопрос «что я с
* этим сделаю»: здесь действуют над записями, и всё трудное живёт в действиях,
* а не в раскладке.
*
* Экран **работает**, а не изображает работу: поиск и фильтр действительно
* отбирают строки, выбор действительно считается, пустое состояние
* действительно наступает. Пример, в котором поле ввода ничего не делает, учит
* ровно тому, чего делать не надо.
*
* Панель редактирования — боковая (`GrDrawer`), и она же показывает, почему
* экран живёт во фрейме: панель монтируется в `document.body`, и на странице
* примера без фрейма легла бы поверх документации (`adr/0016`).
*/
import { computed, ref } from 'vue'
import type { GrDataColumn } from '@feugene/granularity/components/GrDataTable'
import { GrDataTable } from '@feugene/granularity/components/GrDataTable'
import { GrBreadcrumbs } from '@feugene/granularity/components/GrBreadcrumbs'
import { GrInput } from '@feugene/granularity/components/GrInput'
import { GrSelect } from '@feugene/granularity/components/GrSelect'
import { GrButton } from '@feugene/granularity/components/GrButton'
import { GrBadge } from '@feugene/granularity/components/GrBadge'
import { GrAvatar } from '@feugene/granularity/components/GrAvatar'
import { GrDropdownMenu } from '@feugene/granularity/components/GrDropdownMenu'
import { GrEmptyState } from '@feugene/granularity/components/GrEmptyState'
import { GrDrawer } from '@feugene/granularity/components/GrDrawer'
import { GrFormField } from '@feugene/granularity/components/GrFormField'
import type { AdminData, Person } from './data'
const props = defineProps<{ data: AdminData }>()
const query = ref('')
const role = ref<Person['role'] | ''>('')
const selected = ref<Array<string | number>>([])
const editing = ref<Person | null>(null)
/** Тон бейджа состояния. Приглашение — не отказ, поэтому тонов три. */
const STATE_TONE = { active: 'success', invited: 'warning', suspended: 'danger' } as const
const rows = computed(() => {
const needle = query.value.trim().toLowerCase()
return props.data.people.filter((person) => {
if (role.value && person.role !== role.value)
return false
if (!needle)
return true
return person.name.toLowerCase().includes(needle) || person.email.toLowerCase().includes(needle)
})
})
/**
* Выбор не переживает фильтр.
*
* Классическая ошибка админки: отметили двенадцать строк, сузили фильтр,
* нажали «приостановить» — и действие уехало на тех, кого на экране уже нет.
* Здесь выбор всегда пересекается с видимым списком, и подпись рядом говорит
* об этом вслух.
*/
const visibleSelection = computed(() => selected.value.filter(id => rows.value.some(person => person.id === id)))
const roleOptions = computed(() => [
{ value: '', label: props.data.roleFilter.all },
...props.data.roleFilter.options,
])
const columns = computed<GrDataColumn[]>(() => {
const label = props.data.columns
return [
{ key: 'person', label: label.person, sortable: true },
{ key: 'role', label: label.role, sortable: true },
{ key: 'state', label: label.state },
{ key: 'team', label: label.team },
{ key: 'lastSeen', label: label.lastSeen },
{ key: 'actions', label: label.actions, align: 'right' },
]
})
/**
* Форма слова по числу.
*
* Правило — здесь, слова — в содержимом: русскому нужно три формы,
* английскому хватает двух, и различает их счёт, а не язык страницы.
*/
function plural(n: number): string {
const [one, few, many] = props.data.peopleForms
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 rowMenu = computed(() => [
{ key: 'edit', label: props.data.rowMenu.edit },
{ key: 'resend', label: props.data.rowMenu.resend },
{ type: 'divider' as const },
{ key: 'remove', label: props.data.rowMenu.remove, variant: 'danger' as const },
])
function reset() {
query.value = ''
role.value = ''
}
</script>
<template>
<div class="screen">
<header>
<GrBreadcrumbs :items="data.crumbs.map(label => ({ label }))" />
<h1 class="screen__title">{{ data.title }}</h1>
<p class="screen__subtitle">{{ data.subtitle }}</p>
</header>
<div class="screen__filters">
<GrInput v-model="query" :placeholder="data.search.placeholder" :aria-label="data.search.label" clearable>
<template #prefix>
<span class="i-lucide-search block h-4 w-4" aria-hidden="true" />
</template>
</GrInput>
<GrSelect v-model="role" :options="roleOptions" :aria-label="data.roleFilter.label" />
</div>
<!--
Панель массового действия появляется вместе с выбором и исчезает вместе с
ним. Постоянная строка с нулём выбранных занимала бы место круглые сутки
ради состояния, которое бывает изредка.
-->
<div v-if="visibleSelection.length" class="screen__bulk">
<strong>{{ data.bulk.selected }}: {{ visibleSelection.length }}</strong>
<span class="screen__scope">{{ data.bulk.scope }}</span>
<div class="screen__bulkActions">
<GrButton size="sm" variant="outline">{{ data.bulk.invite }}</GrButton>
<GrButton size="sm" variant="outline" tone="danger">{{ data.bulk.suspend }}</GrButton>
<GrButton size="sm" variant="ghost" @click="selected = []">{{ data.bulk.clear }}</GrButton>
</div>
</div>
<div class="screen__scroll">
<GrDataTable
v-if="rows.length"
v-model:selected="selected"
:rows="rows"
:columns="columns"
row-key="id"
selectable
initial-sort-key="person"
>
<template #cell-person="{ row }">
<div class="screen__person">
<GrAvatar :size="28" :alt="String(row.name)" />
<span>
<span class="screen__name">{{ row.name }}</span>
<span class="screen__email">{{ row.email }}</span>
</span>
</div>
</template>
<template #cell-role="{ row }">
{{ data.roleLabels[row.role as Person['role']] }}
</template>
<template #cell-state="{ row }">
<GrBadge size="sm" :tone="STATE_TONE[row.state as Person['state']]">
{{ data.stateLabels[row.state as Person['state']] }}
</GrBadge>
</template>
<template #cell-actions="{ row }">
<GrDropdownMenu
:items="rowMenu"
placement="bottom-end"
@select="item => item.key === 'edit' && (editing = row as Person)"
>
<!--
Кнопка идёт слотом `#trigger` и берёт `triggerProps`. Слот по
умолчанию — это **содержимое панели**, и кнопка, положенная туда,
уезжает внутрь закрытого меню: в таблице пусто, в разметке
элемент есть, ошибки нет. Поймал прогон — движок ролей не нашёл
кнопку, которую css-запрос находил.
Имя — про меню, а не про первый его пункт: «Изменить доступ: Ада»
обещало бы действие, а кнопка открывает список.
-->
<template #trigger="{ triggerProps }">
<GrButton
v-bind="triggerProps"
size="sm"
variant="ghost"
square
:aria-label="`${data.columns.actions}: ${row.name}`"
>
<span class="i-lucide-ellipsis-vertical block h-4 w-4" aria-hidden="true" />
</GrButton>
</template>
</GrDropdownMenu>
</template>
</GrDataTable>
<!--
Пустое состояние отличает «никого нет» от «никто не подошёл». Первое —
про пространство, второе — про фильтр, и лечится оно кнопкой, а не
приглашением людей.
-->
<GrEmptyState v-else :title="data.empty.title" :description="data.empty.description">
<GrButton size="sm" @click="reset">{{ data.empty.action }}</GrButton>
</GrEmptyState>
</div>
<p class="screen__total">{{ rows.length }} {{ plural(rows.length) }}</p>
<GrDrawer
:model-value="Boolean(editing)"
side="right"
size="md"
:title="`${data.drawer.titlePrefix} ${editing?.name ?? ''}`"
@update:model-value="value => !value && (editing = null)"
>
<div class="screen__form">
<GrFormField :label="data.drawer.role">
<GrSelect
:model-value="editing?.role"
:options="data.roleFilter.options"
:disabled="editing?.role === 'owner'"
:aria-label="data.drawer.role"
/>
</GrFormField>
<GrFormField :label="data.drawer.team">
<GrInput :model-value="editing?.team" :aria-label="data.drawer.team" />
</GrFormField>
<!-- Причина запрета названа рядом с запретом. Выключенное поле без
объяснения читается как поломка. -->
<p v-if="editing?.role === 'owner'" class="screen__note">{{ data.drawer.note }}</p>
</div>
<template #footer>
<div class="screen__drawerActions">
<GrButton variant="ghost" @click="editing = null">{{ data.drawer.cancel }}</GrButton>
<GrButton @click="editing = null">{{ data.drawer.save }}</GrButton>
</div>
</template>
</GrDrawer>
</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__title {
margin: var(--gr-space-3) 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__filters {
display: grid;
gap: var(--gr-space-3);
}
.screen__bulk {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--gr-space-3);
padding: var(--gr-space-3);
border: 1px solid var(--gr-brd);
border-radius: var(--gr-radius-md, 8px);
background: var(--gr-muted);
font-size: var(--gr-text-sm);
}
.screen__scope {
color: var(--gr-muted-fg);
}
.screen__bulkActions {
display: flex;
flex-wrap: wrap;
gap: var(--gr-space-2);
margin-inline-start: auto;
}
.screen__scroll {
overflow-x: auto;
}
.screen__person {
display: flex;
align-items: center;
gap: var(--gr-space-3);
}
.screen__name {
display: block;
}
.screen__email {
display: block;
color: var(--gr-muted-fg);
font-size: var(--gr-text-xs);
}
.screen__total {
margin: 0;
color: var(--gr-muted-fg);
font-size: var(--gr-text-sm);
}
.screen__form {
display: grid;
gap: var(--gr-space-4);
}
.screen__note {
margin: 0;
color: var(--gr-muted-fg);
font-size: var(--gr-text-sm);
}
.screen__drawerActions {
display: flex;
justify-content: flex-end;
gap: var(--gr-space-2);
}
@media (min-width: 768px) {
.screen {
padding: var(--gr-space-5);
}
.screen__filters {
grid-template-columns: minmax(0, 1fr) 220px;
}
}
</style>