feat(bases): add useKanbanGroups partitioning hook

This commit is contained in:
Philipinho
2026-05-24 13:09:26 +01:00
parent f75779951e
commit a60de83e57
2 changed files with 233 additions and 0 deletions
@@ -0,0 +1,103 @@
import { describe, it, expect } from "vitest";
import { partitionRowsByGroup } from "../use-kanban-groups";
import { NO_VALUE_CHOICE_ID } from "@/features/base/types/base.types";
describe("partitionRowsByGroup", () => {
const property = {
id: "p1",
type: "status",
typeOptions: {
choices: [
{ id: "c1", name: "Todo", color: "blue" },
{ id: "c2", name: "Done", color: "green" },
],
choiceOrder: ["c1", "c2"],
},
} as any;
const rows = [
{ id: "r1", cells: { p1: "c1" }, position: "a0" },
{ id: "r2", cells: { p1: "c2" }, position: "a1" },
{ id: "r3", cells: {}, position: "a2" },
{ id: "r4", cells: { p1: "c1" }, position: "a3" },
] as any;
it("groups rows under the choice id their cell points at", () => {
const result = partitionRowsByGroup(rows, property, undefined, undefined);
expect(result.columns.map((c) => c.key)).toEqual([
NO_VALUE_CHOICE_ID,
"c1",
"c2",
]);
expect(result.columns[1].rows.map((r) => r.id)).toEqual(["r1", "r4"]);
expect(result.columns[2].rows.map((r) => r.id)).toEqual(["r2"]);
});
it("puts rows without the cell into the NO_VALUE column", () => {
const result = partitionRowsByGroup(rows, property, undefined, undefined);
expect(result.columns[0].key).toBe(NO_VALUE_CHOICE_ID);
expect(result.columns[0].rows.map((r) => r.id)).toEqual(["r3"]);
});
it("hides columns listed in hiddenChoiceIds", () => {
const result = partitionRowsByGroup(rows, property, ["c2"], undefined);
expect(result.columns.map((c) => c.key)).toEqual([NO_VALUE_CHOICE_ID, "c1"]);
});
it("respects an override choiceOrder", () => {
const result = partitionRowsByGroup(
rows,
property,
undefined,
["c2", "c1", NO_VALUE_CHOICE_ID],
);
expect(result.columns.map((c) => c.key)).toEqual([
"c2",
"c1",
NO_VALUE_CHOICE_ID,
]);
});
it("appends newly-added choices (missing from override choiceOrder) at the end", () => {
const result = partitionRowsByGroup(
rows,
property,
undefined,
["c1"], // missing c2 and NO_VALUE
);
expect(result.columns.map((c) => c.key)).toEqual([
"c1",
NO_VALUE_CHOICE_ID,
"c2",
]);
});
it("drops entries in choiceOrder that no longer exist on the property", () => {
const result = partitionRowsByGroup(
rows,
property,
undefined,
["c1", "deleted-choice", "c2"],
);
expect(result.columns.map((c) => c.key)).toEqual(["c1", "c2"]);
});
it("returns null columns when groupByPropertyId is unset", () => {
const result = partitionRowsByGroup(rows, undefined, undefined, undefined);
expect(result.columns).toEqual([]);
});
it("preserves row order within a column (input order)", () => {
const rowsOutOfOrder = [
{ id: "r1", cells: { p1: "c1" }, position: "b" },
{ id: "r2", cells: { p1: "c1" }, position: "a" },
] as any;
const result = partitionRowsByGroup(
rowsOutOfOrder,
property,
undefined,
undefined,
);
expect(result.columns[1].rows.map((r) => r.id)).toEqual(["r1", "r2"]);
});
});
@@ -0,0 +1,130 @@
import { useMemo } from "react";
import {
IBaseProperty,
IBaseRow,
NO_VALUE_CHOICE_ID,
SelectTypeOptions,
} from "@/features/base/types/base.types";
export type KanbanColumn = {
key: string; // choice id or NO_VALUE_CHOICE_ID
choiceId: string | null; // null for NO_VALUE column
name: string;
color: string | null;
rows: IBaseRow[];
};
export type PartitionResult = {
columns: KanbanColumn[];
groupByPropertyId: string | null;
};
function readChoiceOptions(property: IBaseProperty | undefined): {
ids: string[];
byId: Map<string, { id: string; name: string; color: string }>;
} {
if (!property) return { ids: [], byId: new Map() };
const opts = (property.typeOptions ?? {}) as Partial<SelectTypeOptions>;
const choices = opts.choices ?? [];
const order = (opts.choiceOrder ?? []).filter((id) =>
choices.some((c) => c.id === id),
);
// Any choice not in order: append in choices-array order.
const ordered = [
...order,
...choices.filter((c) => !order.includes(c.id)).map((c) => c.id),
];
const byId = new Map<string, { id: string; name: string; color: string }>(
choices.map((c) => [c.id, c]),
);
return { ids: ordered, byId };
}
export function partitionRowsByGroup(
rows: IBaseRow[],
property: IBaseProperty | undefined,
hiddenChoiceIds: string[] | undefined,
choiceOrderOverride: string[] | undefined,
): PartitionResult {
if (!property) return { columns: [], groupByPropertyId: null };
const { ids: propertyChoiceIds, byId } = readChoiceOptions(property);
// Resolve column key order.
let order: string[];
if (choiceOrderOverride && choiceOrderOverride.length > 0) {
const valid = new Set<string>([...propertyChoiceIds, NO_VALUE_CHOICE_ID]);
const fromOverride = choiceOrderOverride.filter((id) => valid.has(id));
const overrideSet = new Set(fromOverride);
const missingChoices = propertyChoiceIds.filter(
(id) => !overrideSet.has(id),
);
// Only inject NO_VALUE implicitly when there are newly-discovered choices
// to append — when the override fully covers the current property, leave
// NO_VALUE off unless the user listed it explicitly.
const tail =
missingChoices.length > 0
? overrideSet.has(NO_VALUE_CHOICE_ID)
? missingChoices
: [NO_VALUE_CHOICE_ID, ...missingChoices]
: [];
order = [...fromOverride, ...tail];
} else {
order = [NO_VALUE_CHOICE_ID, ...propertyChoiceIds];
}
const hidden = new Set(hiddenChoiceIds ?? []);
order = order.filter((id) => !hidden.has(id));
// Build empty buckets first so empty columns still render.
const buckets = new Map<string, IBaseRow[]>();
for (const key of order) buckets.set(key, []);
for (const row of rows) {
const value = (row.cells ?? {})[property.id];
const key =
typeof value === "string" && buckets.has(value)
? value
: NO_VALUE_CHOICE_ID;
if (!buckets.has(key)) continue; // hidden
buckets.get(key)!.push(row);
}
const columns: KanbanColumn[] = order.map((key) => {
if (key === NO_VALUE_CHOICE_ID) {
return {
key,
choiceId: null,
name: "No value",
color: null,
rows: buckets.get(key) ?? [],
};
}
const c = byId.get(key);
return {
key,
choiceId: key,
name: c?.name ?? "",
color: c?.color ?? null,
rows: buckets.get(key) ?? [],
};
});
return { columns, groupByPropertyId: property.id };
}
export function useKanbanGroups(
rows: IBaseRow[],
property: IBaseProperty | undefined,
hiddenChoiceIds: string[] | undefined,
choiceOrderOverride: string[] | undefined,
): PartitionResult {
return useMemo(
() =>
partitionRowsByGroup(
rows,
property,
hiddenChoiceIds,
choiceOrderOverride,
),
[rows, property, hiddenChoiceIds, choiceOrderOverride],
);
}