fix(ui): завершить основу таблиц и фильтров серверов

This commit is contained in:
Denozordec committed 2026-09-13 14:46:17 +07:00
1 parent f9a4a07996
commit 3d3c45e4d2
24 files changed
+385 -263

No files matched your search

@@ -0,0 +1,39 @@
import assert from "node:assert/strict"
import { test } from "node:test"
import { applyFilterQuery } from "./apply-filter-query"
import { migrateFilterQuery } from "./migrate-filter-query"
import { applyReuiFilters } from "./apply-reui-filters"
import { createFilterQuery, createFilterRule } from "../../components/reui/filters/filters-query"
const rows = [{ country: "RU", status: "online" }, { country: "DE", status: "offline" }, { country: "NL", status: "offline" }]
const accessors = { country: (r: typeof rows[number]) => r.country, status: (r: typeof rows[number]) => r.status }
const rule = (field: string, operator: string, value?: unknown) => createFilterRule({ id: field, path: [field], operator, value })
test("scalar select supports multiple allowed values and AND with status", () => {
const query = createFilterQuery([rule("country", "is_any_of", ["RU", "DE"]), rule("status", "is", "offline")])
assert.deepEqual(applyFilterQuery(rows, query, accessors), [rows[1]])
})
test("nested OR preserves grouping and negation", () => {
const query = createFilterQuery([createFilterQuery([rule("country", "is", "RU"), rule("country", "is", "DE")], "or", "countries"), { ...rule("status", "is", "online"), negated: true }])
assert.deepEqual(applyFilterQuery(rows, query, accessors), [rows[1]])
})
test("drafts do not make OR true, an entirely empty query keeps the list", () => {
assert.deepEqual(applyFilterQuery(rows, createFilterQuery([rule("country", "is", "RU"), rule("status", "is")], "or"), accessors), [rows[0]])
assert.deepEqual(applyFilterQuery(rows, createFilterQuery(), accessors), rows)
})
test("unknown fields and operators never broaden the selection", () => {
assert.deepEqual(applyFilterQuery(rows, createFilterQuery([rule("country", "unsupported", "RU")]), accessors), [])
assert.deepEqual(applyFilterQuery(rows, createFilterQuery([rule("unknown", "is", "RU")]), accessors), [])
})
test("negative selection and empty values stay distinct", () => {
assert.deepEqual(applyFilterQuery(rows, createFilterQuery([rule("country", "is_none_of", ["RU", "NL"])]), accessors), [rows[1]])
assert.deepEqual(applyFilterQuery(rows, createFilterQuery([rule("country", "empty")]), accessors), [])
})
test("migration preserves the existing operators and implicit AND", () => {
for (const operator of ["is", "isNot", "isAnyOf", "isNotAnyOf", "contains", "doesNotContain"]) {
const filters = [{ id: "country", field: "country", operator, values: ["RU", "DE"] },
{ id: "status", field: "status", operator: "isNot", values: ["online"] }]
assert.deepEqual(applyFilterQuery(rows, migrateFilterQuery(filters), accessors), applyReuiFilters(rows, filters, accessors))
}
})
+42
View File
@@ -0,0 +1,42 @@
import type { FilterNode, FilterQuery, FilterRule } from "@/components/reui/filters/filters-types"
export type FilterAccessors<T> = Record<string, (row: T) => unknown>
/** Undefined is a draft, not a matching branch (especially inside OR). */
function matchRule<T>(row: T, rule: FilterRule, accessors: FilterAccessors<T>): boolean | undefined {
if (!rule.operator) return undefined
const accessor = accessors[rule.path.join(".")]
if (!accessor) return false
const actual = accessor(row)
const value = rule.value
const values = Array.isArray(value) ? value : value === undefined ? [] : [value]
const empty = actual == null || actual === "" || (Array.isArray(actual) && actual.length === 0)
if (!["empty", "not_empty"].includes(rule.operator) && (value === undefined || values.length === 0)) return undefined
const text = (v: unknown) => String(v ?? "").toLocaleLowerCase()
let result: boolean
switch (rule.operator) {
case "is": result = String(actual) === String(values[0]); break
case "is_not": result = String(actual) !== String(values[0]); break
case "is_any_of": result = values.some(v => String(actual) === String(v)); break
case "is_none_of": result = !values.some(v => String(actual) === String(v)); break
case "contains": result = values.some(v => text(actual).includes(text(v))); break
case "not_contains": result = !values.some(v => text(actual).includes(text(v))); break
case "empty": result = empty; break
case "not_empty": result = !empty; break
// Unsupported conditions must never silently widen the result set.
default: return false
}
return rule.negated ? !result : result
}
function matchNode<T>(row: T, node: FilterNode, accessors: FilterAccessors<T>): boolean | undefined {
if (node.type === "rule") return matchRule(row, node, accessors)
const results = node.rules.map(child => matchNode(row, child, accessors)).filter(value => value !== undefined)
if (results.length === 0) return undefined
return node.combinator === "and" ? results.every(Boolean) : results.some(Boolean)
}
/** Evaluate the tree recursively: flattening loses group combinators. */
export function applyFilterQuery<T>(rows: T[], query: FilterQuery, accessors: FilterAccessors<T>): T[] {
return rows.filter(row => matchNode(row, query, accessors) !== false)
}
+43
View File
@@ -0,0 +1,43 @@
import type { FilterLabels } from "@/components/reui/filters/filters-types"
export const FILTER_LABELS_RU: FilterLabels = {
addFilter: "Фильтры", advancedFilter: "Расширенные фильтры", showRecords: "Показать записи",
builderEmpty: "Фильтров пока нет", builderEmptyHint: "Добавьте условие, чтобы уточнить список.",
addCondition: "Добавить условие", addConditionGroup: "Добавить группу", addToGroup: "Добавить условие в группу",
removeGroup: "Удалить группу", wrapInGroup: "Объединить в группу", ungroup: "Разгруппировать",
moveToTopLevel: "На верхний уровень", moveToGroup: n => `В группу ${n}`,
reorder: "Изменить порядок", reorderHint: "Alt + стрелка вверх или вниз — изменить порядок",
groupAll: "Выполнены все условия", groupAny: "Выполнено любое условие", groupPlaceholder: "Перетащите условия сюда",
rowLabel: (condition, depth) => `${condition}, уровень ${depth}`,
groupLabel: (description, depth) => `${description}, уровень ${depth}`,
groupAnnouncement: added => added ? "Группа добавлена" : "Группа удалена",
reorderAnnouncement: (label, position, total) => `${label}: позиция ${position} из ${total}`,
moveAnnouncement: (label, destination, position, total) => `${label}: ${destination}, позиция ${position} из ${total}`,
clearAll: "Сбросить фильтры", groupMenu: "Действия с группой",
searchFields: "Поиск поля…", searchOperators: "Поиск условия…", searchOptions: "Поиск значения…",
back: "Назад", clear: "Очистить", apply: "Применить", discard: "Отменить изменения",
empty: "Ничего не найдено", loading: "Загрузка…", loadingMore: "Загрузка…", loadMore: "Загрузить ещё",
error: "Не удалось загрузить", retry: "Повторить", where: "Где", and: "И", or: "ИЛИ",
combinator: "Изменить связь условий", combinatorLabel: word => `${word}, изменить связь условий`,
duplicate: "Дублировать", negate: "Инвертировать", convertToAdvanced: "Расширенный редактор", remove: "Удалить",
chipMenu: field => `Действия с фильтром «${field}»`, filtersLabel: "Фильтры", filterLabel: condition => condition,
readOnly: "Фильтры доступны только для чтения.", pathSeparator: " > ", valuePlaceholder: "Введите значение…",
selectPlaceholder: "Выберите…", noValue: "нет значения", selectCondition: "Выберите условие",
incomplete: "незавершённый фильтр", branchAffordance: "открывает список",
exclusiveHint: "нельзя сочетать с другими значениями",
exclusiveAnnouncement: (label, cleared) => `Выбрано ${label}. Снято других значений: ${cleared}.`,
itemCount: count => `Элементов: ${count}`, fieldsLabel: "Поля",
resultsAnnouncement: count => `Найдено: ${count}`, actionsLabel: "Действия",
stepAnnouncement: (step, label) => `${step === "field" ? "Выберите поле" : step === "operator" ? "Выберите условие" : "Введите значение"}. ${label}`,
countAnnouncement: count => `Применено фильтров: ${count}`, valueCount: count => `Выбрано: ${count}`,
valueDetail: (summary, values) => `${summary}: ${values.join(", ")}`, valueRange: (from, to) => `${from} — ${to}`,
rangeFrom: field => `${field}: от`, rangeTo: field => `${field}: до`, rangeSeparator: "до",
negated: operator => `не (${operator})`, issueOperator: "Выберите условие", issueValue: "Введите значение",
issueRange: "Укажите обе границы", issueRangeOrder: "Конец диапазона меньше начала",
issueEmptyGroup: "В группе пока нет условий", issueSummary: count => `Требуют внимания: ${count}`,
}
export const FILTER_OPERATOR_LABELS_RU = {
is: "равно", is_not: "не равно", is_any_of: "любой из", is_none_of: "ни один из",
contains: "содержит", not_contains: "не содержит", empty: "не задано", not_empty: "задано",
}
+15
View File
@@ -0,0 +1,15 @@
import { createFilterQuery, createFilterRule } from "@/components/reui/filters/filters-query"
const OPERATORS: Record<string, string> = {
is: "is", isNot: "is_not", isAnyOf: "is_any_of", isNotAnyOf: "is_none_of",
contains: "contains", doesNotContain: "not_contains",
}
/** Temporary input contract, independent of the old UI component. */
export function migrateFilterQuery(filters: { id: string; field: string; operator: string; values: unknown[] }[]) {
return createFilterQuery(filters.map(filter => {
const operator = OPERATORS[filter.operator]
if (!operator) throw new Error(`Неподдерживаемое условие: ${filter.operator}`)
return createFilterRule({ id: filter.id, path: [filter.field], operator, value: filter.values })
}))
}
+8 -8
View File
@@ -1,11 +1,11 @@
import type { FilterFieldConfig } from "@/components/reui/filters"
import type { FilterField } from "@/components/reui/filters/filters-types"
import type { Server, ServerType } from "@/lib/data"
export const SERVER_FILTER_FIELDS: FilterFieldConfig[] = [
export const SERVER_FILTER_FIELDS: FilterField[] = [
{
key: "country",
id: "country",
label: "Страна",
type: "multiselect",
type: "select",
searchable: true,
options: [
{ value: "RU", label: "RU" },
@@ -17,9 +17,9 @@ export const SERVER_FILTER_FIELDS: FilterFieldConfig[] = [
],
},
{
key: "status",
id: "status",
label: "Статус",
type: "multiselect",
type: "select",
options: [
{ value: "online", label: "Online" },
{ value: "degraded", label: "Degraded" },
@@ -27,9 +27,9 @@ export const SERVER_FILTER_FIELDS: FilterFieldConfig[] = [
],
},
{
key: "type",
id: "type",
label: "Тип",
type: "multiselect",
type: "select",
options: [
{ value: "jump-host", label: "JumpHost" },
{ value: "exit-node", label: "Exit Node" },