feat(bases): scaffold kanban renderer (no DnD yet)

This commit is contained in:
Philipinho
2026-05-24 13:27:25 +01:00
parent dfd6d3aee0
commit 68cdcd970c
5 changed files with 255 additions and 0 deletions
@@ -0,0 +1,56 @@
import { useMemo } from "react";
import {
IBase,
IBaseRow,
IBaseView,
} from "@/features/base/types/base.types";
import { useKanbanGroups } from "@/features/base/hooks/use-kanban-groups";
import { KanbanColumn } from "./kanban-column";
import classes from "@/features/base/styles/kanban.module.css";
type BaseKanbanProps = {
base: IBase;
rows: IBaseRow[];
effectiveView: IBaseView | undefined;
onCardClick: (rowId: string) => void;
};
export function BaseKanban({
base,
rows,
effectiveView,
onCardClick,
}: BaseKanbanProps) {
const groupByPropertyId = effectiveView?.config?.groupByPropertyId;
const property = useMemo(
() =>
groupByPropertyId
? base.properties.find((p) => p.id === groupByPropertyId)
: undefined,
[groupByPropertyId, base.properties],
);
const primaryProperty = useMemo(
() => base.properties.find((p) => p.isPrimary),
[base.properties],
);
const isGroupable = property?.type === "select" || property?.type === "status";
const { columns } = useKanbanGroups(
rows,
isGroupable ? property : undefined,
effectiveView?.config?.hiddenChoiceIds,
effectiveView?.config?.choiceOrder,
);
return (
<div className={classes.board}>
{columns.map((column) => (
<KanbanColumn
key={column.key}
column={column}
primaryProperty={primaryProperty}
onCardClick={onCardClick}
/>
))}
</div>
);
}
@@ -0,0 +1,42 @@
import { IBaseProperty, IBaseRow } from "@/features/base/types/base.types";
import classes from "@/features/base/styles/kanban.module.css";
import { useTranslation } from "react-i18next";
type KanbanCardProps = {
row: IBaseRow;
primaryProperty: IBaseProperty | undefined;
onClick: (rowId: string) => void;
};
export function KanbanCard({ row, primaryProperty, onClick }: KanbanCardProps) {
const { t } = useTranslation();
const titleValue =
primaryProperty
? ((row.cells ?? {})[primaryProperty.id] as string | undefined)
: undefined;
const titleText = titleValue?.trim().length ? titleValue : t("Untitled");
const isEmpty = !titleValue?.trim().length;
return (
<div
className={classes.card}
role="button"
tabIndex={0}
onClick={() => onClick(row.id)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onClick(row.id);
}
}}
>
<div
className={
classes.cardTitle + (isEmpty ? " " + classes.cardTitleEmpty : "")
}
>
{titleText}
</div>
</div>
);
}
@@ -0,0 +1,31 @@
import { Badge, Text } from "@mantine/core";
import classes from "@/features/base/styles/kanban.module.css";
type KanbanColumnHeaderProps = {
name: string;
color: string | null;
count: number;
};
export function KanbanColumnHeader({
name,
color,
count,
}: KanbanColumnHeaderProps) {
return (
<div className={classes.columnHeader}>
<div className={classes.columnHeaderLeft}>
{color ? (
<Badge color={color} variant="light" size="sm">
{name}
</Badge>
) : (
<Text size="sm" c="dimmed">
{name}
</Text>
)}
<span className={classes.columnCount}>{count}</span>
</div>
</div>
);
}
@@ -0,0 +1,37 @@
import { KanbanColumn as KanbanColumnData } from "@/features/base/hooks/use-kanban-groups";
import { IBaseProperty } from "@/features/base/types/base.types";
import { KanbanCard } from "./kanban-card";
import { KanbanColumnHeader } from "./kanban-column-header";
import classes from "@/features/base/styles/kanban.module.css";
type KanbanColumnProps = {
column: KanbanColumnData;
primaryProperty: IBaseProperty | undefined;
onCardClick: (rowId: string) => void;
};
export function KanbanColumn({
column,
primaryProperty,
onCardClick,
}: KanbanColumnProps) {
return (
<div className={classes.column} data-column-key={column.key}>
<KanbanColumnHeader
name={column.name}
color={column.color}
count={column.rows.length}
/>
<div className={classes.columnBody} data-column-body={column.key}>
{column.rows.map((row) => (
<KanbanCard
key={row.id}
row={row}
primaryProperty={primaryProperty}
onClick={onCardClick}
/>
))}
</div>
</div>
);
}
@@ -0,0 +1,89 @@
.board {
display: flex;
flex-direction: row;
gap: 12px;
padding: 12px;
overflow-x: auto;
overflow-y: hidden;
height: 100%;
min-height: 0;
align-items: stretch;
}
.column {
display: flex;
flex-direction: column;
flex: 0 0 280px;
width: 280px;
background: var(--mantine-color-default-hover);
border-radius: 8px;
min-height: 0;
}
.columnHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border-bottom: 1px solid var(--mantine-color-default-border);
position: relative; /* anchor for BaseDropEdgeIndicator on column reorder */
}
.columnHeaderLeft {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
min-width: 0;
}
.columnCount {
font-size: 12px;
color: var(--mantine-color-dimmed);
}
.columnBody {
flex: 1 1 auto;
overflow-y: auto;
padding: 8px;
display: flex;
flex-direction: column;
gap: 8px;
min-height: 80px;
}
.card {
padding: 10px 12px;
background: var(--mantine-color-body);
border: 1px solid var(--mantine-color-default-border);
border-radius: 6px;
cursor: pointer;
user-select: none;
position: relative;
}
.card[data-dragging="true"] {
opacity: 0.4;
}
.cardTitle {
font-size: 14px;
line-height: 1.4;
word-break: break-word;
}
.cardTitleEmpty {
color: var(--mantine-color-dimmed);
}
.addCardButton {
margin-top: 4px;
align-self: stretch;
justify-content: flex-start;
}
.sortHint {
padding: 6px 12px;
font-size: 12px;
color: var(--mantine-color-dimmed);
}