Files
docmost/apps/client/src/features/base/components/views/kanban/base-kanban.tsx
T

274 lines
8.9 KiB
TypeScript

import { useCallback, useMemo } from "react";
import { Badge } from "@mantine/core";
import { IconPlus } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import {
IBase,
IBaseRow,
IBaseView,
NO_VALUE_CHOICE_ID,
} from "@/features/base/types/base.types";
import { useKanbanGroups } from "@/features/base/hooks/use-kanban-groups";
import { useUpdateViewMutation } from "@/features/base/queries/base-view-query";
import {
useCreateRowMutation,
useReorderRowMutation,
useUpdateRowMutation,
} from "@/features/base/queries/base-row-query";
import { resolveCardDrop } from "@/features/base/hooks/resolve-card-drop";
import type { CardDropPayload } from "@/features/base/hooks/use-kanban-card-drag";
import type { ColumnReorderPayload } from "@/features/base/hooks/use-kanban-column-reorder";
import { triggerPostMoveFlash } from "@atlaskit/pragmatic-drag-and-drop-flourish/trigger-post-move-flash";
import * as liveRegion from "@atlaskit/pragmatic-drag-and-drop-live-region";
import { KanbanColumn } from "./kanban-column";
import { KanbanEmptyState } from "./kanban-empty-state";
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 { t } = useTranslation();
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 updateViewMutation = useUpdateViewMutation();
const createRowMutation = useCreateRowMutation();
const updateRowMutation = useUpdateRowMutation();
const reorderRowMutation = useReorderRowMutation();
const sortsActive = (effectiveView?.config?.sorts?.length ?? 0) > 0;
// Rules of Hooks: call useKanbanGroups unconditionally with `undefined`
// when not groupable; switch the render path on isGroupable below.
const { columns } = useKanbanGroups(
rows,
isGroupable ? property : undefined,
effectiveView?.config?.hiddenChoiceIds,
effectiveView?.config?.choiceOrder,
);
const handlePickProperty = (propertyId: string) => {
if (!effectiveView) return;
updateViewMutation.mutate({
viewId: effectiveView.id,
pageId: base.id,
config: { ...effectiveView.config, groupByPropertyId: propertyId },
});
};
const handleCardDrop = useCallback(
(payload: CardDropPayload) => {
if (!groupByPropertyId) return;
const targetColumn = columns.find((c) => c.key === payload.targetColumnKey);
// The drop target restricts allowedEdges to ["top","bottom"], so the
// runtime value is always assignable; narrow the broader Edge union.
const edge =
payload.edge === "top" || payload.edge === "bottom"
? payload.edge
: null;
const result = resolveCardDrop({
draggedCardId: payload.draggedCardId,
targetCardId: payload.targetCardId,
edge,
sourceColumnKey: payload.sourceColumnKey,
targetColumnKey: payload.targetColumnKey,
groupByPropertyId,
columnRows: targetColumn?.rows ?? [],
sortsActive,
});
if (result.cells !== undefined) {
updateRowMutation.mutate({
rowId: payload.draggedCardId,
pageId: base.id,
cells: result.cells,
...(result.position !== undefined && { position: result.position }),
});
} else if (result.position !== undefined) {
reorderRowMutation.mutate({
rowId: payload.draggedCardId,
pageId: base.id,
position: result.position,
});
}
// a11y + post-move flash on the dropped card (if still in DOM).
const el = document.querySelector(
`[data-row-id="${payload.draggedCardId}"]`,
);
if (el instanceof HTMLElement) triggerPostMoveFlash(el);
const colName = targetColumn?.name ?? "column";
liveRegion.announce(`Moved card to ${colName}`);
},
[
base.id,
columns,
groupByPropertyId,
reorderRowMutation,
sortsActive,
updateRowMutation,
],
);
const handleAddCard = async (columnKey: string) => {
if (!groupByPropertyId) return;
const cells =
columnKey === NO_VALUE_CHOICE_ID
? {}
: { [groupByPropertyId]: columnKey };
const column = columns.find((c) => c.key === columnKey);
const afterRowId = column?.rows[column.rows.length - 1]?.id;
try {
const newRow = await createRowMutation.mutateAsync({
pageId: base.id,
cells,
afterRowId,
});
onCardClick(newRow.id);
} catch {
// mutation already shows an error toast.
}
};
const handleColumnReorder = useCallback(
(payload: ColumnReorderPayload) => {
if (!effectiveView) return;
const current = columns.map((c) => c.key);
const fromIdx = current.indexOf(payload.draggedColumnKey);
const toIdx = current.indexOf(payload.targetColumnKey);
if (fromIdx === -1 || toIdx === -1) return;
const next = current.slice();
next.splice(fromIdx, 1);
const insertAt =
payload.edge === "left"
? toIdx > fromIdx
? toIdx - 1
: toIdx
: toIdx > fromIdx
? toIdx
: toIdx + 1;
next.splice(insertAt, 0, payload.draggedColumnKey);
updateViewMutation.mutate({
viewId: effectiveView.id,
pageId: base.id,
config: { ...effectiveView.config, choiceOrder: next },
});
},
[base.id, columns, effectiveView, updateViewMutation],
);
const handleHideColumn = useCallback(
(columnKey: string) => {
if (!effectiveView) return;
const current = effectiveView.config?.hiddenChoiceIds ?? [];
if (current.includes(columnKey)) return;
updateViewMutation.mutate({
viewId: effectiveView.id,
pageId: base.id,
config: {
...effectiveView.config,
hiddenChoiceIds: [...current, columnKey],
},
});
},
[base.id, effectiveView, updateViewMutation],
);
const handleShowColumn = useCallback(
(columnKey: string) => {
if (!effectiveView) return;
const next = (effectiveView.config?.hiddenChoiceIds ?? []).filter(
(id) => id !== columnKey,
);
updateViewMutation.mutate({
viewId: effectiveView.id,
pageId: base.id,
config: { ...effectiveView.config, hiddenChoiceIds: next },
});
},
[base.id, effectiveView, updateViewMutation],
);
const hiddenIds = effectiveView?.config?.hiddenChoiceIds ?? [];
const hiddenChoices = useMemo(() => {
if (!isGroupable || hiddenIds.length === 0) return [];
const opts = (property!.typeOptions as { choices?: Array<{ id: string; name: string; color: string }> } | undefined) ?? {};
const choices = opts.choices ?? [];
const byId = new Map(choices.map((c) => [c.id, c]));
return hiddenIds
.map((id) =>
id === NO_VALUE_CHOICE_ID
? { id, name: t("No value"), color: null as string | null }
: byId.get(id)
? { id, name: byId.get(id)!.name, color: byId.get(id)!.color as string | null }
: null,
)
.filter((c): c is { id: string; name: string; color: string | null } => c !== null);
}, [hiddenIds, isGroupable, property, t]);
if (!isGroupable) {
return <KanbanEmptyState base={base} onPick={handlePickProperty} />;
}
return (
<>
{hiddenChoices.length > 0 && (
<div className={classes.hiddenStrip}>
{hiddenChoices.map((c) => (
<Badge
key={c.id}
color={c.color ?? "gray"}
variant="outline"
className={classes.hiddenChip}
onClick={() => handleShowColumn(c.id)}
rightSection={<IconPlus size={12} />}
>
{c.name}
</Badge>
))}
</div>
)}
{sortsActive && (
<div className={classes.sortHint}>
{t("Sorted — cards within a column can't be reordered.")}
</div>
)}
<div className={classes.board}>
{columns.map((column) => (
<KanbanColumn
key={column.key}
column={column}
primaryProperty={primaryProperty}
onCardClick={onCardClick}
onAddCard={handleAddCard}
onCardDrop={handleCardDrop}
onColumnReorder={handleColumnReorder}
onHide={handleHideColumn}
sortsActive={sortsActive}
/>
))}
</div>
</>
);
}