mirror of
https://github.com/docmost/docmost.git
synced 2026-08-28 17:27:06 +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