Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions frontend/components/resource/CreateResourceCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"use client";

import { Plus } from "lucide-react";

export interface CreateResourceCardProps {
title: string;
onClick: () => void;
}

export default function CreateResourceCard({
title,
onClick,
}: CreateResourceCardProps) {

Check warning on line 13 in frontend/components/resource/CreateResourceCard.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Mark the props of the component as read-only.

See more on https://sonarcloud.io/project/issues?id=ModelEngine-Group_nexent&issues=AaBrbR_LVIOPsjUO5C4A&open=AaBrbR_LVIOPsjUO5C4A&pullRequest=3862
return (
<button
type="button"
onClick={onClick}
aria-label={title}
className="group flex h-full min-h-[240px] w-full flex-col items-center justify-center rounded-lg border-2 border-dashed border-slate-300 bg-white p-6 text-center transition hover:border-blue-400 hover:bg-slate-50/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2"
>
<span className="mb-4 flex size-12 items-center justify-center rounded-full border-2 border-slate-400 text-slate-400 transition group-hover:border-blue-500 group-hover:text-blue-500">
<Plus size={24} aria-hidden="true" />
</span>
<span className="text-base font-normal text-slate-500 transition group-hover:text-blue-600">
{title}
</span>
</button>
);
}
132 changes: 132 additions & 0 deletions frontend/components/resource/ResourceCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
"use client";

import { useId, type ReactNode, type Ref } from "react";

import { cn } from "@/lib/utils";

export interface ResourceCardProps {
title: ReactNode;
description?: ReactNode;
icon?: ReactNode;
/** Status or lifecycle content displayed beside the title. */
badge?: ReactNode;
/** Resource tags displayed below the description. */
tags?: ReactNode;
meta?: ReactNode;
/** Status icons displayed beside the title, left of actions. */
headerActions?: ReactNode;
/** Actions displayed at the top-right (e.g., "..." menu). */
actions?: ReactNode;
footer?: ReactNode;
onClick?: () => void;
selected?: boolean;
className?: string;
containerRef?: Ref<HTMLDivElement>;
}

export default function ResourceCard({
title,
description,
icon,
badge,
tags,
meta,
headerActions,
actions,
footer,
onClick,
selected,
className,
containerRef,
}: ResourceCardProps) {

Check warning on line 41 in frontend/components/resource/ResourceCard.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Mark the props of the component as read-only.

See more on https://sonarcloud.io/project/issues?id=ModelEngine-Group_nexent&issues=AaBrbR-8VIOPsjUO5C37&open=AaBrbR-8VIOPsjUO5C37&pullRequest=3862
const titleId = `resource-card-title-${useId()}`;
const isInteractive = onClick !== undefined;
const actionClassName = isInteractive
? "pointer-events-auto relative z-10"
: undefined;

return (
<div
ref={containerRef}
className={cn(
"group relative flex min-h-[240px] flex-col rounded-lg border bg-white text-left transition",
"p-5 shadow-sm hover:border-blue-300 hover:shadow-md",
selected && "border-blue-400 ring-1 ring-blue-200",
!selected && "border-slate-200",
className
)}
>
{isInteractive ? (
<button
type="button"
aria-labelledby={titleId}
aria-pressed={selected}
onClick={onClick}
className="absolute inset-0 z-0 size-full cursor-pointer rounded-[inherit] border-0 bg-transparent p-0 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-inset"
/>
) : null}
<div
className={cn(
"flex min-h-0 flex-1 flex-col",
isInteractive && "pointer-events-none relative z-[1]"
)}
>
<div className="flex items-start gap-3">
{icon ? <span className="shrink-0">{icon}</span> : null}
<div className="min-w-0 flex-1">
<h2
id={titleId}
className="truncate text-base font-semibold text-slate-900"
>
{title}
</h2>
{badge ? (
<div className="mt-1 flex flex-wrap gap-2">{badge}</div>
) : null}
</div>
{headerActions || actions ? (
<div
data-resource-card-action
className={cn(
"flex shrink-0 items-center gap-1",
actionClassName
)}
>
{headerActions}
{actions}
</div>
) : null}
</div>
<div className="flex flex-1 flex-col">
{description ? (
<div className="mt-4 line-clamp-3 text-sm leading-6 text-slate-600">
{description}
</div>
) : null}
{tags ? (
<div
className={cn(
"flex flex-wrap gap-2 pb-2 text-xs",
description ? "mt-3" : "mt-4"
)}
>
{tags}
</div>
) : null}
<div className="mt-auto">
{meta || footer ? (
<div className="flex items-center justify-between gap-3 border-t border-slate-100 pt-4">
<div className="min-w-0">{meta}</div>
{footer ? (
<div data-resource-card-action className={actionClassName}>
{footer}
</div>
) : null}
</div>
) : null}
</div>
</div>
</div>
</div>
);
}
178 changes: 178 additions & 0 deletions frontend/components/resource/ResourceCardGrid.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
"use client";

import { useEffect, useMemo } from "react";
import { Empty, Input, Pagination } from "antd";
import { Search } from "lucide-react";
Comment thread
ayiya12 marked this conversation as resolved.
import type { CSSProperties, ReactNode } from "react";

import CreateResourceCard from "./CreateResourceCard";

export interface ResourceFilterOption {
key: string;
label: ReactNode;
count?: number;
}

export interface ResourceCardGridProps<T> {
items: T[];
page?: number;
total?: number;
onPageChange?: (page: number) => void;
search?: string;
searchPlaceholder?: string;
onSearchChange?: (value: string) => void;
filters?: ResourceFilterOption[];
activeFilter?: string;
onFilterChange?: (key: string) => void;
showToolbar?: boolean;
showCreateCard?: boolean;
createCard?: ReactNode;
createCardTitle?: string;
onCreate?: () => void;
columns?: number;
rows?: number;
/** Keep support for callers that need to place another item before the cards. */
headerItem?: ReactNode;
emptyState?: ReactNode;
/** Set to false when items already contain only the current server page. */
paginateItems?: boolean;
renderItem: (item: T, index: number) => ReactNode;
}

export default function ResourceCardGrid<T>({

Check failure on line 42 in frontend/components/resource/ResourceCardGrid.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 16 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=ModelEngine-Group_nexent&issues=AaB5kzaDkVRyiI0bGcHk&open=AaB5kzaDkVRyiI0bGcHk&pullRequest=3862
items,
page,
total,
onPageChange,
search,
searchPlaceholder,
onSearchChange,
filters = [],
activeFilter,
onFilterChange,
showToolbar,
showCreateCard,
createCard,
createCardTitle,
onCreate,
columns,
rows,
headerItem,
emptyState = <Empty />,
paginateItems = true,
renderItem,
}: ResourceCardGridProps<T>) {

Check warning on line 64 in frontend/components/resource/ResourceCardGrid.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Mark the props of the component as read-only.

See more on https://sonarcloud.io/project/issues?id=ModelEngine-Group_nexent&issues=AaBrbR_TVIOPsjUO5C4B&open=AaBrbR_TVIOPsjUO5C4B&pullRequest=3862
const columnCount = normalizePositiveInteger(columns, 4);
const rowCount = normalizePositiveInteger(rows, 3);
const slotsPerPage = columnCount * rowCount;
const createEnabled =
showCreateCard ?? Boolean(createCard || (createCardTitle && onCreate));
const itemsPerPage = createEnabled
? Math.max(1, slotsPerPage - 1)
: slotsPerPage;
const itemCount = total ?? items.length;
const totalPages = Math.max(1, Math.ceil(itemCount / itemsPerPage));
const currentPage = Math.min(Math.max(page ?? 1, 1), totalPages);
const visibleItems = useMemo(() => {
if (!paginateItems) return items;
const start = (currentPage - 1) * itemsPerPage;
return items.slice(start, start + itemsPerPage);
}, [currentPage, items, itemsPerPage, paginateItems]);
const shouldShowCreateCard = createEnabled;
const createCardNode = shouldShowCreateCard
? (createCard ??
(createCardTitle && onCreate ? (
<CreateResourceCard title={createCardTitle} onClick={onCreate} />
) : null))

Check warning on line 86 in frontend/components/resource/ResourceCardGrid.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested ternary operation into an independent statement.

See more on https://sonarcloud.io/project/issues?id=ModelEngine-Group_nexent&issues=AaB5kzaDkVRyiI0bGcHl&open=AaB5kzaDkVRyiI0bGcHl&pullRequest=3862
: null;
const toolbarVisible =
showToolbar ??
Boolean(onSearchChange || search !== undefined || filters.length > 0);
const hasSearchControl = onSearchChange !== undefined || search !== undefined;
const gridStyle = {
"--resource-card-columns": columnCount,
} as CSSProperties;

useEffect(() => {
if (page !== undefined && page > totalPages) onPageChange?.(totalPages);
}, [onPageChange, page, totalPages]);

return (
<>
{toolbarVisible ? (
<div className="mb-5 flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
{hasSearchControl ? (
<Input
value={search ?? ""}
onChange={(event) => onSearchChange?.(event.target.value)}
allowClear={onSearchChange !== undefined}
readOnly={onSearchChange === undefined}
prefix={<Search size={16} className="text-slate-400" />}
placeholder={searchPlaceholder}
className="h-10 w-full sm:max-w-md"
/>
) : null}
{filters.length > 0 ? (
<div className="flex items-center gap-5 border-b border-slate-200 text-sm">
{filters.map((filter) => (
<button
key={filter.key}
type="button"
aria-pressed={activeFilter === filter.key}
onClick={() => onFilterChange?.(filter.key)}
className={cnFilter(activeFilter === filter.key)}
>
{filter.label}
{filter.count !== undefined ? (
<span className="ml-1 text-xs text-slate-400">
{filter.count}
</span>
) : null}
</button>
))}
</div>
) : null}
</div>
) : null}
<div
className="grid grid-cols-1 gap-5 sm:[grid-template-columns:repeat(var(--resource-card-columns),minmax(0,1fr))]"
style={gridStyle}
>
{headerItem != null && currentPage === 1 ? headerItem : null}
{createCardNode}
{itemCount === 0 ? (
<div className="col-span-full flex min-h-[220px] items-center justify-center">
{emptyState}
</div>
) : (
visibleItems.map((item, index) => renderItem(item, index))
)}
</div>
{itemCount > 0 && totalPages > 1 && onPageChange ? (
<div className="mt-7 flex justify-end">
<Pagination
current={currentPage}
pageSize={itemsPerPage}
total={itemCount}
showSizeChanger={false}
onChange={onPageChange}
/>
</div>
) : null}
</>
);
}

function normalizePositiveInteger(value: number | undefined, fallback: number) {
return Number.isFinite(value) && value !== undefined && value > 0
? Math.floor(value)
: fallback;
}

function cnFilter(active: boolean) {
return `border-b-2 px-1 pb-2 font-medium transition-colors ${
active
? "border-blue-600 text-blue-600"
: "border-transparent text-slate-500 hover:text-slate-800"
}`;
}
Loading