fix(ui): завершить основу таблиц и фильтров серверов
This commit is contained in:
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))
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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: "задано",
|
||||
}
|
||||
@@ -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 })
|
||||
}))
|
||||
}
|
||||
@@ -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" },
|
||||
|
||||
Reference in new issue
Block a user