feat(web): add multi-view schema and settings persistence
This commit is contained in:
parent
68015d6c14
commit
b5987e931c
@ -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,
|
||||
|
||||
@ -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" },
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user