forked from baron/baron-sso
common 정렬 헬퍼 공통화 및 devfront 목록 정렬 추가
This commit is contained in:
@@ -21,6 +21,12 @@ import {
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import {
|
||||
sortItems,
|
||||
toggleSort,
|
||||
type SortConfig,
|
||||
type SortResolverMap,
|
||||
} from "../../../../../common/core/utils";
|
||||
import { RoleGuard } from "../../../components/auth/RoleGuard";
|
||||
import { Badge } from "../../../components/ui/badge";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
@@ -82,10 +88,7 @@ import {
|
||||
const tenantCSVTemplate =
|
||||
"name,type,parent_tenant_slug,slug,memo,email_domain\n";
|
||||
|
||||
type SortConfig = {
|
||||
key: keyof TenantSummary | "recursiveMemberCount";
|
||||
direction: "asc" | "desc";
|
||||
};
|
||||
type TenantSortKey = keyof TenantSummary | "recursiveMemberCount";
|
||||
|
||||
const getTenantIcon = (type?: string) => {
|
||||
switch (type?.toUpperCase()) {
|
||||
@@ -225,7 +228,8 @@ function TenantListPage() {
|
||||
const [viewMode, setViewMode] = React.useState<"list" | "hierarchy">("list");
|
||||
const [selectedIds, setSelectedIds] = React.useState<string[]>([]);
|
||||
const [search, setSearch] = React.useState("");
|
||||
const [sortConfig, setSortConfig] = React.useState<SortConfig | null>(null);
|
||||
const [sortConfig, setSortConfig] =
|
||||
React.useState<SortConfig<TenantSortKey> | null>(null);
|
||||
const fileInputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const [importMessage, setImportMessage] = React.useState("");
|
||||
const [previewRows, setPreviewRows] = React.useState<
|
||||
@@ -363,6 +367,14 @@ function TenantListPage() {
|
||||
const allTenants = query.data?.items ?? [];
|
||||
const importParentOptionGroups =
|
||||
buildTenantImportParentOptionGroups(allTenants);
|
||||
const tenantSortResolvers = React.useMemo<
|
||||
SortResolverMap<TenantSummary & { recursiveMemberCount: number }, TenantSortKey>
|
||||
>(
|
||||
() => ({
|
||||
recursiveMemberCount: (tenant) => tenant.recursiveMemberCount,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
const tenants = React.useMemo(() => {
|
||||
// 1. Calculate recursive counts
|
||||
// buildTenantFullTree returns subTree which represents roots, but it also mutates the mapped nodes internally.
|
||||
@@ -396,38 +408,14 @@ function TenantListPage() {
|
||||
);
|
||||
}
|
||||
|
||||
if (sortConfig) {
|
||||
enriched.sort((a, b) => {
|
||||
const aValue = a[sortConfig.key as keyof typeof a];
|
||||
const bValue = b[sortConfig.key as keyof typeof b];
|
||||
return sortItems(enriched, sortConfig, tenantSortResolvers);
|
||||
}, [allTenants, search, sortConfig, tenantSortResolvers]);
|
||||
|
||||
if (aValue === bValue) return 0;
|
||||
if (aValue === null || aValue === undefined) return 1;
|
||||
if (bValue === null || bValue === undefined) return -1;
|
||||
|
||||
if (sortConfig.direction === "asc") {
|
||||
return aValue < bValue ? -1 : 1;
|
||||
}
|
||||
return aValue > bValue ? -1 : 1;
|
||||
});
|
||||
}
|
||||
|
||||
return enriched;
|
||||
}, [allTenants, search, sortConfig]);
|
||||
|
||||
const requestSort = (key: SortConfig["key"]) => {
|
||||
let direction: "asc" | "desc" = "asc";
|
||||
if (
|
||||
sortConfig &&
|
||||
sortConfig.key === key &&
|
||||
sortConfig.direction === "asc"
|
||||
) {
|
||||
direction = "desc";
|
||||
}
|
||||
setSortConfig({ key, direction });
|
||||
const requestSort = (key: TenantSortKey) => {
|
||||
setSortConfig((current) => toggleSort(current, key));
|
||||
};
|
||||
|
||||
const getSortIcon = (key: SortConfig["key"]) => {
|
||||
const getSortIcon = (key: TenantSortKey) => {
|
||||
if (!sortConfig || sortConfig.key !== key) {
|
||||
return <ArrowUpDown size={14} className="ml-1 opacity-50" />;
|
||||
}
|
||||
|
||||
@@ -50,6 +50,12 @@ import {
|
||||
TableRow,
|
||||
} from "../../components/ui/table";
|
||||
import { toast } from "../../components/ui/use-toast";
|
||||
import {
|
||||
sortItems,
|
||||
toggleSort,
|
||||
type SortConfig,
|
||||
type SortResolverMap,
|
||||
} from "../../../../common/core/utils";
|
||||
import {
|
||||
type UserSummary,
|
||||
bulkDeleteUsers,
|
||||
@@ -71,10 +77,7 @@ type UserSchemaField = {
|
||||
type: string;
|
||||
};
|
||||
|
||||
type SortConfig = {
|
||||
key: string;
|
||||
direction: "asc" | "desc";
|
||||
};
|
||||
type UserSortKey = string;
|
||||
|
||||
function UserListPage() {
|
||||
const navigate = useNavigate();
|
||||
@@ -86,7 +89,9 @@ function UserListPage() {
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
const [selectedUserIds, setSelectedUserIds] = React.useState<string[]>([]);
|
||||
const [sortConfig, setSortConfig] = React.useState<SortConfig | null>(null);
|
||||
const [sortConfig, setSortConfig] = React.useState<SortConfig<UserSortKey> | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const limit = 1000;
|
||||
const offset = (page - 1) * limit;
|
||||
@@ -219,60 +224,38 @@ function UserListPage() {
|
||||
: null;
|
||||
|
||||
const rawItems = query.data?.items ?? [];
|
||||
const userSortResolvers = React.useMemo<SortResolverMap<UserSummary, UserSortKey>>(
|
||||
() =>
|
||||
userSchema.reduce<SortResolverMap<UserSummary, UserSortKey>>(
|
||||
(accumulator, field) => ({
|
||||
...accumulator,
|
||||
[field.key]: (user) => {
|
||||
const value = user.metadata?.[field.key];
|
||||
return typeof value === "string" ||
|
||||
typeof value === "number" ||
|
||||
typeof value === "boolean"
|
||||
? value
|
||||
: null;
|
||||
},
|
||||
}),
|
||||
{
|
||||
name_email: (user) =>
|
||||
`${user.name ?? ""} ${user.email ?? ""} ${user.phone ?? ""}`,
|
||||
tenant_dept: (user) =>
|
||||
`${user.tenant?.name ?? user.tenantSlug ?? ""} ${user.department ?? ""}`,
|
||||
},
|
||||
),
|
||||
[userSchema],
|
||||
);
|
||||
const items = React.useMemo(() => {
|
||||
const sorted = [...rawItems];
|
||||
if (sortConfig) {
|
||||
sorted.sort((a, b) => {
|
||||
let aValue: string | number | boolean | null | undefined;
|
||||
let bValue: string | number | boolean | null | undefined;
|
||||
return sortItems(rawItems, sortConfig, userSortResolvers);
|
||||
}, [rawItems, sortConfig, userSortResolvers]);
|
||||
|
||||
if (sortConfig.key === "name_email") {
|
||||
aValue = a.name?.toLowerCase() || "";
|
||||
bValue = b.name?.toLowerCase() || "";
|
||||
} else if (sortConfig.key === "tenant_dept") {
|
||||
aValue =
|
||||
(a.tenant?.name || a.tenantSlug || "").toLowerCase() +
|
||||
(a.department || "").toLowerCase();
|
||||
bValue =
|
||||
(b.tenant?.name || b.tenantSlug || "").toLowerCase() +
|
||||
(b.department || "").toLowerCase();
|
||||
} else {
|
||||
aValue = (a as Record<string, unknown>)[sortConfig.key] as
|
||||
| string
|
||||
| number
|
||||
| boolean;
|
||||
bValue = (b as Record<string, unknown>)[sortConfig.key] as
|
||||
| string
|
||||
| number
|
||||
| boolean;
|
||||
}
|
||||
|
||||
if (aValue === bValue) return 0;
|
||||
if (aValue === null || aValue === undefined) return 1;
|
||||
if (bValue === null || bValue === undefined) return -1;
|
||||
|
||||
if (sortConfig.direction === "asc") {
|
||||
return aValue < bValue ? -1 : 1;
|
||||
}
|
||||
return aValue > bValue ? -1 : 1;
|
||||
});
|
||||
}
|
||||
return sorted;
|
||||
}, [rawItems, sortConfig]);
|
||||
|
||||
const requestSort = (key: SortConfig["key"]) => {
|
||||
let direction: "asc" | "desc" = "asc";
|
||||
if (
|
||||
sortConfig &&
|
||||
sortConfig.key === key &&
|
||||
sortConfig.direction === "asc"
|
||||
) {
|
||||
direction = "desc";
|
||||
}
|
||||
setSortConfig({ key, direction });
|
||||
const requestSort = (key: UserSortKey) => {
|
||||
setSortConfig((current) => toggleSort(current, key));
|
||||
};
|
||||
|
||||
const getSortIcon = (key: SortConfig["key"]) => {
|
||||
const getSortIcon = (key: UserSortKey) => {
|
||||
if (!sortConfig || sortConfig.key !== key) {
|
||||
return <ArrowUpDown size={14} className="ml-1 opacity-50" />;
|
||||
}
|
||||
|
||||
72
adminfront/src/lib/sort.test.ts
Normal file
72
adminfront/src/lib/sort.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
compareNullableValues,
|
||||
sortItems,
|
||||
toggleSort,
|
||||
type SortConfig,
|
||||
} from "../../../common/core/utils";
|
||||
|
||||
describe("shared sort helpers", () => {
|
||||
it("toggles sort direction for the same key", () => {
|
||||
expect(toggleSort<string>(null, "name")).toEqual({
|
||||
key: "name",
|
||||
direction: "asc",
|
||||
});
|
||||
|
||||
expect(
|
||||
toggleSort<string>({ key: "name", direction: "asc" }, "name"),
|
||||
).toEqual({
|
||||
key: "name",
|
||||
direction: "desc",
|
||||
});
|
||||
|
||||
expect(
|
||||
toggleSort<string>({ key: "name", direction: "desc" }, "status"),
|
||||
).toEqual({
|
||||
key: "status",
|
||||
direction: "asc",
|
||||
});
|
||||
});
|
||||
|
||||
it("compares nullable values with nulls last", () => {
|
||||
expect(compareNullableValues("a", "b", "asc")).toBeLessThan(0);
|
||||
expect(compareNullableValues("a", "b", "desc")).toBeGreaterThan(0);
|
||||
expect(compareNullableValues(null, "b", "asc")).toBeGreaterThan(0);
|
||||
expect(compareNullableValues("b", null, "asc")).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it("sorts items with resolver maps", () => {
|
||||
const items = [
|
||||
{
|
||||
id: "2",
|
||||
name: "Beta",
|
||||
metadata: { score: 2 },
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
name: "gamma",
|
||||
metadata: {},
|
||||
},
|
||||
{
|
||||
id: "1",
|
||||
name: "alpha",
|
||||
metadata: { score: 1 },
|
||||
},
|
||||
];
|
||||
|
||||
const nameSort: SortConfig<"name"> = { key: "name", direction: "asc" };
|
||||
expect(sortItems(items, nameSort).map((item) => item.id)).toEqual([
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
]);
|
||||
|
||||
const scoreSort: SortConfig<"score"> = { key: "score", direction: "asc" };
|
||||
expect(
|
||||
sortItems(items, scoreSort, {
|
||||
score: (item) =>
|
||||
typeof item.metadata.score === "number" ? item.metadata.score : null,
|
||||
}).map((item) => item.id),
|
||||
).toEqual(["1", "2", "3"]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user