mirror of
https://github.com/docmost/docmost.git
synced 2026-06-16 06:57:01 +08:00
274 lines
8.9 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|