mirror of
https://github.com/docmost/docmost.git
synced 2026-06-10 18:16:57 +08:00
feat(bases): add useKanbanGroups partitioning hook
This commit is contained in:
@@ -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],
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user