feat(web): add multi-view schema and settings persistence

This commit is contained in:
kumarabhirup 2026-03-04 11:03:27 -08:00
parent 68015d6c14
commit b5987e931c
No known key found for this signature in database
GPG Key ID: DB7CA2289CAB0167
5 changed files with 163 additions and 14 deletions

View File

@ -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,

View File

@ -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" },

View File

@ -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,
};
}

View File

@ -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<string, unknown>)[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;

View File

@ -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;
}