첫 커밋: 로컬 프로젝트 업로드
This commit is contained in:
148
baron-sso/common/core/components/audit/AuditLogTable.test.tsx
Normal file
148
baron-sso/common/core/components/audit/AuditLogTable.test.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import { act } from "react-dom/test-utils";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CommonAuditLog } from "../../audit";
|
||||
import { AuditLogTable } from "./AuditLogTable";
|
||||
|
||||
const roots: Root[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
function renderTable(props: Parameters<typeof AuditLogTable>[0]) {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
roots.push(root);
|
||||
|
||||
act(() => {
|
||||
root.render(<AuditLogTable {...props} />);
|
||||
});
|
||||
|
||||
return { container };
|
||||
}
|
||||
|
||||
const logs: CommonAuditLog[] = [
|
||||
{
|
||||
event_id: "evt-1",
|
||||
timestamp: "2026-05-28T06:07:18.000Z",
|
||||
user_id: "user-1",
|
||||
event_type: "CLIENT_UPDATE",
|
||||
status: "success",
|
||||
ip_address: "127.0.0.1",
|
||||
user_agent: "Vitest",
|
||||
device_id: "device-1",
|
||||
details: JSON.stringify({
|
||||
request_id: "req-1",
|
||||
method: "POST",
|
||||
path: "/api/v1/clients",
|
||||
latency_ms: 120,
|
||||
tenant_id: "tenant-1",
|
||||
actor_id: "user-1",
|
||||
action: "업데이트",
|
||||
target_id: "client-a",
|
||||
before: { status: "inactive" },
|
||||
after: { status: "active" },
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
describe("AuditLogTable", () => {
|
||||
it("renders loading and empty states", () => {
|
||||
const { container: loadingContainer } = renderTable({
|
||||
logs: [],
|
||||
t: (key, fallback) => fallback ?? key,
|
||||
loading: true,
|
||||
hasNextPage: false,
|
||||
isFetchingNextPage: false,
|
||||
onLoadMore: vi.fn(),
|
||||
});
|
||||
|
||||
expect(loadingContainer.textContent).toContain("Loading audit logs...");
|
||||
|
||||
const { container: emptyContainer } = renderTable({
|
||||
logs: [],
|
||||
t: (key, fallback) => fallback ?? key,
|
||||
loading: false,
|
||||
hasNextPage: false,
|
||||
isFetchingNextPage: false,
|
||||
onLoadMore: vi.fn(),
|
||||
});
|
||||
|
||||
expect(emptyContainer.textContent).toContain("No audit logs found.");
|
||||
expect(emptyContainer.textContent).toContain("End of audit feed");
|
||||
});
|
||||
|
||||
it("renders rows, expands details, copies fields, and loads more", async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
value: { writeText },
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const onLoadMore = vi.fn();
|
||||
const { container } = renderTable({
|
||||
logs,
|
||||
t: (key, fallback, vars) => {
|
||||
let text = fallback ?? key;
|
||||
for (const [name, value] of Object.entries(vars ?? {})) {
|
||||
text = text.replaceAll(`{{${name}}}`, String(value));
|
||||
}
|
||||
return text;
|
||||
},
|
||||
loading: false,
|
||||
hasNextPage: true,
|
||||
isFetchingNextPage: false,
|
||||
onLoadMore,
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("user-1");
|
||||
expect(container.textContent).toContain("업데이트");
|
||||
expect(container.textContent).toContain("client-a");
|
||||
expect(container.textContent).toContain("success");
|
||||
|
||||
const buttons = Array.from(container.querySelectorAll("button"));
|
||||
const actorCopyButton = buttons.find(
|
||||
(button) => button.getAttribute("aria-label") === "Copy User ID",
|
||||
);
|
||||
const targetCopyButton = buttons.find(
|
||||
(button) => button.getAttribute("aria-label") === "Copy Client ID",
|
||||
);
|
||||
const expandButton = buttons.find(
|
||||
(button) => !button.getAttribute("aria-label") && !button.textContent,
|
||||
);
|
||||
const loadMoreButton = buttons.find(
|
||||
(button) => button.textContent === "Load more",
|
||||
);
|
||||
|
||||
expect(actorCopyButton).toBeTruthy();
|
||||
expect(targetCopyButton).toBeTruthy();
|
||||
expect(expandButton).toBeTruthy();
|
||||
expect(loadMoreButton).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
actorCopyButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
targetCopyButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expandButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith("user-1");
|
||||
expect(writeText).toHaveBeenCalledWith("client-a");
|
||||
expect(container.textContent).toContain("Request ID · req-1");
|
||||
expect(container.textContent).toContain("Actor");
|
||||
expect(container.textContent).toContain("Result");
|
||||
|
||||
await act(async () => {
|
||||
loadMoreButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(onLoadMore).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
409
baron-sso/common/core/components/audit/AuditLogTable.tsx
Normal file
409
baron-sso/common/core/components/audit/AuditLogTable.tsx
Normal file
@@ -0,0 +1,409 @@
|
||||
import { ChevronDown, ChevronUp, Copy } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import {
|
||||
getCommonBadgeClasses,
|
||||
type CommonBadgeVariant,
|
||||
} from "../../../ui/badge";
|
||||
import { getCommonButtonClasses } from "../../../ui/button";
|
||||
import {
|
||||
commonStickyTableHeaderClass,
|
||||
commonTableBodyClass,
|
||||
commonTableCellClass,
|
||||
commonTableClass,
|
||||
commonTableHeadClass,
|
||||
commonTableHeaderClass,
|
||||
commonTableRowClass,
|
||||
commonTableShellClass,
|
||||
commonTableViewportClass,
|
||||
commonTableWrapperClass,
|
||||
} from "../../../ui/table";
|
||||
import type { CommonAuditLog } from "../../audit";
|
||||
import {
|
||||
formatAuditDateParts,
|
||||
formatAuditValue,
|
||||
parseAuditDetails,
|
||||
resolveAuditAction,
|
||||
resolveAuditActor,
|
||||
resolveAuditTarget,
|
||||
} from "../../audit";
|
||||
|
||||
type AuditTranslate = (
|
||||
key: string,
|
||||
fallback: string,
|
||||
vars?: Record<string, string | number>,
|
||||
) => string;
|
||||
|
||||
type AuditLogTableProps = {
|
||||
logs: CommonAuditLog[];
|
||||
t: AuditTranslate;
|
||||
loading: boolean;
|
||||
hasNextPage: boolean;
|
||||
isFetchingNextPage: boolean;
|
||||
onLoadMore: () => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function cx(...classNames: Array<string | false | null | undefined>) {
|
||||
return classNames.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
function statusVariant(status: string): CommonBadgeVariant {
|
||||
switch (status.toLowerCase()) {
|
||||
case "success":
|
||||
case "ok":
|
||||
return "success";
|
||||
case "failure":
|
||||
case "error":
|
||||
case "blocked":
|
||||
return "destructive";
|
||||
case "pending":
|
||||
case "warning":
|
||||
return "warning";
|
||||
default:
|
||||
return "default";
|
||||
}
|
||||
}
|
||||
|
||||
export function AuditLogTable({
|
||||
logs,
|
||||
t,
|
||||
loading,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
onLoadMore,
|
||||
className,
|
||||
}: AuditLogTableProps) {
|
||||
const [expandedRows, setExpandedRows] = React.useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
|
||||
const handleCopy = (value: string) => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
navigator.clipboard.writeText(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cx(commonTableShellClass, className)}>
|
||||
<div className={cx(commonTableViewportClass, "flex-1")}>
|
||||
<div className={commonTableWrapperClass}>
|
||||
<Table className={commonTableClass}>
|
||||
<TableHeader className={commonTableHeaderClass}>
|
||||
<TableRow className={cx(commonTableRowClass, commonStickyTableHeaderClass)}>
|
||||
<TableHead className={cx(commonTableHeadClass, "w-[190px]")}>
|
||||
{t("ui.common.audit.table.time", "Time")}
|
||||
</TableHead>
|
||||
<TableHead className={cx(commonTableHeadClass, "w-[180px]")}>
|
||||
{t("ui.common.audit.table.user_id", "User ID")}
|
||||
</TableHead>
|
||||
<TableHead className={cx(commonTableHeadClass, "w-[180px]")}>
|
||||
{t("ui.common.audit.table.action", "Action")}
|
||||
</TableHead>
|
||||
<TableHead className={cx(commonTableHeadClass, "w-[260px]")}>
|
||||
{t("ui.common.audit.table.client_id", "Client ID")}
|
||||
</TableHead>
|
||||
<TableHead className={cx(commonTableHeadClass, "w-[120px]")}>
|
||||
{t("ui.common.audit.table.status", "Status")}
|
||||
</TableHead>
|
||||
<TableHead className={cx(commonTableHeadClass, "w-[80px]")} />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody className={commonTableBodyClass}>
|
||||
{logs.map((log, index) => {
|
||||
const details = parseAuditDetails(log.details);
|
||||
const actorLabel = resolveAuditActor(log, details);
|
||||
const actionLabel = resolveAuditAction(log, details);
|
||||
const targetLabel = resolveAuditTarget(details);
|
||||
const rowKey = `${log.event_id}-${log.timestamp}-${index}`;
|
||||
const expanded = Boolean(expandedRows[rowKey]);
|
||||
const { date, time } = formatAuditDateParts(log.timestamp);
|
||||
|
||||
return (
|
||||
<React.Fragment key={rowKey}>
|
||||
<TableRow className={cx(commonTableRowClass, "bg-card/40")}>
|
||||
<TableCell className={cx(commonTableCellClass, "text-xs text-muted-foreground")}>
|
||||
<div className="space-y-1">
|
||||
<div>{date}</div>
|
||||
<div>{time}</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className={commonTableCellClass}>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="rounded-md bg-secondary/60 px-2 py-1 text-xs text-muted-foreground">
|
||||
{actorLabel}
|
||||
</code>
|
||||
{actorLabel !== "-" ? (
|
||||
<button
|
||||
type="button"
|
||||
className={cx(
|
||||
getCommonButtonClasses({
|
||||
variant: "ghost",
|
||||
size: "icon",
|
||||
}),
|
||||
"h-7 w-7 text-muted-foreground hover:text-primary",
|
||||
)}
|
||||
aria-label={t(
|
||||
"ui.common.audit.copy.actor_id",
|
||||
"Copy User ID",
|
||||
)}
|
||||
onClick={() => handleCopy(actorLabel)}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className={cx(commonTableCellClass, "text-xs text-muted-foreground")}>
|
||||
<div className="font-semibold text-foreground">
|
||||
{actionLabel}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className={cx(commonTableCellClass, "text-xs text-muted-foreground")}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="break-all">{targetLabel}</span>
|
||||
{targetLabel !== "-" ? (
|
||||
<button
|
||||
type="button"
|
||||
className={cx(
|
||||
getCommonButtonClasses({
|
||||
variant: "ghost",
|
||||
size: "icon",
|
||||
}),
|
||||
"h-7 w-7 text-muted-foreground hover:text-primary",
|
||||
)}
|
||||
aria-label={t(
|
||||
"ui.common.audit.copy.target",
|
||||
"Copy Client ID",
|
||||
)}
|
||||
onClick={() => handleCopy(targetLabel)}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className={commonTableCellClass}>
|
||||
<span
|
||||
className={getCommonBadgeClasses({
|
||||
variant: statusVariant(log.status),
|
||||
})}
|
||||
>
|
||||
{log.status}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className={cx(commonTableCellClass, "text-right")}>
|
||||
<button
|
||||
type="button"
|
||||
className={getCommonButtonClasses({
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
})}
|
||||
onClick={() =>
|
||||
setExpandedRows((prev) => ({
|
||||
...prev,
|
||||
[rowKey]: !expanded,
|
||||
}))
|
||||
}
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{expanded && (
|
||||
<TableRow className={cx(commonTableRowClass, "bg-card/20")}>
|
||||
<TableCell colSpan={6} className={cx(commonTableCellClass, "text-xs")}>
|
||||
<div className="grid gap-4 text-muted-foreground md:grid-cols-3">
|
||||
<div className="space-y-1">
|
||||
<div className="uppercase tracking-[0.16em]">
|
||||
{t("ui.common.audit.details.request", "Request")}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{t(
|
||||
"ui.common.audit.details.request_id",
|
||||
"Request ID · {{value}}",
|
||||
{ value: formatAuditValue(details.request_id) },
|
||||
)}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{t(
|
||||
"ui.common.audit.details.event_id",
|
||||
"Event ID · {{value}}",
|
||||
{ value: formatAuditValue(log.event_id) },
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{t("ui.common.audit.details.ip", "IP · {{value}}", {
|
||||
value: formatAuditValue(log.ip_address),
|
||||
})}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{t(
|
||||
"ui.common.audit.details.method",
|
||||
"Method · {{value}}",
|
||||
{ value: formatAuditValue(details.method) },
|
||||
)}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{t(
|
||||
"ui.common.audit.details.path",
|
||||
"Path · {{value}}",
|
||||
{ value: formatAuditValue(details.path) },
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{t(
|
||||
"ui.common.audit.details.latency",
|
||||
"Latency · {{value}}",
|
||||
{
|
||||
value:
|
||||
details.latency_ms !== undefined
|
||||
? `${details.latency_ms}ms`
|
||||
: "-",
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="uppercase tracking-[0.16em]">
|
||||
{t("ui.common.audit.details.actor", "Actor")}
|
||||
</div>
|
||||
<div>
|
||||
{t(
|
||||
"ui.common.audit.details.actor_id",
|
||||
"User ID · {{value}}",
|
||||
{ value: actorLabel },
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{t(
|
||||
"ui.common.audit.details.tenant",
|
||||
"Tenant · {{value}}",
|
||||
{ value: formatAuditValue(details.tenant_id) },
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{t(
|
||||
"ui.common.audit.details.device",
|
||||
"Device · {{value}}",
|
||||
{ value: formatAuditValue(log.device_id) },
|
||||
)}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{t(
|
||||
"ui.common.audit.details.target",
|
||||
"Client ID · {{value}}",
|
||||
{ value: targetLabel },
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="uppercase tracking-[0.16em]">
|
||||
{t("ui.common.audit.details.result", "Result")}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{t(
|
||||
"ui.common.audit.details.error",
|
||||
"Error · {{value}}",
|
||||
{ value: formatAuditValue(details.error) },
|
||||
)}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{t(
|
||||
"ui.common.audit.details.before",
|
||||
"Before · {{value}}",
|
||||
{ value: formatAuditValue(details.before) },
|
||||
)}
|
||||
</div>
|
||||
<div className="break-all">
|
||||
{t(
|
||||
"ui.common.audit.details.after",
|
||||
"After · {{value}}",
|
||||
{ value: formatAuditValue(details.after) },
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
{logs.length === 0 && !loading && (
|
||||
<TableRow className={commonTableRowClass}>
|
||||
<TableCell
|
||||
colSpan={6}
|
||||
className={cx(
|
||||
commonTableCellClass,
|
||||
"text-center text-muted-foreground py-8",
|
||||
)}
|
||||
>
|
||||
{t("msg.common.audit.empty", "No audit logs found.")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 border-t text-center flex-shrink-0 bg-background/50 backdrop-blur-sm z-10">
|
||||
{hasNextPage ? (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
{isFetchingNextPage && (
|
||||
<span className="text-xs text-muted-foreground animate-pulse">
|
||||
{t("msg.common.loading", "Loading more...")}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={getCommonButtonClasses({
|
||||
variant: "outline",
|
||||
size: "sm",
|
||||
})}
|
||||
onClick={onLoadMore}
|
||||
disabled={isFetchingNextPage}
|
||||
>
|
||||
{isFetchingNextPage
|
||||
? t("msg.common.loading", "Loading...")
|
||||
: t("ui.common.audit.load_more", "Load more")}
|
||||
</button>
|
||||
</div>
|
||||
) : logs.length > 0 ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("msg.common.audit.end", "End of audit feed")}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Internal table components for cleaner implementation
|
||||
function Table({ className, children, style }: { className?: string, children: React.ReactNode, style?: React.CSSProperties }) {
|
||||
return <table className={className} style={style}>{children}</table>;
|
||||
}
|
||||
|
||||
function TableHeader({ className, children }: { className?: string, children: React.ReactNode }) {
|
||||
return <thead className={className}>{children}</thead>;
|
||||
}
|
||||
|
||||
function TableBody({ className, children }: { className?: string, children: React.ReactNode }) {
|
||||
return <tbody className={className}>{children}</tbody>;
|
||||
}
|
||||
|
||||
function TableRow({ className, children }: { className?: string, children: React.ReactNode }) {
|
||||
return <tr className={className}>{children}</tr>;
|
||||
}
|
||||
|
||||
function TableHead({ className, children }: { className?: string, children?: React.ReactNode }) {
|
||||
return <th className={className}>{children}</th>;
|
||||
}
|
||||
|
||||
function TableCell({ className, children, colSpan }: { className?: string, children: React.ReactNode, colSpan?: number }) {
|
||||
return <td className={className} colSpan={colSpan}>{children}</td>;
|
||||
}
|
||||
1
baron-sso/common/core/components/audit/index.ts
Normal file
1
baron-sso/common/core/components/audit/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./AuditLogTable";
|
||||
@@ -0,0 +1,14 @@
|
||||
export function OverviewAxisNotes({
|
||||
xAxisLabel,
|
||||
yAxisLabel,
|
||||
}: {
|
||||
xAxisLabel: string;
|
||||
yAxisLabel: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
<span>{xAxisLabel}</span>
|
||||
<span>{yAxisLabel}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
19
baron-sso/common/core/components/overview/OverviewMetric.tsx
Normal file
19
baron-sso/common/core/components/overview/OverviewMetric.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export function OverviewMetric({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
value: string;
|
||||
}) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-2 whitespace-nowrap text-sm">
|
||||
<span className="text-muted-foreground">{icon}</span>
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<span className="font-semibold tabular-nums">{value}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
type OverviewSelectionChipOption = {
|
||||
id: string;
|
||||
label: ReactNode;
|
||||
};
|
||||
|
||||
export function OverviewSelectionChips({
|
||||
allLabel,
|
||||
options,
|
||||
selectedIds,
|
||||
onSelectAll,
|
||||
onToggle,
|
||||
}: {
|
||||
allLabel: string;
|
||||
options: OverviewSelectionChipOption[];
|
||||
selectedIds: string[];
|
||||
onSelectAll: () => void;
|
||||
onToggle: (id: string) => void;
|
||||
}) {
|
||||
const isAllSelected = selectedIds.length === 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2 rounded-xl border border-border/60 bg-card/60 p-3">
|
||||
<label className="inline-flex items-center gap-2 rounded-full border border-border/60 px-3 py-1.5 text-xs">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isAllSelected}
|
||||
onChange={onSelectAll}
|
||||
className="h-3.5 w-3.5"
|
||||
/>
|
||||
<span>{allLabel}</span>
|
||||
</label>
|
||||
{options.map((option) => (
|
||||
<label
|
||||
key={option.id}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-border/60 px-3 py-1.5 text-xs"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.includes(option.id)}
|
||||
onChange={() => onToggle(option.id)}
|
||||
className="h-3.5 w-3.5"
|
||||
/>
|
||||
<span>{option.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
3
baron-sso/common/core/components/overview/index.ts
Normal file
3
baron-sso/common/core/components/overview/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { OverviewAxisNotes } from "./OverviewAxisNotes";
|
||||
export { OverviewMetric } from "./OverviewMetric";
|
||||
export { OverviewSelectionChips } from "./OverviewSelectionChips";
|
||||
68
baron-sso/common/core/components/page/PageHeader.tsx
Normal file
68
baron-sso/common/core/components/page/PageHeader.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { ElementType, HTMLAttributes, ReactNode } from "react";
|
||||
|
||||
function cx(...classNames: Array<string | false | null | undefined>) {
|
||||
return classNames.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
type PageHeaderProps = Omit<HTMLAttributes<HTMLElement>, "title"> & {
|
||||
actions?: ReactNode;
|
||||
icon?: ReactNode;
|
||||
as?: ElementType;
|
||||
description?: ReactNode;
|
||||
eyebrow?: ReactNode;
|
||||
sticky?: boolean;
|
||||
title: ReactNode;
|
||||
titleAs?: ElementType;
|
||||
};
|
||||
|
||||
export function PageHeader({
|
||||
actions,
|
||||
as,
|
||||
className,
|
||||
description,
|
||||
eyebrow,
|
||||
icon,
|
||||
sticky = false,
|
||||
title,
|
||||
titleAs,
|
||||
...props
|
||||
}: PageHeaderProps) {
|
||||
const Root = as ?? "header";
|
||||
const Title = titleAs ?? "h1";
|
||||
|
||||
return (
|
||||
<Root
|
||||
className={cx(
|
||||
"flex flex-wrap items-start justify-between gap-4",
|
||||
sticky &&
|
||||
"sticky top-[-2.5rem] z-20 -mt-4 bg-background/95 pt-4 pb-2 backdrop-blur",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
{icon ? (
|
||||
<div className="mt-1 flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-primary/15 bg-primary/10 text-primary">
|
||||
{icon}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="space-y-2">
|
||||
{eyebrow ? (
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground">
|
||||
{eyebrow}
|
||||
</p>
|
||||
) : null}
|
||||
<Title className="text-3xl font-semibold tracking-tight">
|
||||
{title}
|
||||
</Title>
|
||||
{description ? (
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{actions ? (
|
||||
<div className="flex flex-wrap items-center gap-2">{actions}</div>
|
||||
) : null}
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
1
baron-sso/common/core/components/page/index.ts
Normal file
1
baron-sso/common/core/components/page/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./PageHeader";
|
||||
170
baron-sso/common/core/components/sort/SortableTableHead.tsx
Normal file
170
baron-sso/common/core/components/sort/SortableTableHead.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import type { ReactNode, ThHTMLAttributes } from "react";
|
||||
import {
|
||||
commonStickyTableHeaderClass,
|
||||
commonTableHeadClass,
|
||||
} from "../../../ui/table";
|
||||
import type { SortConfig } from "../../utils";
|
||||
|
||||
export const sortableTableHeadBaseClassName = commonTableHeadClass;
|
||||
|
||||
export const sortableTableHeaderClassName = commonStickyTableHeaderClass;
|
||||
|
||||
function SortAscendingIcon() {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 24 24"
|
||||
className="h-3.5 w-3.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="m12 5-5 5" />
|
||||
<path d="m12 5 5 5" />
|
||||
<path d="M12 19V5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function SortDescendingIcon() {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 24 24"
|
||||
className="h-3.5 w-3.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="m12 19-5-5" />
|
||||
<path d="m12 19 5-5" />
|
||||
<path d="M12 5v14" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function SortIdleIcon() {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 24 24"
|
||||
className="ml-1 h-3.5 w-3.5 opacity-50"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="m7 15 5 5 5-5" />
|
||||
<path d="m7 9 5-5 5 5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
type SortableTableHeadAlign = "left" | "center" | "right";
|
||||
|
||||
function alignClassName(align: SortableTableHeadAlign) {
|
||||
switch (align) {
|
||||
case "center":
|
||||
return "text-center";
|
||||
case "right":
|
||||
return "text-right";
|
||||
default:
|
||||
return "text-left";
|
||||
}
|
||||
}
|
||||
|
||||
function buttonAlignClassName(align: SortableTableHeadAlign) {
|
||||
switch (align) {
|
||||
case "center":
|
||||
return "justify-center";
|
||||
case "right":
|
||||
return "justify-end";
|
||||
default:
|
||||
return "justify-start";
|
||||
}
|
||||
}
|
||||
|
||||
function sortAriaValue(
|
||||
isActive: boolean,
|
||||
direction: "asc" | "desc" | null,
|
||||
): ThHTMLAttributes<HTMLTableCellElement>["aria-sort"] {
|
||||
if (!isActive || direction === null) {
|
||||
return "none";
|
||||
}
|
||||
return direction === "asc" ? "ascending" : "descending";
|
||||
}
|
||||
|
||||
type SortableTableHeadProps<Key extends string> = Omit<
|
||||
ThHTMLAttributes<HTMLTableCellElement>,
|
||||
"children"
|
||||
> & {
|
||||
align?: SortableTableHeadAlign;
|
||||
contentClassName?: string;
|
||||
disabled?: boolean;
|
||||
label: ReactNode;
|
||||
onSort: (key: Key) => void;
|
||||
sortConfig: SortConfig<Key> | null;
|
||||
sortKey: Key;
|
||||
};
|
||||
|
||||
export function SortableTableHead<Key extends string>({
|
||||
align = "left",
|
||||
className = "",
|
||||
contentClassName = "",
|
||||
disabled = false,
|
||||
label,
|
||||
onSort,
|
||||
sortConfig,
|
||||
sortKey,
|
||||
...props
|
||||
}: SortableTableHeadProps<Key>) {
|
||||
const isActive = sortConfig?.key === sortKey;
|
||||
const direction = isActive ? (sortConfig?.direction ?? null) : null;
|
||||
|
||||
return (
|
||||
<th
|
||||
aria-sort={sortAriaValue(isActive, direction)}
|
||||
className={[
|
||||
sortableTableHeadBaseClassName,
|
||||
alignClassName(align),
|
||||
disabled ? "" : "transition-colors hover:bg-muted/50",
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
{...props}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSort(sortKey)}
|
||||
disabled={disabled}
|
||||
className={[
|
||||
"flex w-full items-center font-inherit",
|
||||
buttonAlignClassName(align),
|
||||
disabled ? "cursor-default opacity-70" : "cursor-pointer",
|
||||
contentClassName,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
>
|
||||
<span>{label}</span>
|
||||
{direction === "asc" ? (
|
||||
<span className="ml-1 inline-flex">
|
||||
<SortAscendingIcon />
|
||||
</span>
|
||||
) : direction === "desc" ? (
|
||||
<span className="ml-1 inline-flex">
|
||||
<SortDescendingIcon />
|
||||
</span>
|
||||
) : (
|
||||
<SortIdleIcon />
|
||||
)}
|
||||
</button>
|
||||
</th>
|
||||
);
|
||||
}
|
||||
1
baron-sso/common/core/components/sort/index.ts
Normal file
1
baron-sso/common/core/components/sort/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./SortableTableHead";
|
||||
Reference in New Issue
Block a user