Compare commits

...
1 Commits
Author SHA1 Message Date
DenozordecandCursor 869b13cb57 fix(web): open module create in FormDrawer
quality / changes (push) Successful in 8s
quality / go (push) Skipped
quality / bird2 (push) Skipped
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 22s
quality / web (push) Successful in 56s
CD / quality (push) Successful in 1m30s
CD / publish (push) Successful in 2m53s
Создание модуля перенесено в боковую Sheet, как community и редактирование. /modules/new и быстрые ссылки открывают список с ?create=true.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 22:09:41 +07:00
8 changed files with 347 additions and 407 deletions
@@ -167,7 +167,7 @@ export function DashboardModulesGrid({
description="Поиск, сортировка и быстрый переход к настройке"
className="min-w-0"
actions={
<Button variant="outline" size="sm" render={<Link to="/modules/new" />}>
<Button variant="outline" size="sm" render={<Link to="/modules" search={{ create: true }} />}>
<PlusIcon />
Создать
</Button>
@@ -16,7 +16,8 @@ const ACTIONS: QuickActionItem[] = [
id: 'new-module',
title: 'Создать модуль',
description: 'Новый модуль маршрутизации и источники префиксов.',
to: '/modules/new',
to: '/modules',
search: { create: true },
icon: <Plus aria-hidden />,
iconClassName: 'text-primary',
},
@@ -128,7 +128,7 @@ export function LookupAddStep({
<AlertTitle>Нет подходящего модуля</AlertTitle>
<AlertDescription>
Создайте модуль типа {wantedType}, затем повторите добавление.{' '}
<Button variant="link" size="sm" className="h-auto p-0" render={<Link to="/modules/new" />}>
<Button variant="link" size="sm" className="h-auto p-0" render={<Link to="/modules" search={{ create: true }} />}>
Перейти к модулям
</Button>
</AlertDescription>
@@ -0,0 +1,289 @@
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { Button } from '@evobgp/ui/components/button'
import { Checkbox } from '@evobgp/ui/components/checkbox'
import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label'
import { FormDrawer } from '@/components/form-drawer'
import { LoadingButton } from '@/components/loading-button'
import { CommunitySelect } from '@/components/modules/community-select'
import { SelectField } from '@/components/select-field'
import { dohProfileShortLabel } from '@/lib/modules/helpers'
import { dohPolicyRu, moduleTypeRu } from '@/lib/ui-labels'
import { useCreateModuleMutation } from '@/queries/modules'
import type {
BgpCommunity,
DohProfile,
DohResolverPolicy,
ModuleCreate,
ModuleRow,
ModuleType,
} from '@/types/api'
interface ModuleCreateDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
communities: BgpCommunity[]
dohProfiles: DohProfile[]
onCreated?: (mod: ModuleRow) => void
}
/** @see https://reui.io/preview/base/form-7 */
/** @see https://reui.io/preview/base/sheet-8 */
const MODULE_TYPE_ITEMS: { value: ModuleType; label: string }[] = [
{ value: 'IP_RANGES', label: moduleTypeRu('IP_RANGES') },
{ value: 'AS_PREFIXES', label: moduleTypeRu('AS_PREFIXES') },
{ value: 'CDN_CIDRS', label: moduleTypeRu('CDN_CIDRS') },
{ value: 'DOMAINS', label: moduleTypeRu('DOMAINS') },
]
const DOH_POLICY_ITEMS: { value: DohResolverPolicy; label: string }[] = [
{ value: 'primary_only', label: dohPolicyRu('primary_only') },
{ value: 'failover', label: dohPolicyRu('failover') },
{ value: 'union', label: dohPolicyRu('union') },
]
export function ModuleCreateDialog({
open,
onOpenChange,
communities,
dohProfiles,
onCreated,
}: ModuleCreateDialogProps) {
const createMutation = useCreateModuleMutation()
const [type, setType] = useState<ModuleType>('IP_RANGES')
const [name, setName] = useState('')
const [enabled, setEnabled] = useState(true)
const [priority, setPriority] = useState('0')
const [refreshIntervalSec, setRefreshIntervalSec] = useState('')
const [cronExpr, setCronExpr] = useState('')
const [defaultCommunityId, setDefaultCommunityId] = useState<string | null>(null)
const [dohResolverPolicy, setDohResolverPolicy] = useState<DohResolverPolicy>('primary_only')
const [dohProfileIds, setDohProfileIds] = useState<string[]>([])
const isDomains = type === 'DOMAINS'
useEffect(() => {
if (!open) return
setType('IP_RANGES')
setName('')
setEnabled(true)
setPriority('0')
setRefreshIntervalSec('')
setCronExpr('')
setDefaultCommunityId(null)
setDohResolverPolicy('primary_only')
setDohProfileIds([])
}, [open])
function toggleDohProfile(id: string, checked: boolean) {
setDohProfileIds((prev) => {
if (checked) {
if (prev.includes(id)) return prev
return [...prev, id]
}
return prev.filter((x) => x !== id)
})
}
async function save() {
const trimmedName = name.trim()
if (!trimmedName) {
toast.error('Укажите название модуля')
return
}
const priorityNum = Number(priority)
if (!Number.isFinite(priorityNum) || !Number.isInteger(priorityNum)) {
toast.error('Приоритет должен быть целым числом')
return
}
let refresh: number | undefined
if (refreshIntervalSec.trim() !== '') {
const n = Number(refreshIntervalSec)
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0) {
toast.error('Интервал обновления должен быть целым числом ≥ 0')
return
}
refresh = n
}
const body: ModuleCreate = {
type,
name: trimmedName,
enabled,
priority: priorityNum,
}
if (refresh !== undefined) {
body.refresh_interval_sec = refresh
}
const cron = cronExpr.trim()
if (cron) {
body.cron_expr = cron
}
if (defaultCommunityId) {
body.default_community_id = defaultCommunityId
}
if (isDomains) {
body.doh_resolver_policy = dohResolverPolicy
body.doh_profile_ids = dohProfileIds
}
try {
const created = await createMutation.mutateAsync(body)
onOpenChange(false)
onCreated?.(created)
} catch {
// toast in mutation
}
}
return (
<FormDrawer
open={open}
onOpenChange={onOpenChange}
title="Новый модуль"
description="Тип задаётся один раз. Записи добавляются на карточке модуля."
className="sm:max-w-md"
footer={
<>
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
Отмена
</Button>
<LoadingButton type="button" loading={createMutation.isPending} onClick={() => void save()}>
Создать
</LoadingButton>
</>
}
>
<SelectField
id="mod-create-type"
label="Тип"
items={MODULE_TYPE_ITEMS}
value={type}
onValueChange={(v) => {
if (v) setType(v)
}}
/>
<div className="flex flex-col gap-2">
<Label htmlFor="mod-create-name">Название</Label>
<Input
id="mod-create-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Имя модуля"
/>
</div>
<div className="flex flex-row items-center justify-between gap-4 rounded-lg border border-border bg-muted/30 p-3">
<div className="grid min-w-0 flex-1 gap-1 pr-2">
<Label htmlFor="mod-create-enabled">Включён</Label>
<p className="text-xs text-muted-foreground">
Выключенный модуль не участвует в обновлении и применении.
</p>
</div>
<Checkbox
id="mod-create-enabled"
checked={enabled}
onCheckedChange={(v) => setEnabled(v === true)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="mod-create-priority">Приоритет</Label>
<Input
id="mod-create-priority"
type="number"
value={priority}
onChange={(e) => setPriority(e.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="mod-create-interval">Интервал обновления (сек)</Label>
<Input
id="mod-create-interval"
type="number"
min={0}
placeholder="пусто = по умолчанию"
value={refreshIntervalSec}
onChange={(e) => setRefreshIntervalSec(e.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="mod-create-cron">Cron (опционально)</Label>
<Input
id="mod-create-cron"
placeholder="0 * * * *"
value={cronExpr}
onChange={(e) => setCronExpr(e.target.value)}
/>
</div>
<CommunitySelect
id="mod-create-community"
label="Community по умолчанию"
value={defaultCommunityId}
onValueChange={setDefaultCommunityId}
communities={communities}
nullable
/>
{isDomains ? (
<>
<SelectField
id="mod-create-doh-policy"
label="Политика DoH"
items={DOH_POLICY_ITEMS}
value={dohResolverPolicy}
onValueChange={(v) => {
if (v) setDohResolverPolicy(v)
}}
/>
<div className="flex flex-col gap-2">
<Label>DoH профили</Label>
{dohProfiles.length === 0 ? (
<p className="text-muted-foreground text-sm">Нет профилей в справочнике</p>
) : (
<div className="flex flex-col gap-2 rounded-lg border border-border p-3">
{dohProfiles.map((p) => {
const checked = dohProfileIds.includes(p.id)
return (
<label
key={p.id}
htmlFor={`mod-create-doh-${p.id}`}
className="flex cursor-pointer items-start gap-3"
>
<Checkbox
id={`mod-create-doh-${p.id}`}
checked={checked}
onCheckedChange={(v) => toggleDohProfile(p.id, v === true)}
className="mt-0.5"
/>
<span className="flex min-w-0 flex-col gap-0.5">
<span className="text-sm font-medium">
{dohProfileShortLabel(p.id, dohProfiles)}
</span>
<span className="text-muted-foreground truncate text-xs" title={p.url}>
{p.url}
</span>
</span>
</label>
)
})}
</div>
)}
</div>
</>
) : null}
</FormDrawer>
)
}
@@ -1,393 +0,0 @@
import { Link, useNavigate } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { ArrowLeft, ShieldOff } from 'lucide-react'
import { useState } from 'react'
import { toast } from 'sonner'
import { Button } from '@evobgp/ui/components/button'
import { Checkbox } from '@evobgp/ui/components/checkbox'
import { Input } from '@evobgp/ui/components/input'
import { Switch } from '@evobgp/ui/components/switch'
import {
ToggleGroup,
ToggleGroupItem,
} from '@evobgp/ui/components/toggle-group'
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
import {
NumberField,
NumberFieldDecrement,
NumberFieldGroup,
NumberFieldIncrement,
NumberFieldInput,
} from '@/components/reui/number-field'
import { LoadingButton } from '@/components/loading-button'
import { CommunitySelect } from '@/components/modules/community-select'
import { PageHeader } from '@/components/page-header'
import { PageShell } from '@/components/page-shell'
import { SelectMenu } from '@/components/select-field'
import { SettingRow } from '@/components/settings/setting-row'
import { SettingsCard } from '@/components/settings/settings-card'
import { SettingsFieldGroup } from '@/components/settings/settings-field-group'
import { sessionCanWriteModules } from '@/lib/auth'
import { dohProfileShortLabel } from '@/lib/modules/helpers'
import { dohPolicyRu, moduleTypeRu } from '@/lib/ui-labels'
import { authSessionQueryOptions } from '@/queries/auth'
import {
directoriesCommunitiesQueryOptions,
directoriesDohQueryOptions,
} from '@/queries/directories'
import { useCreateModuleMutation } from '@/queries/modules'
import type { DohResolverPolicy, ModuleCreate, ModuleType } from '@/types/api'
/**
* Create module — settings-3 / settings-16 Frame rows.
* @see https://reui.io/preview/base/settings-3
* @see https://reui.io/preview/base/settings-16
* @see https://reui.io/docs/components/base/frame
* @see https://reui.io/docs/components/base/number-field
*/
const MODULE_TYPES: ModuleType[] = ['IP_RANGES', 'AS_PREFIXES', 'CDN_CIDRS', 'DOMAINS']
const MODULE_TYPE_HINT: Record<ModuleType, string> = {
IP_RANGES: 'Статические префиксы. Тип после создания не меняется.',
AS_PREFIXES: 'Префиксы по номерам AS (RIPEstat). Тип после создания не меняется.',
CDN_CIDRS: 'CIDR-списки с URL-источников. Тип после создания не меняется.',
DOMAINS: 'FQDN → префиксы через DoH. Тип после создания не меняется.',
}
const DOH_POLICY_ITEMS: { value: DohResolverPolicy; label: string }[] = [
{ value: 'primary_only', label: dohPolicyRu('primary_only') },
{ value: 'failover', label: dohPolicyRu('failover') },
{ value: 'union', label: dohPolicyRu('union') },
]
export function ModuleCreateForm() {
const navigate = useNavigate()
const sessionQ = useQuery(authSessionQueryOptions())
const canWrite = sessionCanWriteModules(sessionQ.data)
const communitiesQ = useQuery(directoriesCommunitiesQueryOptions())
const dohQ = useQuery(directoriesDohQueryOptions())
const createMutation = useCreateModuleMutation()
const [type, setType] = useState<ModuleType>('IP_RANGES')
const [name, setName] = useState('')
const [enabled, setEnabled] = useState(true)
const [priority, setPriority] = useState(0)
const [refreshIntervalSec, setRefreshIntervalSec] = useState('')
const [cronExpr, setCronExpr] = useState('')
const [defaultCommunityId, setDefaultCommunityId] = useState<string | null>(null)
const [dohResolverPolicy, setDohResolverPolicy] = useState<DohResolverPolicy>('primary_only')
const [dohProfileIds, setDohProfileIds] = useState<string[]>([])
const isDomains = type === 'DOMAINS'
const communities = communitiesQ.data?.items ?? []
const dohProfiles = dohQ.data?.items ?? []
function toggleDohProfile(id: string, checked: boolean) {
setDohProfileIds((prev) => {
if (checked) {
if (prev.includes(id)) return prev
return [...prev, id]
}
return prev.filter((x) => x !== id)
})
}
async function submit() {
const trimmedName = name.trim()
if (!trimmedName) {
toast.error('Укажите название модуля')
return
}
if (!Number.isInteger(priority)) {
toast.error('Приоритет должен быть целым числом')
return
}
let refresh: number | undefined
if (refreshIntervalSec.trim() !== '') {
const n = Number(refreshIntervalSec)
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0) {
toast.error('Интервал обновления должен быть целым числом ≥ 0')
return
}
refresh = n
}
const body: ModuleCreate = {
type,
name: trimmedName,
enabled,
priority,
}
if (refresh !== undefined) {
body.refresh_interval_sec = refresh
}
const cron = cronExpr.trim()
if (cron) {
body.cron_expr = cron
}
if (defaultCommunityId) {
body.default_community_id = defaultCommunityId
}
if (isDomains) {
body.doh_resolver_policy = dohResolverPolicy
body.doh_profile_ids = dohProfileIds
}
try {
const created = await createMutation.mutateAsync(body)
await navigate({ to: '/modules/$moduleId', params: { moduleId: created.id } })
} catch {
// toast in mutation
}
}
const back = (
<Button variant="outline" size="sm" render={<Link to="/modules" />}>
<ArrowLeft />
К списку
</Button>
)
if (sessionQ.isPending) {
return (
<PageShell className="mx-auto w-full max-w-3xl">
<PageHeader title="Новый модуль" description="Создание маршрутного списка" actions={back} />
</PageShell>
)
}
if (!canWrite) {
return (
<PageShell>
<PageHeader
title="Новый модуль"
description="Создание маршрутного списка"
actions={back}
/>
<Alert variant="warning">
<ShieldOff aria-hidden />
<AlertTitle>Недостаточно прав</AlertTitle>
<AlertDescription>
Нужно право bgp:modules:write, чтобы создавать модули.
</AlertDescription>
</Alert>
</PageShell>
)
}
return (
<PageShell className="mx-auto w-full max-w-3xl">
<PageHeader
title="Новый модуль"
description="Тип задаётся один раз при создании. Записи (AS, домены, CIDR) добавляются на карточке модуля."
actions={back}
/>
<SettingsCard
title="Параметры"
description="Основные поля. Тип после сохранения изменить нельзя."
footer={
<>
<Button variant="outline" type="button" render={<Link to="/modules" />}>
Отмена
</Button>
<LoadingButton
type="button"
loading={createMutation.isPending}
onClick={() => void submit()}
>
Создать
</LoadingButton>
</>
}
>
<SettingsFieldGroup
legend="Создание модуля"
description="Тип, название и расписание нового маршрутного списка."
>
<SettingRow
title="Тип"
description={MODULE_TYPE_HINT[type]}
stacked
labelFor="mod-create-type"
>
<ToggleGroup
multiple={false}
value={[type]}
onValueChange={(value) => {
if (value.length > 0) setType(value[0] as ModuleType)
}}
variant="outline"
size="sm"
aria-label="Тип модуля"
className="flex w-full min-w-0 flex-wrap"
>
{MODULE_TYPES.map((value) => (
<ToggleGroupItem key={value} value={value} className="gap-1.5">
{moduleTypeRu(value)}
</ToggleGroupItem>
))}
</ToggleGroup>
</SettingRow>
<SettingRow
title="Название"
description="Короткое имя в списках и на карточке."
labelFor="mod-create-name"
stacked
>
<Input
id="mod-create-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Например: static-ru"
className="w-full min-w-0"
/>
</SettingRow>
<SettingRow
title="Включён"
description="Выключенный модуль не участвует в обновлении и применении."
>
<Switch
checked={enabled}
onCheckedChange={setEnabled}
aria-label="Модуль включён"
/>
</SettingRow>
<SettingRow
title="Приоритет"
description="Меньше значение — выше приоритет при агрегации."
labelFor="mod-create-priority"
>
<NumberField
id="mod-create-priority"
value={priority}
onValueChange={(next) => {
if (typeof next === 'number' && Number.isFinite(next)) {
setPriority(Math.trunc(next))
}
}}
step={1}
className="w-full max-w-40"
>
<NumberFieldGroup>
<NumberFieldDecrement />
<NumberFieldInput />
<NumberFieldIncrement />
</NumberFieldGroup>
</NumberField>
</SettingRow>
<SettingRow
title="Интервал обновления"
description="Секунды. Пусто — без периодического ingest по интервалу."
labelFor="mod-create-interval"
>
<Input
id="mod-create-interval"
type="number"
min={0}
placeholder="пусто"
value={refreshIntervalSec}
onChange={(e) => setRefreshIntervalSec(e.target.value)}
className="w-full max-w-40"
/>
</SettingRow>
<SettingRow
title="Cron"
description="Опциональное расписание планировщика, например 0 * * * *."
labelFor="mod-create-cron"
>
<Input
id="mod-create-cron"
placeholder="0 * * * *"
value={cronExpr}
onChange={(e) => setCronExpr(e.target.value)}
className="w-full min-w-0"
/>
</SettingRow>
<SettingRow
title="Community по умолчанию"
description="Подставляется в записи без своей community."
labelFor="mod-create-community"
last={!isDomains}
>
<CommunitySelect
id="mod-create-community"
value={defaultCommunityId}
onValueChange={setDefaultCommunityId}
communities={communities}
nullable
/>
</SettingRow>
{isDomains ? (
<>
<SettingRow
title="Политика DoH"
description="Как выбирать профили при нескольких URL."
labelFor="mod-create-doh-policy"
>
<SelectMenu
id="mod-create-doh-policy"
items={DOH_POLICY_ITEMS}
value={dohResolverPolicy}
onValueChange={(v) => {
if (v) setDohResolverPolicy(v)
}}
/>
</SettingRow>
<SettingRow
title="DoH профили"
description="Упорядоченный список. Пусто — резолв без профилей модуля."
stacked
last
>
{dohProfiles.length === 0 ? (
<p className="text-muted-foreground text-sm">Нет профилей в справочнике</p>
) : (
<div className="flex w-full min-w-0 flex-col gap-2">
{dohProfiles.map((p) => {
const checked = dohProfileIds.includes(p.id)
return (
<label
key={p.id}
htmlFor={`mod-create-doh-${p.id}`}
className="flex cursor-pointer items-start gap-3"
>
<Checkbox
id={`mod-create-doh-${p.id}`}
checked={checked}
onCheckedChange={(v) => toggleDohProfile(p.id, v === true)}
className="mt-0.5"
/>
<span className="flex min-w-0 flex-col gap-0.5">
<span className="text-sm font-medium">
{dohProfileShortLabel(p.id, dohProfiles)}
</span>
<span className="text-muted-foreground truncate text-xs" title={p.url}>
{p.url}
</span>
</span>
</label>
)
})}
</div>
)}
</SettingRow>
</>
) : null}
</SettingsFieldGroup>
</SettingsCard>
</PageShell>
)
}
@@ -1,19 +1,24 @@
import { Link } from '@tanstack/react-router'
import { Boxes, Plus } from 'lucide-react'
import { IllustratedEmptyState } from '@/components/patterns/illustrated-empty-state'
import { Button } from '@evobgp/ui/components/button'
/** empty-state-3 pattern for first module. */
export function ProjectsEmptyState({ canCreate = true }: { canCreate?: boolean }) {
export function ProjectsEmptyState({
canCreate = true,
onCreate,
}: {
canCreate?: boolean
onCreate?: () => void
}) {
return (
<IllustratedEmptyState
icon={Boxes}
title="Создайте первый модуль"
description="Модули задают источники префиксов: AS, CDN, домены и IP-диапазоны."
action={
canCreate ? (
<Button size="sm" render={<Link to="/modules/new" />}>
canCreate && onCreate ? (
<Button size="sm" onClick={onCreate}>
<Plus />
Новый модуль
</Button>
+41 -3
View File
@@ -1,4 +1,4 @@
import { Link, createFileRoute } from '@tanstack/react-router'
import { createFileRoute } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { Plus, RefreshCw } from 'lucide-react'
@@ -6,22 +6,45 @@ import { Button } from '@evobgp/ui/components/button'
import { FrameDataGrid } from '@/components/reui-kit'
import { ProjectsEmptyState } from '@/components/patterns/projects-empty-state'
import { ModuleCreateDialog } from '@/components/modules/module-create-dialog'
import { ModulesListGrid } from '@/components/modules/modules-list-grid'
import { PageHeader } from '@/components/page-header'
import { QueryState } from '@/components/query-state'
import { TableSkeleton } from '@/components/skeletons'
import { sessionCanWriteModules } from '@/lib/auth'
import { authSessionQueryOptions } from '@/queries/auth'
import {
directoriesCommunitiesQueryOptions,
directoriesDohQueryOptions,
} from '@/queries/directories'
import { modulesListQueryOptions } from '@/queries/modules'
function parseCreateFlag(value: unknown): boolean {
return value === true || value === '1' || value === 'true'
}
export const Route = createFileRoute('/_auth/modules/')({
component: ModulesListComponent,
validateSearch: (search: Record<string, unknown>): { create?: boolean } => {
if (parseCreateFlag(search.create)) return { create: true }
return {}
},
})
function ModulesListComponent() {
const { create } = Route.useSearch()
const navigate = Route.useNavigate()
const query = useQuery(modulesListQueryOptions())
const sessionQ = useQuery(authSessionQueryOptions())
const canWrite = sessionCanWriteModules(sessionQ.data)
const communitiesQ = useQuery(directoriesCommunitiesQueryOptions())
const dohQ = useQuery(directoriesDohQueryOptions())
const createOpen = canWrite && create === true
function setCreateOpen(open: boolean) {
void navigate({ search: open ? { create: true } : {}, replace: true })
}
return (
<div className="flex flex-col gap-6">
@@ -47,7 +70,7 @@ function ModulesListComponent() {
title="Все модули"
actions={
canWrite ? (
<Button size="sm" render={<Link to="/modules/new" />}>
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus />
Создать
</Button>
@@ -60,7 +83,12 @@ function ModulesListComponent() {
isError={query.isError}
error={query.error}
empty={query.data?.items?.length === 0}
emptyContent={<ProjectsEmptyState canCreate={canWrite} />}
emptyContent={
<ProjectsEmptyState
canCreate={canWrite}
onCreate={() => setCreateOpen(true)}
/>
}
skeleton={<TableSkeleton rows={6} cols={5} />}
onRetry={() => query.refetch()}
>
@@ -72,6 +100,16 @@ function ModulesListComponent() {
)}
</QueryState>
</FrameDataGrid>
<ModuleCreateDialog
open={createOpen}
onOpenChange={setCreateOpen}
communities={communitiesQ.data?.items ?? []}
dohProfiles={dohQ.data?.items ?? []}
onCreated={(mod) => {
void navigate({ to: '/modules/$moduleId', params: { moduleId: mod.id } })
}}
/>
</div>
)
}
+4 -4
View File
@@ -1,7 +1,7 @@
import { createFileRoute } from '@tanstack/react-router'
import { ModuleCreateForm } from '@/components/modules/module-create-form'
import { createFileRoute, redirect } from '@tanstack/react-router'
export const Route = createFileRoute('/_auth/modules/new')({
component: ModuleCreateForm,
beforeLoad: () => {
throw redirect({ to: '/modules', search: { create: true } })
},
})