diff --git a/apps/client/src/features/base/hooks/__tests__/use-kanban-groups.spec.ts b/apps/client/src/features/base/hooks/__tests__/use-kanban-groups.spec.ts new file mode 100644 index 000000000..b47ae1a8f --- /dev/null +++ b/apps/client/src/features/base/hooks/__tests__/use-kanban-groups.spec.ts @@ -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"]); + }); +}); diff --git a/apps/client/src/features/base/hooks/use-kanban-groups.ts b/apps/client/src/features/base/hooks/use-kanban-groups.ts new file mode 100644 index 000000000..554fe9bf1 --- /dev/null +++ b/apps/client/src/features/base/hooks/use-kanban-groups.ts @@ -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; +} { + if (!property) return { ids: [], byId: new Map() }; + const opts = (property.typeOptions ?? {}) as Partial; + 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( + 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([...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(); + 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], + ); +}