From b5987e931c4ccb6160ec9394337b9c34d44ef18b Mon Sep 17 00:00:00 2001 From: kumarabhirup Date: Wed, 4 Mar 2026 11:03:27 -0800 Subject: [PATCH] feat(web): add multi-view schema and settings persistence --- .../app/api/workspace/objects/[name]/route.ts | 3 +- .../workspace/objects/[name]/views/route.ts | 16 +-- .../app/workspace/object-view-active-view.ts | 23 +++- apps/web/lib/object-filters.ts | 117 ++++++++++++++++++ apps/web/lib/workspace.ts | 18 ++- 5 files changed, 163 insertions(+), 14 deletions(-) diff --git a/apps/web/app/api/workspace/objects/[name]/route.ts b/apps/web/app/api/workspace/objects/[name]/route.ts index 2e3d26a4f88..c36b8a7f86f 100644 --- a/apps/web/app/api/workspace/objects/[name]/route.ts +++ b/apps/web/app/api/workspace/objects/[name]/route.ts @@ -473,7 +473,7 @@ export async function GET( const effectiveDisplayField = resolveDisplayField(obj, fields); // Include saved views from .object.yaml - const { views: savedViews, activeView } = getObjectViews(name); + const { views: savedViews, activeView, viewSettings } = getObjectViews(name); return Response.json({ object: obj, @@ -485,6 +485,7 @@ export async function GET( effectiveDisplayField, savedViews, activeView, + viewSettings, totalCount, page, pageSize, diff --git a/apps/web/app/api/workspace/objects/[name]/views/route.ts b/apps/web/app/api/workspace/objects/[name]/views/route.ts index 32e6ae53b0b..bfdd02a600a 100644 --- a/apps/web/app/api/workspace/objects/[name]/views/route.ts +++ b/apps/web/app/api/workspace/objects/[name]/views/route.ts @@ -1,21 +1,21 @@ import { NextResponse } from "next/server"; import { getObjectViews, saveObjectViews } from "@/lib/workspace"; -import type { SavedView } from "@/lib/object-filters"; +import type { SavedView, ViewTypeSettings } from "@/lib/object-filters"; type Params = { params: Promise<{ name: string }> }; /** * GET /api/workspace/objects/[name]/views * - * Returns saved views and active_view from the object's .object.yaml. + * Returns saved views, active_view, and view_settings from the object's .object.yaml. */ export async function GET(_req: Request, ctx: Params) { const { name } = await ctx.params; const objectName = decodeURIComponent(name); try { - const { views, activeView } = getObjectViews(objectName); - return NextResponse.json({ views, activeView }); + const { views, activeView, viewSettings } = getObjectViews(objectName); + return NextResponse.json({ views, activeView, viewSettings }); } catch (err) { return NextResponse.json( { error: `Failed to read views: ${err instanceof Error ? err.message : String(err)}` }, @@ -27,8 +27,8 @@ export async function GET(_req: Request, ctx: Params) { /** * PUT /api/workspace/objects/[name]/views * - * Save views and active_view to the object's .object.yaml. - * Body: { views: SavedView[], activeView?: string } + * Save views, active_view, and view_settings to the object's .object.yaml. + * Body: { views: SavedView[], activeView?: string, viewSettings?: ViewTypeSettings } */ export async function PUT(req: Request, ctx: Params) { const { name } = await ctx.params; @@ -38,12 +38,14 @@ export async function PUT(req: Request, ctx: Params) { const body = (await req.json()) as { views?: SavedView[]; activeView?: string; + viewSettings?: ViewTypeSettings; }; const views = body.views ?? []; const activeView = body.activeView; + const viewSettings = body.viewSettings; - const ok = saveObjectViews(objectName, views, activeView); + const ok = saveObjectViews(objectName, views, activeView, viewSettings); if (!ok) { return NextResponse.json( { error: "Object directory not found" }, diff --git a/apps/web/app/workspace/object-view-active-view.ts b/apps/web/app/workspace/object-view-active-view.ts index 76eee1ab04e..616670b4a31 100644 --- a/apps/web/app/workspace/object-view-active-view.ts +++ b/apps/web/app/workspace/object-view-active-view.ts @@ -1,6 +1,8 @@ import { type FilterGroup, type SavedView, + type ViewType, + type ViewTypeSettings, emptyFilterGroup, serializeFilters, } from "@/lib/object-filters"; @@ -22,11 +24,22 @@ function areFiltersEqual(a: FilterGroup, b: FilterGroup): boolean { return serializeFilters(a) === serializeFilters(b); } +function areSettingsEqual( + a: ViewTypeSettings | undefined, + b: ViewTypeSettings | undefined, +): boolean { + if (!a && !b) {return true;} + if (!a || !b) {return false;} + return JSON.stringify(a) === JSON.stringify(b); +} + export type ActiveViewSyncDecision = { shouldApply: boolean; nextFilters: FilterGroup; nextColumns: string[] | undefined; nextActiveViewName: string; + nextViewType: ViewType | undefined; + nextSettings: ViewTypeSettings | undefined; }; export function resolveActiveViewSyncDecision(params: { @@ -35,6 +48,8 @@ export function resolveActiveViewSyncDecision(params: { currentActiveViewName: string | undefined; currentFilters: FilterGroup; currentViewColumns: string[] | undefined; + currentViewType?: ViewType; + currentSettings?: ViewTypeSettings; }): ActiveViewSyncDecision | null { const activeView = params.activeView; if (!activeView) {return null;} @@ -45,15 +60,21 @@ export function resolveActiveViewSyncDecision(params: { const nextFilters = view.filters ?? emptyFilterGroup(); const nextColumns = view.columns; const nextActiveViewName = view.name; + const nextViewType = view.view_type; + const nextSettings = view.settings; const nameMismatch = params.currentActiveViewName !== nextActiveViewName; const filterMismatch = !areFiltersEqual(params.currentFilters, nextFilters); const columnMismatch = !areColumnsEqual(params.currentViewColumns, nextColumns); + const viewTypeMismatch = params.currentViewType !== nextViewType; + const settingsMismatch = !areSettingsEqual(params.currentSettings, nextSettings); return { - shouldApply: nameMismatch || filterMismatch || columnMismatch, + shouldApply: nameMismatch || filterMismatch || columnMismatch || viewTypeMismatch || settingsMismatch, nextFilters, nextColumns, nextActiveViewName, + nextViewType, + nextSettings, }; } diff --git a/apps/web/lib/object-filters.ts b/apps/web/lib/object-filters.ts index 09c858861da..062ab3e7a78 100644 --- a/apps/web/lib/object-filters.ts +++ b/apps/web/lib/object-filters.ts @@ -73,13 +73,130 @@ export type SortRule = { direction: "asc" | "desc"; }; +// --------------------------------------------------------------------------- +// View types +// --------------------------------------------------------------------------- + +export type ViewType = "table" | "kanban" | "calendar" | "timeline" | "gallery" | "list"; + +export const VIEW_TYPES: ViewType[] = ["table", "kanban", "calendar", "timeline", "gallery", "list"]; + +export type CalendarMode = "day" | "week" | "month" | "year"; + +export type TimelineZoom = "day" | "week" | "month" | "quarter"; + +export type ViewTypeSettings = { + kanbanField?: string; + calendarDateField?: string; + calendarEndDateField?: string; + calendarMode?: CalendarMode; + timelineStartField?: string; + timelineEndField?: string; + timelineGroupField?: string; + timelineZoom?: TimelineZoom; + galleryCoverField?: string; + galleryTitleField?: string; + listTitleField?: string; + listSubtitleField?: string; +}; + export type SavedView = { name: string; + view_type?: ViewType; filters?: FilterGroup; sort?: SortRule[]; columns?: string[]; + settings?: ViewTypeSettings; }; +// --------------------------------------------------------------------------- +// View type resolution +// --------------------------------------------------------------------------- + +function isValidViewType(v: unknown): v is ViewType { + return typeof v === "string" && VIEW_TYPES.includes(v as ViewType); +} + +/** + * Resolve the effective view type: saved view override > explicit current > object default > "table". + */ +export function resolveViewType( + savedView: SavedView | undefined, + currentViewType: ViewType | undefined, + objectDefault: string | undefined, +): ViewType { + if (savedView?.view_type && isValidViewType(savedView.view_type)) { + return savedView.view_type; + } + if (currentViewType && isValidViewType(currentViewType)) { + return currentViewType; + } + if (objectDefault && isValidViewType(objectDefault)) { + return objectDefault; + } + return "table"; +} + +/** + * Merge view-type settings: saved view settings override object-level defaults. + */ +export function resolveViewSettings( + savedViewSettings: ViewTypeSettings | undefined, + objectDefaults: ViewTypeSettings | undefined, +): ViewTypeSettings { + if (!objectDefaults) {return savedViewSettings ?? {};} + if (!savedViewSettings) {return objectDefaults;} + const merged: ViewTypeSettings = { ...objectDefaults }; + for (const key of Object.keys(savedViewSettings) as (keyof ViewTypeSettings)[]) { + const val = savedViewSettings[key]; + if (val !== undefined) { + (merged as Record)[key] = val; + } + } + return merged; +} + +/** + * Auto-detect a reasonable field for a view type from the available fields. + * Used when no explicit setting is configured. + */ +export function autoDetectViewField( + viewType: ViewType, + settingKey: keyof ViewTypeSettings, + fields: FieldMeta[], +): string | undefined { + switch (settingKey) { + case "kanbanField": + return ( + fields.find((f) => f.type === "enum" && /status/i.test(f.name))?.name ?? + fields.find((f) => f.type === "enum")?.name + ); + case "calendarDateField": + case "timelineStartField": + return ( + fields.find((f) => f.type === "date" && /due|start|begin/i.test(f.name))?.name ?? + fields.find((f) => f.type === "date")?.name + ); + case "calendarEndDateField": + case "timelineEndField": + return fields.find((f) => f.type === "date" && /end|finish|close/i.test(f.name))?.name; + case "timelineGroupField": + return fields.find((f) => f.type === "enum")?.name; + case "galleryTitleField": + case "listTitleField": + return ( + fields.find((f) => f.type === "text" && /name|title/i.test(f.name))?.name ?? + fields.find((f) => f.type === "text")?.name + ); + case "galleryCoverField": + return undefined; + case "listSubtitleField": + return fields.find((f) => f.type === "text" && !/name|title/i.test(f.name))?.name; + default: + return undefined; + } +} + /** Minimal field descriptor needed by the filter system. */ export type FieldMeta = { name: string; diff --git a/apps/web/lib/workspace.ts b/apps/web/lib/workspace.ts index 81b8519bb01..f0d5e1d26fd 100644 --- a/apps/web/lib/workspace.ts +++ b/apps/web/lib/workspace.ts @@ -4,7 +4,7 @@ import { promisify } from "node:util"; import { join, resolve, normalize, relative } from "node:path"; import { homedir } from "node:os"; import YAML from "yaml"; -import { normalizeFilterGroup, type SavedView } from "./object-filters"; +import { normalizeFilterGroup, type SavedView, type ViewTypeSettings } from "./object-filters"; const execAsync = promisify(exec); @@ -954,6 +954,7 @@ export function parseSimpleYaml( export type ObjectYamlConfig = { icon?: string; default_view?: string; + view_settings?: ViewTypeSettings; views?: SavedView[]; active_view?: string; /** Any other top-level keys. */ @@ -1051,12 +1052,13 @@ export function findObjectDir(objectName: string): string | null { export function getObjectViews(objectName: string): { views: SavedView[]; activeView: string | undefined; + viewSettings: ViewTypeSettings | undefined; } { const dir = findObjectDir(objectName); - if (!dir) {return { views: [], activeView: undefined };} + if (!dir) {return { views: [], activeView: undefined, viewSettings: undefined };} const config = readObjectYaml(dir); - if (!config) {return { views: [], activeView: undefined };} + if (!config) {return { views: [], activeView: undefined, viewSettings: undefined };} return { views: (config.views ?? []).map((v) => ({ @@ -1064,6 +1066,7 @@ export function getObjectViews(objectName: string): { filters: v.filters ? normalizeFilterGroup(v.filters) : undefined, })), activeView: config.active_view, + viewSettings: config.view_settings, }; } @@ -1074,14 +1077,19 @@ export function saveObjectViews( objectName: string, views: SavedView[], activeView?: string, + viewSettings?: ViewTypeSettings, ): boolean { const dir = findObjectDir(objectName); if (!dir) {return false;} - writeObjectYaml(dir, { + const patch: ObjectYamlConfig = { views: views.length > 0 ? views : undefined, active_view: activeView, - }); + }; + if (viewSettings) { + patch.view_settings = viewSettings; + } + writeObjectYaml(dir, patch); return true; }