From a7a89a990e3772658fbaa2e7f38f558c904e4263 Mon Sep 17 00:00:00 2001 From: kumarabhirup Date: Mon, 2 Mar 2026 18:35:41 -0800 Subject: [PATCH] feat(web): add object-view-active-view module for workspace page --- .../workspace/object-view-active-view.test.ts | 92 +++++++++++++++++++ .../app/workspace/object-view-active-view.ts | 59 ++++++++++++ 2 files changed, 151 insertions(+) create mode 100644 apps/web/app/workspace/object-view-active-view.test.ts create mode 100644 apps/web/app/workspace/object-view-active-view.ts diff --git a/apps/web/app/workspace/object-view-active-view.test.ts b/apps/web/app/workspace/object-view-active-view.test.ts new file mode 100644 index 00000000000..34b4a9f1279 --- /dev/null +++ b/apps/web/app/workspace/object-view-active-view.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vitest"; +import { emptyFilterGroup, type FilterGroup, type SavedView } from "@/lib/object-filters"; +import { resolveActiveViewSyncDecision } from "./object-view-active-view"; + +function statusFilter(value: string): FilterGroup { + return { + id: "root", + conjunction: "and", + rules: [ + { id: "rule-status", field: "Status", operator: "is", value }, + ], + }; +} + +describe("resolveActiveViewSyncDecision", () => { + const importantView: SavedView = { + name: "Important", + filters: statusFilter("Important"), + columns: ["Name", "Status", "Owner"], + }; + + it("applies active view on initial load even when active view name already matches (prevents visible-but-unapplied bug)", () => { + const decision = resolveActiveViewSyncDecision({ + savedViews: [importantView], + activeView: "Important", + currentActiveViewName: "Important", + currentFilters: emptyFilterGroup(), + currentViewColumns: undefined, + }); + + expect(decision).not.toBeNull(); + expect(decision?.shouldApply).toBe(true); + expect(decision?.nextFilters).toEqual(statusFilter("Important")); + expect(decision?.nextColumns).toEqual(["Name", "Status", "Owner"]); + }); + + it("does not re-apply when name, filters, and columns are already aligned", () => { + const decision = resolveActiveViewSyncDecision({ + savedViews: [importantView], + activeView: "Important", + currentActiveViewName: "Important", + currentFilters: statusFilter("Important"), + currentViewColumns: ["Name", "Status", "Owner"], + }); + + expect(decision).not.toBeNull(); + expect(decision?.shouldApply).toBe(false); + }); + + it("re-applies when active view changes during refresh (keeps label and table state in sync)", () => { + const backlogView: SavedView = { + name: "Backlog", + filters: statusFilter("Backlog"), + columns: ["Name", "Status"], + }; + + const decision = resolveActiveViewSyncDecision({ + savedViews: [importantView, backlogView], + activeView: "Backlog", + currentActiveViewName: "Important", + currentFilters: statusFilter("Important"), + currentViewColumns: ["Name", "Status", "Owner"], + }); + + expect(decision).not.toBeNull(); + expect(decision?.shouldApply).toBe(true); + expect(decision?.nextActiveViewName).toBe("Backlog"); + expect(decision?.nextFilters).toEqual(statusFilter("Backlog")); + expect(decision?.nextColumns).toEqual(["Name", "Status"]); + }); + + it("returns null when active view is missing or cannot be resolved", () => { + const missingActive = resolveActiveViewSyncDecision({ + savedViews: [importantView], + activeView: undefined, + currentActiveViewName: "Important", + currentFilters: statusFilter("Important"), + currentViewColumns: ["Name", "Status", "Owner"], + }); + + const unknownActive = resolveActiveViewSyncDecision({ + savedViews: [importantView], + activeView: "Unknown", + currentActiveViewName: "Important", + currentFilters: statusFilter("Important"), + currentViewColumns: ["Name", "Status", "Owner"], + }); + + expect(missingActive).toBeNull(); + expect(unknownActive).toBeNull(); + }); +}); diff --git a/apps/web/app/workspace/object-view-active-view.ts b/apps/web/app/workspace/object-view-active-view.ts new file mode 100644 index 00000000000..76eee1ab04e --- /dev/null +++ b/apps/web/app/workspace/object-view-active-view.ts @@ -0,0 +1,59 @@ +import { + type FilterGroup, + type SavedView, + emptyFilterGroup, + serializeFilters, +} from "@/lib/object-filters"; + +function areColumnsEqual( + a: string[] | undefined, + b: string[] | undefined, +): boolean { + if (!a && !b) {return true;} + if (!a || !b) {return false;} + if (a.length !== b.length) {return false;} + for (let i = 0; i < a.length; i += 1) { + if (a[i] !== b[i]) {return false;} + } + return true; +} + +function areFiltersEqual(a: FilterGroup, b: FilterGroup): boolean { + return serializeFilters(a) === serializeFilters(b); +} + +export type ActiveViewSyncDecision = { + shouldApply: boolean; + nextFilters: FilterGroup; + nextColumns: string[] | undefined; + nextActiveViewName: string; +}; + +export function resolveActiveViewSyncDecision(params: { + savedViews: SavedView[] | undefined; + activeView: string | undefined; + currentActiveViewName: string | undefined; + currentFilters: FilterGroup; + currentViewColumns: string[] | undefined; +}): ActiveViewSyncDecision | null { + const activeView = params.activeView; + if (!activeView) {return null;} + + const view = (params.savedViews ?? []).find((candidate) => candidate.name === activeView); + if (!view) {return null;} + + const nextFilters = view.filters ?? emptyFilterGroup(); + const nextColumns = view.columns; + const nextActiveViewName = view.name; + + const nameMismatch = params.currentActiveViewName !== nextActiveViewName; + const filterMismatch = !areFiltersEqual(params.currentFilters, nextFilters); + const columnMismatch = !areColumnsEqual(params.currentViewColumns, nextColumns); + + return { + shouldApply: nameMismatch || filterMismatch || columnMismatch, + nextFilters, + nextColumns, + nextActiveViewName, + }; +}