track requesters for builds releases deployments and migrations
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
type CommitAuthorUser = {
|
||||
login?: string;
|
||||
username?: string;
|
||||
full_name?: string;
|
||||
name?: string;
|
||||
avatar_url?: string;
|
||||
avatarUrl?: string;
|
||||
} | null | undefined;
|
||||
|
||||
export type CommitAuthorLike = {
|
||||
commit?: {
|
||||
author?: {
|
||||
name?: string;
|
||||
};
|
||||
};
|
||||
author?: CommitAuthorUser;
|
||||
committer?: CommitAuthorUser;
|
||||
};
|
||||
|
||||
export function commitAuthorName(commit: CommitAuthorLike) {
|
||||
return commit.author?.login
|
||||
|| commit.author?.username
|
||||
|| commit.author?.full_name
|
||||
|| commit.author?.name
|
||||
|| commit.commit?.author?.name
|
||||
|| commit.committer?.login
|
||||
|| commit.committer?.username
|
||||
|| commit.committer?.full_name
|
||||
|| commit.committer?.name
|
||||
|| '-';
|
||||
}
|
||||
|
||||
function commitAuthorAvatarUrl(commit: CommitAuthorLike) {
|
||||
return commit.author?.avatar_url
|
||||
|| commit.author?.avatarUrl
|
||||
|| commit.committer?.avatar_url
|
||||
|| commit.committer?.avatarUrl
|
||||
|| '';
|
||||
}
|
||||
|
||||
export function CommitAuthor({ commit }: { commit: CommitAuthorLike }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
const name = commitAuthorName(commit);
|
||||
const avatarUrl = commitAuthorAvatarUrl(commit);
|
||||
const initial = name === '-' ? '?' : name.trim().charAt(0).toUpperCase() || '?';
|
||||
|
||||
return (
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
{avatarUrl && !failed ? (
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt=""
|
||||
className="size-7 shrink-0 rounded-full border border-border bg-muted object-cover"
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
) : (
|
||||
<span className="inline-flex size-7 shrink-0 items-center justify-center rounded-full border border-border bg-muted text-xs font-semibold text-muted-foreground">
|
||||
{initial}
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate font-medium">{name}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ export type DataTableColumn<T> = {
|
||||
key: string;
|
||||
header: string;
|
||||
className?: string;
|
||||
width?: number;
|
||||
cell: (row: T) => ReactNode;
|
||||
};
|
||||
|
||||
@@ -16,6 +17,25 @@ export type DataTableFilter<T> = {
|
||||
getValue: (row: T) => string;
|
||||
};
|
||||
|
||||
const spacingWidthPattern = /\bw-(?:\[(\d+)px\]|(\d+))\b/;
|
||||
const widthClassPattern = /\bw-(?:\[[^\]]+\]|\d+)\b/g;
|
||||
|
||||
function getColumnWidth<T>(column: DataTableColumn<T>) {
|
||||
if (column.key === 'no') return 75;
|
||||
if (column.width) return column.width;
|
||||
|
||||
const widthMatch = column.className?.match(spacingWidthPattern);
|
||||
if (widthMatch?.[1]) return Number(widthMatch[1]);
|
||||
if (widthMatch?.[2]) return Number(widthMatch[2]) * 4;
|
||||
if (column.key === 'actions') return 80;
|
||||
|
||||
return 160;
|
||||
}
|
||||
|
||||
function stripWidthClass(className?: string) {
|
||||
return className?.replace(widthClassPattern, '').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
export function DataTableCard<T>({
|
||||
title,
|
||||
description,
|
||||
@@ -66,6 +86,7 @@ export function DataTableCard<T>({
|
||||
const totalPages = Math.max(1, Math.ceil(filteredRows.length / pageSize));
|
||||
const currentPage = Math.min(page, totalPages);
|
||||
const pageRows = filteredRows.slice((currentPage - 1) * pageSize, currentPage * pageSize);
|
||||
const flexibleColumnIndex = columns[0]?.key === 'no' && columns.length > 1 ? 1 : -1;
|
||||
|
||||
const updateFilter = (key: string, value: string) => {
|
||||
setFilterValues((prev) => ({ ...prev, [key]: value }));
|
||||
@@ -116,11 +137,36 @@ export function DataTableCard<T>({
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-[560px] overflow-x-auto overflow-y-auto">
|
||||
<table className="w-full text-left text-sm" style={{ minWidth }}>
|
||||
<table className="w-full table-fixed text-left text-sm" style={{ minWidth }}>
|
||||
<colgroup>
|
||||
{columns.map((column, columnIndex) => (
|
||||
<col
|
||||
key={column.key}
|
||||
style={
|
||||
columnIndex === flexibleColumnIndex
|
||||
? { width: '100%' }
|
||||
: { width: getColumnWidth(column) }
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</colgroup>
|
||||
<thead className="sticky top-0 z-10 border-b border-border bg-card text-xs text-muted-foreground">
|
||||
<tr>
|
||||
{columns.map((column) => (
|
||||
<th key={column.key} className={column.className ?? 'px-5 py-3 font-medium'}>
|
||||
{columns.map((column, columnIndex) => (
|
||||
<th
|
||||
key={column.key}
|
||||
className={
|
||||
column.key === 'no'
|
||||
? 'py-3 text-center font-medium'
|
||||
: columnIndex === flexibleColumnIndex
|
||||
? stripWidthClass(column.className) || 'px-5 py-3 font-medium'
|
||||
: column.className ?? 'px-5 py-3 font-medium'
|
||||
}
|
||||
style={{
|
||||
...(column.key === 'no' ? { boxSizing: 'border-box', width: 75, minWidth: 75, maxWidth: 75, paddingLeft: 0, paddingRight: 0 } : columnIndex === flexibleColumnIndex ? { width: '100%' } : { width: getColumnWidth(column) }),
|
||||
...(columnIndex === flexibleColumnIndex ? { paddingLeft: 12 } : {}),
|
||||
}}
|
||||
>
|
||||
{column.header}
|
||||
</th>
|
||||
))}
|
||||
@@ -129,9 +175,25 @@ export function DataTableCard<T>({
|
||||
<tbody className="divide-y divide-border">
|
||||
{!loading && pageRows.map((row) => (
|
||||
<tr key={getRowKey(row)} className="hover:bg-muted/20">
|
||||
{columns.map((column) => (
|
||||
<td key={column.key} className="px-5 py-3">
|
||||
{column.cell(row)}
|
||||
{columns.map((column, columnIndex) => (
|
||||
<td
|
||||
key={column.key}
|
||||
className={column.key === 'no' ? 'py-3 text-center font-medium' : columns[0]?.key === 'no' && columnIndex === 1 ? 'py-3 pl-3 pr-5' : 'px-5 py-3'}
|
||||
style={
|
||||
column.key === 'no'
|
||||
? { boxSizing: 'border-box', width: 75, minWidth: 75, maxWidth: 75, paddingLeft: 0, paddingRight: 0 }
|
||||
: columnIndex === flexibleColumnIndex
|
||||
? { width: '100%' }
|
||||
: { width: getColumnWidth(column) }
|
||||
}
|
||||
>
|
||||
{column.key === 'no' ? (
|
||||
<span className="block w-full text-center font-semibold">
|
||||
{String((row as { no?: unknown }).no ?? '')}
|
||||
</span>
|
||||
) : (
|
||||
column.cell(row)
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
export type RequesterLike = {
|
||||
requestedBy?: string | null;
|
||||
requestedByName?: string | null;
|
||||
requestedByEmail?: string | null;
|
||||
requestedByAvatarUrl?: string | null;
|
||||
createdBy?: string | null;
|
||||
createdByName?: string | null;
|
||||
createdByEmail?: string | null;
|
||||
createdByAvatarUrl?: string | null;
|
||||
};
|
||||
|
||||
export function requesterName(item: RequesterLike) {
|
||||
return item.requestedByName
|
||||
|| item.createdByName
|
||||
|| item.requestedBy
|
||||
|| item.createdBy
|
||||
|| item.requestedByEmail
|
||||
|| item.createdByEmail
|
||||
|| 'admin';
|
||||
}
|
||||
|
||||
export function Requester({ item, label = '작업자' }: { item: RequesterLike; label?: string }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
const name = requesterName(item);
|
||||
const avatarUrl = item.requestedByAvatarUrl || item.createdByAvatarUrl || '';
|
||||
const initial = name.trim().charAt(0).toUpperCase() || '?';
|
||||
|
||||
return (
|
||||
<span className="inline-flex min-w-0 items-center gap-2 text-muted-foreground">
|
||||
<span>{label}</span>
|
||||
{avatarUrl && !failed ? (
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt=""
|
||||
className="size-6 shrink-0 rounded-full border border-border bg-muted object-cover"
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
) : (
|
||||
<span className="inline-flex size-6 shrink-0 items-center justify-center rounded-full border border-border bg-muted text-[11px] font-semibold text-muted-foreground">
|
||||
{initial}
|
||||
</span>
|
||||
)}
|
||||
<b className="truncate text-foreground">{name}</b>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user