🚀 RELEASE: full table filter
This commit is contained in:
parent
a5343992dc
commit
b7baae188c
@ -227,7 +227,7 @@ describe("Web Sessions API", () => {
|
||||
|
||||
describe("POST /api/web-sessions/[id]/messages", () => {
|
||||
it("appends messages to session file", async () => {
|
||||
const { existsSync: mockExists, readFileSync: mockReadFile, writeFileSync: mockWrite } = await import("node:fs");
|
||||
const { existsSync: mockExists, readFileSync: mockReadFile, writeFileSync: _mockWrite } = await import("node:fs");
|
||||
vi.mocked(mockExists).mockReturnValue(true);
|
||||
vi.mocked(mockReadFile).mockImplementation((p) => {
|
||||
const s = String(p);
|
||||
@ -251,7 +251,7 @@ describe("Web Sessions API", () => {
|
||||
});
|
||||
|
||||
it("auto-creates session file if missing", async () => {
|
||||
const { existsSync: mockExists, readFileSync: mockReadFile, writeFileSync: mockWrite } = await import("node:fs");
|
||||
const { existsSync: mockExists, readFileSync: mockReadFile, writeFileSync: _mockWrite } = await import("node:fs");
|
||||
vi.mocked(mockExists).mockImplementation((p) => {
|
||||
const s = String(p);
|
||||
if (s.endsWith(".jsonl")) {return false;}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { duckdbPath, parseRelationValue, resolveDuckdbBin, findDuckDBForObject, duckdbQueryOnFile, discoverDuckDBPaths } from "@/lib/workspace";
|
||||
import { duckdbPath, parseRelationValue, resolveDuckdbBin, findDuckDBForObject, duckdbQueryOnFile, discoverDuckDBPaths, getObjectViews } from "@/lib/workspace";
|
||||
import { deserializeFilters, buildWhereClause, buildOrderByClause, type FieldMeta } from "@/lib/object-filters";
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@ -372,16 +373,65 @@ export async function GET(
|
||||
`SELECT * FROM statuses WHERE object_id = '${obj.id}' ORDER BY sort_order`,
|
||||
);
|
||||
|
||||
// --- Parse filter/sort/pagination query params ---
|
||||
const url = new URL(_req.url);
|
||||
const filtersParam = url.searchParams.get("filters");
|
||||
const sortParam = url.searchParams.get("sort");
|
||||
const searchParam = url.searchParams.get("search");
|
||||
const pageParam = url.searchParams.get("page");
|
||||
const pageSizeParam = url.searchParams.get("pageSize");
|
||||
|
||||
const filterGroup = filtersParam ? deserializeFilters(filtersParam) : undefined;
|
||||
const fieldsMeta: FieldMeta[] = fields.map((f) => ({ name: f.name, type: f.type }));
|
||||
|
||||
// Build WHERE clause from filters
|
||||
let whereClause = "";
|
||||
if (filterGroup) {
|
||||
const where = buildWhereClause(filterGroup, fieldsMeta);
|
||||
if (where) {whereClause = ` WHERE ${where}`;}
|
||||
}
|
||||
|
||||
// Build ORDER BY clause
|
||||
let orderByClause = " ORDER BY created_at DESC";
|
||||
if (sortParam) {
|
||||
try {
|
||||
const sortRules = JSON.parse(sortParam);
|
||||
const orderBy = buildOrderByClause(sortRules);
|
||||
if (orderBy) {orderByClause = ` ORDER BY ${orderBy}`;}
|
||||
} catch {
|
||||
// keep default sort
|
||||
}
|
||||
}
|
||||
|
||||
// Pagination
|
||||
const page = Math.max(1, Number(pageParam) || 1);
|
||||
const pageSize = Math.min(500, Math.max(1, Number(pageSizeParam) || 200));
|
||||
const offset = (page - 1) * pageSize;
|
||||
const limitClause = ` LIMIT ${pageSize} OFFSET ${offset}`;
|
||||
|
||||
// Full-text search across text fields
|
||||
if (searchParam && searchParam.trim()) {
|
||||
const textFields = fields.filter((f) => ["text", "richtext", "email"].includes(f.type));
|
||||
if (textFields.length > 0) {
|
||||
const searchConditions = textFields
|
||||
.map((f) => `LOWER(CAST("${f.name.replace(/"/g, '""')}" AS VARCHAR)) LIKE '%${sqlEscape(searchParam.toLowerCase())}%'`)
|
||||
.join(" OR ");
|
||||
whereClause = whereClause
|
||||
? `${whereClause} AND (${searchConditions})`
|
||||
: ` WHERE (${searchConditions})`;
|
||||
}
|
||||
}
|
||||
|
||||
// Try the PIVOT view first, then fall back to raw EAV query + client-side pivot
|
||||
let entries: Record<string, unknown>[] = [];
|
||||
|
||||
const pivotEntries = q(dbFile,
|
||||
`SELECT * FROM v_${name} ORDER BY created_at DESC LIMIT 200`,
|
||||
);
|
||||
|
||||
if (pivotEntries.length > 0) {
|
||||
try {
|
||||
const pivotEntries = q(dbFile,
|
||||
`SELECT * FROM v_${name}${whereClause}${orderByClause}${limitClause}`,
|
||||
);
|
||||
entries = pivotEntries;
|
||||
} else {
|
||||
} catch {
|
||||
// Pivot view might not exist or filter SQL may not apply; fall back
|
||||
const rawRows = q<EavRow>(dbFile,
|
||||
`SELECT e.id as entry_id, e.created_at, e.updated_at,
|
||||
f.name as field_name, ef.value
|
||||
@ -392,7 +442,6 @@ export async function GET(
|
||||
ORDER BY e.created_at DESC
|
||||
LIMIT 5000`,
|
||||
);
|
||||
|
||||
entries = pivotEavRows(rawRows);
|
||||
}
|
||||
|
||||
@ -415,6 +464,9 @@ export async function GET(
|
||||
|
||||
const effectiveDisplayField = resolveDisplayField(obj, fields);
|
||||
|
||||
// Include saved views from .object.yaml
|
||||
const { views: savedViews, activeView } = getObjectViews(name);
|
||||
|
||||
return Response.json({
|
||||
object: obj,
|
||||
fields: enrichedFields,
|
||||
@ -423,5 +475,7 @@ export async function GET(
|
||||
relationLabels,
|
||||
reverseRelations,
|
||||
effectiveDisplayField,
|
||||
savedViews,
|
||||
activeView,
|
||||
});
|
||||
}
|
||||
|
||||
61
apps/web/app/api/workspace/objects/[name]/views/route.ts
Normal file
61
apps/web/app/api/workspace/objects/[name]/views/route.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getObjectViews, saveObjectViews } from "@/lib/workspace";
|
||||
import type { SavedView } 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.
|
||||
*/
|
||||
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 });
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to read views: ${err instanceof Error ? err.message : String(err)}` },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/workspace/objects/[name]/views
|
||||
*
|
||||
* Save views and active_view to the object's .object.yaml.
|
||||
* Body: { views: SavedView[], activeView?: string }
|
||||
*/
|
||||
export async function PUT(req: Request, ctx: Params) {
|
||||
const { name } = await ctx.params;
|
||||
const objectName = decodeURIComponent(name);
|
||||
|
||||
try {
|
||||
const body = (await req.json()) as {
|
||||
views?: SavedView[];
|
||||
activeView?: string;
|
||||
};
|
||||
|
||||
const views = body.views ?? [];
|
||||
const activeView = body.activeView;
|
||||
|
||||
const ok = saveObjectViews(objectName, views, activeView);
|
||||
if (!ok) {
|
||||
return NextResponse.json(
|
||||
{ error: "Object directory not found" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to save views: ${err instanceof Error ? err.message : String(err)}` },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1172,24 +1172,28 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
|
||||
|
||||
// ── Stop handler (aborts server-side run + client-side stream) ──
|
||||
const handleStop = useCallback(async () => {
|
||||
// Abort the server-side agent run
|
||||
if (currentSessionId) {
|
||||
fetch("/api/chat/stop", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
sessionId: currentSessionId,
|
||||
}),
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
// Abort reconnection stream if active
|
||||
// Abort reconnection stream if active (immediate visual feedback).
|
||||
reconnectAbortRef.current?.abort();
|
||||
setIsReconnecting(false);
|
||||
|
||||
// Stop the useChat transport stream
|
||||
// Stop the server-side agent run and wait for confirmation so the
|
||||
// session is no longer in "running" state before we stop the
|
||||
// client-side stream (which may trigger queued message flush).
|
||||
if (currentSessionId) {
|
||||
try {
|
||||
await fetch("/api/chat/stop", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
sessionId: currentSessionId,
|
||||
}),
|
||||
});
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// Stop the useChat transport stream (transitions status → "ready").
|
||||
void stop();
|
||||
}, [currentSessionId, stop]);
|
||||
|
||||
|
||||
@ -178,6 +178,12 @@ export function DataTable<TData, TValue>({
|
||||
const [globalFilter, setGlobalFilter] = useState("");
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(initialColumnVisibility ?? {});
|
||||
// Sync column visibility when the prop changes (e.g. loading a saved view)
|
||||
useEffect(() => {
|
||||
if (initialColumnVisibility) {
|
||||
setColumnVisibility(initialColumnVisibility);
|
||||
}
|
||||
}, [initialColumnVisibility]);
|
||||
const [internalRowSelection, setInternalRowSelection] = useState<Record<string, boolean>>({});
|
||||
const [showColumnsMenu, setShowColumnsMenu] = useState(false);
|
||||
const [stickyFirstColumn, setStickyFirstColumn] = useState(stickyFirstProp);
|
||||
@ -484,7 +490,7 @@ export function DataTable<TData, TValue>({
|
||||
/>
|
||||
{typeof column.columnDef.header === "string"
|
||||
? column.columnDef.header
|
||||
: column.id}
|
||||
: String((column.columnDef.meta as Record<string, string> | undefined)?.label ?? column.id)}
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
|
||||
1003
apps/web/app/components/workspace/object-filter-bar.tsx
Normal file
1003
apps/web/app/components/workspace/object-filter-bar.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -38,6 +38,8 @@ type ObjectTableProps = {
|
||||
onNavigateToObject?: (objectName: string) => void;
|
||||
onEntryClick?: (entryId: string) => void;
|
||||
onRefresh?: () => void;
|
||||
/** Column visibility state keyed by field ID. */
|
||||
columnVisibility?: Record<string, boolean>;
|
||||
};
|
||||
|
||||
type EntryRow = Record<string, unknown> & { entry_id?: string };
|
||||
@ -365,6 +367,7 @@ export function ObjectTable({
|
||||
onNavigateToObject,
|
||||
onEntryClick,
|
||||
onRefresh,
|
||||
columnVisibility,
|
||||
}: ObjectTableProps) {
|
||||
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
@ -379,6 +382,7 @@ export function ObjectTable({
|
||||
const cols: ColumnDef<EntryRow>[] = fields.map((field, fieldIdx) => ({
|
||||
id: field.id,
|
||||
accessorKey: field.name,
|
||||
meta: { label: field.name, fieldName: field.name },
|
||||
header: () => (
|
||||
<span className="flex items-center gap-1" style={{ color: "var(--color-text-muted)" }}>
|
||||
{field.name}
|
||||
@ -434,6 +438,7 @@ export function ObjectTable({
|
||||
for (const rr of activeReverseRelations) {
|
||||
cols.push({
|
||||
id: `rev_${rr.sourceObjectName}_${rr.fieldName}`,
|
||||
meta: { label: `${rr.sourceObjectName} (via ${rr.fieldName})` },
|
||||
header: () => (
|
||||
<span className="flex items-center gap-1.5" style={{ color: "var(--color-text-muted)" }}>
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ opacity: 0.4 }}>
|
||||
@ -570,6 +575,7 @@ export function ObjectTable({
|
||||
addButtonLabel="+ Add"
|
||||
rowActions={getRowActions}
|
||||
stickyFirstColumn
|
||||
initialColumnVisibility={columnVisibility}
|
||||
/>
|
||||
|
||||
{/* Add Entry Modal */}
|
||||
|
||||
@ -25,6 +25,8 @@ import { CronDashboard } from "../components/cron/cron-dashboard";
|
||||
import { CronJobDetail } from "../components/cron/cron-job-detail";
|
||||
import type { CronJob, CronJobsResponse } from "../types/cron";
|
||||
import { useIsMobile } from "../hooks/use-mobile";
|
||||
import { ObjectFilterBar } from "../components/workspace/object-filter-bar";
|
||||
import { type FilterGroup, type SavedView, emptyFilterGroup, matchesFilter } from "@/lib/object-filters";
|
||||
|
||||
// --- Types ---
|
||||
|
||||
@ -73,6 +75,8 @@ type ObjectData = {
|
||||
relationLabels?: Record<string, Record<string, string>>;
|
||||
reverseRelations?: ReverseRelation[];
|
||||
effectiveDisplayField?: string;
|
||||
savedViews?: import("@/lib/object-filters").SavedView[];
|
||||
activeView?: string;
|
||||
};
|
||||
|
||||
type FileData = {
|
||||
@ -791,6 +795,19 @@ function WorkspacePageInner() {
|
||||
}
|
||||
}, [content]);
|
||||
|
||||
// Auto-refresh the current object view when the workspace tree updates.
|
||||
// The SSE watcher triggers tree refreshes on any file change (including
|
||||
// .object.yaml edits by the AI agent). We track the tree reference and
|
||||
// re-fetch the object data so saved views/filters update live.
|
||||
const prevTreeRef = useRef(tree);
|
||||
useEffect(() => {
|
||||
if (prevTreeRef.current === tree) {return;}
|
||||
prevTreeRef.current = tree;
|
||||
if (content.kind === "object") {
|
||||
void refreshCurrentObject();
|
||||
}
|
||||
}, [tree, content.kind, refreshCurrentObject]);
|
||||
|
||||
// Top-level safety net: catch workspace link clicks anywhere in the page
|
||||
// to prevent full-page navigation and handle via client-side state instead.
|
||||
const handleContainerClick = useCallback(
|
||||
@ -1299,6 +1316,113 @@ function ObjectView({
|
||||
}) {
|
||||
const [updatingDisplayField, setUpdatingDisplayField] = useState(false);
|
||||
|
||||
// --- Filter state ---
|
||||
const [filters, setFilters] = useState<FilterGroup>(() => emptyFilterGroup());
|
||||
const [savedViews, setSavedViews] = useState<SavedView[]>(data.savedViews ?? []);
|
||||
const [activeViewName, setActiveViewName] = useState<string | undefined>(data.activeView);
|
||||
|
||||
// Column visibility: maps field IDs to boolean (false = hidden)
|
||||
const [viewColumns, setViewColumns] = useState<string[] | undefined>(undefined);
|
||||
|
||||
// Convert field-name-based columns list to TanStack VisibilityState keyed by field ID
|
||||
const columnVisibility = useMemo(() => {
|
||||
if (!viewColumns || viewColumns.length === 0) {return undefined;}
|
||||
const vis: Record<string, boolean> = {};
|
||||
for (const field of data.fields) {
|
||||
vis[field.id] = viewColumns.includes(field.name);
|
||||
}
|
||||
return vis;
|
||||
}, [viewColumns, data.fields]);
|
||||
|
||||
// Sync saved views when data changes (e.g. SSE refresh from AI editing .object.yaml)
|
||||
useEffect(() => {
|
||||
setSavedViews(data.savedViews ?? []);
|
||||
if (data.activeView && data.activeView !== activeViewName) {
|
||||
const view = (data.savedViews ?? []).find((v) => v.name === data.activeView);
|
||||
if (view) {
|
||||
setFilters(view.filters ?? emptyFilterGroup());
|
||||
setViewColumns(view.columns);
|
||||
setActiveViewName(view.name);
|
||||
}
|
||||
}
|
||||
// Only re-run when the API data itself changes (not our local state)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data.savedViews, data.activeView]);
|
||||
|
||||
// Apply client-side filtering
|
||||
const filteredEntries = useMemo(
|
||||
() => matchesFilter(data.entries, filters),
|
||||
[data.entries, filters],
|
||||
);
|
||||
|
||||
// Save view to .object.yaml via API
|
||||
const handleSaveView = useCallback(async (name: string) => {
|
||||
const newView: SavedView = { name, filters, columns: viewColumns };
|
||||
const updated = [...savedViews.filter((v) => v.name !== name), newView];
|
||||
setSavedViews(updated);
|
||||
setActiveViewName(name);
|
||||
try {
|
||||
await fetch(
|
||||
`/api/workspace/objects/${encodeURIComponent(data.object.name)}/views`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ views: updated, activeView: name }),
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
// ignore save errors
|
||||
}
|
||||
}, [filters, savedViews, data.object.name]);
|
||||
|
||||
const handleLoadView = useCallback((view: SavedView) => {
|
||||
setFilters(view.filters ?? emptyFilterGroup());
|
||||
setViewColumns(view.columns);
|
||||
setActiveViewName(view.name);
|
||||
}, []);
|
||||
|
||||
const handleDeleteView = useCallback(async (name: string) => {
|
||||
const updated = savedViews.filter((v) => v.name !== name);
|
||||
setSavedViews(updated);
|
||||
if (activeViewName === name) {
|
||||
setActiveViewName(undefined);
|
||||
setFilters(emptyFilterGroup());
|
||||
setViewColumns(undefined);
|
||||
}
|
||||
try {
|
||||
await fetch(
|
||||
`/api/workspace/objects/${encodeURIComponent(data.object.name)}/views`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
views: updated,
|
||||
activeView: activeViewName === name ? undefined : activeViewName,
|
||||
}),
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [savedViews, activeViewName, data.object.name]);
|
||||
|
||||
const handleSetActiveView = useCallback(async (name: string | undefined) => {
|
||||
setActiveViewName(name);
|
||||
if (!name) {setViewColumns(undefined);}
|
||||
try {
|
||||
await fetch(
|
||||
`/api/workspace/objects/${encodeURIComponent(data.object.name)}/views`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ views: savedViews, activeView: name }),
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [savedViews, data.object.name]);
|
||||
|
||||
const handleDisplayFieldChange = async (fieldName: string) => {
|
||||
setUpdatingDisplayField(true);
|
||||
try {
|
||||
@ -1330,10 +1454,15 @@ function ObjectView({
|
||||
(rr) => Object.keys(rr.entries).length > 0,
|
||||
);
|
||||
|
||||
const filterBarMembers = useMemo(
|
||||
() => members?.map((m) => ({ id: m.id, name: m.name })),
|
||||
[members],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
{/* Object header */}
|
||||
<div className="mb-6">
|
||||
<div className="mb-4">
|
||||
<h1
|
||||
className="font-instrument text-3xl tracking-tight capitalize"
|
||||
style={{ color: "var(--color-text)" }}
|
||||
@ -1357,7 +1486,7 @@ function ObjectView({
|
||||
border: "1px solid var(--color-border)",
|
||||
}}
|
||||
>
|
||||
{data.entries.length} entries
|
||||
{filteredEntries.length}{filters.rules.length > 0 ? `/${data.entries.length}` : ""} entries
|
||||
</span>
|
||||
<span
|
||||
className="text-xs px-2 py-1 rounded-full"
|
||||
@ -1438,12 +1567,34 @@ function ObjectView({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filter bar */}
|
||||
<div
|
||||
className="mb-4 py-3 px-4 rounded-lg border"
|
||||
style={{
|
||||
borderColor: "var(--color-border)",
|
||||
background: "var(--color-surface)",
|
||||
}}
|
||||
>
|
||||
<ObjectFilterBar
|
||||
fields={data.fields}
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
savedViews={savedViews}
|
||||
activeViewName={activeViewName}
|
||||
onSaveView={handleSaveView}
|
||||
onLoadView={handleLoadView}
|
||||
onDeleteView={handleDeleteView}
|
||||
onSetActiveView={handleSetActiveView}
|
||||
members={filterBarMembers}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Table or Kanban */}
|
||||
{data.object.default_view === "kanban" ? (
|
||||
<ObjectKanban
|
||||
objectName={data.object.name}
|
||||
fields={data.fields}
|
||||
entries={data.entries}
|
||||
entries={filteredEntries}
|
||||
statuses={data.statuses}
|
||||
members={members}
|
||||
relationLabels={data.relationLabels}
|
||||
@ -1454,13 +1605,14 @@ function ObjectView({
|
||||
<ObjectTable
|
||||
objectName={data.object.name}
|
||||
fields={data.fields}
|
||||
entries={data.entries}
|
||||
entries={filteredEntries}
|
||||
members={members}
|
||||
relationLabels={data.relationLabels}
|
||||
reverseRelations={data.reverseRelations}
|
||||
onNavigateToObject={onNavigateToObject}
|
||||
onEntryClick={onOpenEntry ? (entryId) => onOpenEntry(data.object.name, entryId) : undefined}
|
||||
onRefresh={onRefreshObject}
|
||||
columnVisibility={columnVisibility}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -597,6 +597,73 @@ describe("active-runs", () => {
|
||||
const { abortRun } = await setup();
|
||||
expect(abortRun("nonexistent")).toBe(false);
|
||||
});
|
||||
|
||||
it("immediately marks the run as non-active so new messages are not blocked", async () => {
|
||||
const { startRun, abortRun, hasActiveRun, getActiveRun } = await setup();
|
||||
|
||||
startRun({
|
||||
sessionId: "s-abort-status",
|
||||
message: "hi",
|
||||
agentSessionId: "s-abort-status",
|
||||
});
|
||||
|
||||
expect(hasActiveRun("s-abort-status")).toBe(true);
|
||||
|
||||
abortRun("s-abort-status");
|
||||
|
||||
// hasActiveRun must return false immediately after abort
|
||||
// (before the child process exits), otherwise the next
|
||||
// user message is rejected with 409.
|
||||
expect(hasActiveRun("s-abort-status")).toBe(false);
|
||||
expect(getActiveRun("s-abort-status")?.status).toBe("error");
|
||||
});
|
||||
|
||||
it("allows starting a new run after abort (no 409 race)", async () => {
|
||||
const { startRun, abortRun, hasActiveRun } = await setup();
|
||||
|
||||
startRun({
|
||||
sessionId: "s-abort-new",
|
||||
message: "first",
|
||||
agentSessionId: "s-abort-new",
|
||||
});
|
||||
|
||||
abortRun("s-abort-new");
|
||||
|
||||
// Starting a new run for the same session should succeed.
|
||||
expect(() =>
|
||||
startRun({
|
||||
sessionId: "s-abort-new",
|
||||
message: "second",
|
||||
agentSessionId: "s-abort-new",
|
||||
}),
|
||||
).not.toThrow();
|
||||
|
||||
expect(hasActiveRun("s-abort-new")).toBe(true);
|
||||
});
|
||||
|
||||
it("signals subscribers with null on abort", async () => {
|
||||
const { startRun, abortRun, subscribeToRun } = await setup();
|
||||
|
||||
const completed: boolean[] = [];
|
||||
|
||||
startRun({
|
||||
sessionId: "s-abort-sub",
|
||||
message: "hi",
|
||||
agentSessionId: "s-abort-sub",
|
||||
});
|
||||
|
||||
subscribeToRun(
|
||||
"s-abort-sub",
|
||||
(event) => {
|
||||
if (event === null) {completed.push(true);}
|
||||
},
|
||||
{ replay: false },
|
||||
);
|
||||
|
||||
abortRun("s-abort-sub");
|
||||
|
||||
expect(completed).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── duplicate run prevention ──────────────────────────────────────
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
* - Messages are written to persistent sessions as they arrive.
|
||||
* - New HTTP connections can re-attach to a running stream.
|
||||
*/
|
||||
import { type ChildProcess } from "node:child_process";
|
||||
import { type ChildProcess, spawn } from "node:child_process";
|
||||
import { createInterface } from "node:readline";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
@ -21,6 +21,7 @@ import { homedir } from "node:os";
|
||||
import {
|
||||
type AgentEvent,
|
||||
spawnAgentProcess,
|
||||
resolvePackageRoot,
|
||||
extractToolResult,
|
||||
buildToolOutput,
|
||||
parseAgentErrorMessage,
|
||||
@ -157,17 +158,40 @@ export function subscribeToRun(
|
||||
export function abortRun(sessionId: string): boolean {
|
||||
const run = activeRuns.get(sessionId);
|
||||
if (!run || run.status !== "running") {return false;}
|
||||
|
||||
// Immediately mark the run as non-running so hasActiveRun() returns
|
||||
// false and the next user message isn't rejected with 409.
|
||||
run.status = "error";
|
||||
|
||||
run.abortController.abort();
|
||||
run.childProcess.kill("SIGTERM");
|
||||
|
||||
// Send chat.abort directly to the gateway so the agent run stops
|
||||
// even if the CLI child's best-effort onAbort doesn't complete in time.
|
||||
sendGatewayAbort(sessionId);
|
||||
|
||||
// Flush persistence to save the partial response (without _streaming).
|
||||
flushPersistence(run);
|
||||
|
||||
// Signal subscribers that the stream ended.
|
||||
for (const sub of run.subscribers) {
|
||||
try { sub(null); } catch { /* ignore */ }
|
||||
}
|
||||
run.subscribers.clear();
|
||||
|
||||
// Schedule grace-period cleanup (guard: only if we're still the active run).
|
||||
setTimeout(() => {
|
||||
if (activeRuns.get(sessionId) === run) {
|
||||
cleanupRun(sessionId);
|
||||
}
|
||||
}, CLEANUP_GRACE_MS);
|
||||
|
||||
// Fallback: if the child doesn't exit within 5 seconds after
|
||||
// SIGTERM (e.g. the CLI's best-effort chat.abort RPC hangs),
|
||||
// send SIGKILL to force-terminate.
|
||||
const killTimer = setTimeout(() => {
|
||||
try {
|
||||
if (run.status === "running") {
|
||||
run.childProcess.kill("SIGKILL");
|
||||
}
|
||||
run.childProcess.kill("SIGKILL");
|
||||
} catch { /* already dead */ }
|
||||
}, 5_000);
|
||||
run.childProcess.once("close", () => clearTimeout(killTimer));
|
||||
@ -175,6 +199,47 @@ export function abortRun(sessionId: string): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a `chat.abort` RPC directly to the gateway daemon via a short-lived
|
||||
* CLI process. This is a belt-and-suspenders complement to the SIGTERM sent
|
||||
* to the child: even if the child's best-effort `onAbort` callback doesn't
|
||||
* reach the gateway in time, this separate process will.
|
||||
*/
|
||||
function sendGatewayAbort(sessionId: string): void {
|
||||
try {
|
||||
const root = resolvePackageRoot();
|
||||
const devScript = join(root, "scripts", "run-node.mjs");
|
||||
const prodScript = join(root, "openclaw.mjs");
|
||||
const scriptPath = existsSync(devScript) ? devScript : prodScript;
|
||||
|
||||
const sessionKey = `agent:main:web:${sessionId}`;
|
||||
const child = spawn(
|
||||
"node",
|
||||
[
|
||||
scriptPath,
|
||||
"gateway",
|
||||
"call",
|
||||
"chat.abort",
|
||||
"--params",
|
||||
JSON.stringify({ sessionKey }),
|
||||
"--json",
|
||||
"--timeout",
|
||||
"4000",
|
||||
],
|
||||
{
|
||||
cwd: root,
|
||||
env: { ...process.env },
|
||||
stdio: "ignore",
|
||||
detached: true,
|
||||
},
|
||||
);
|
||||
// Let the abort process run independently — don't block on it.
|
||||
child.unref();
|
||||
} catch {
|
||||
// Best-effort; don't let abort failures break the stop flow.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a new agent run for the given session.
|
||||
* Throws if a run is already active for this session.
|
||||
@ -650,6 +715,12 @@ function wireChildProcess(run: ActiveRun): void {
|
||||
// ── Child process exit ──
|
||||
|
||||
child.on("close", (code) => {
|
||||
// If already finalized (e.g. by abortRun), just record the exit code.
|
||||
if (run.status !== "running") {
|
||||
run.exitCode = code;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!agentErrorReported && stderrChunks.length > 0) {
|
||||
const stderr = stderrChunks.join("").trim();
|
||||
const msg = parseErrorFromStderr(stderr);
|
||||
@ -692,10 +763,18 @@ function wireChildProcess(run: ActiveRun): void {
|
||||
|
||||
// Clean up run state after a grace period so reconnections
|
||||
// within that window still get the buffered events.
|
||||
setTimeout(() => cleanupRun(run.sessionId), CLEANUP_GRACE_MS);
|
||||
// Guard: only clean up if we're still the active run for this session.
|
||||
setTimeout(() => {
|
||||
if (activeRuns.get(run.sessionId) === run) {
|
||||
cleanupRun(run.sessionId);
|
||||
}
|
||||
}, CLEANUP_GRACE_MS);
|
||||
});
|
||||
|
||||
child.on("error", (err) => {
|
||||
// If already finalized (e.g. by abortRun), skip.
|
||||
if (run.status !== "running") {return;}
|
||||
|
||||
console.error("[active-runs] Child process error:", err);
|
||||
emitError(`Failed to start agent: ${err.message}`);
|
||||
run.status = "error";
|
||||
@ -708,7 +787,11 @@ function wireChildProcess(run: ActiveRun): void {
|
||||
}
|
||||
}
|
||||
run.subscribers.clear();
|
||||
setTimeout(() => cleanupRun(run.sessionId), CLEANUP_GRACE_MS);
|
||||
setTimeout(() => {
|
||||
if (activeRuns.get(run.sessionId) === run) {
|
||||
cleanupRun(run.sessionId);
|
||||
}
|
||||
}, CLEANUP_GRACE_MS);
|
||||
});
|
||||
|
||||
child.stderr?.on("data", (chunk: Buffer) => {
|
||||
|
||||
723
apps/web/lib/object-filters.ts
Normal file
723
apps/web/lib/object-filters.ts
Normal file
@ -0,0 +1,723 @@
|
||||
/**
|
||||
* Object view filter system.
|
||||
*
|
||||
* Provides a composable filter model for workspace object entries,
|
||||
* a client-side evaluator (`matchesFilter`), a DuckDB SQL builder
|
||||
* (`buildWhereClause`), and operator metadata per field type.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type FilterOperator =
|
||||
// text / url / richtext
|
||||
| "contains"
|
||||
| "not_contains"
|
||||
| "equals"
|
||||
| "not_equals"
|
||||
| "starts_with"
|
||||
| "ends_with"
|
||||
// number
|
||||
| "eq"
|
||||
| "neq"
|
||||
| "gt"
|
||||
| "gte"
|
||||
| "lt"
|
||||
| "lte"
|
||||
| "between"
|
||||
// date
|
||||
| "before"
|
||||
| "after"
|
||||
| "on"
|
||||
| "date_between"
|
||||
| "relative_past"
|
||||
| "relative_next"
|
||||
// enum
|
||||
| "is"
|
||||
| "is_not"
|
||||
| "is_any_of"
|
||||
| "is_none_of"
|
||||
// boolean
|
||||
| "is_true"
|
||||
| "is_false"
|
||||
// relation
|
||||
| "has_any"
|
||||
| "has_none"
|
||||
| "has_all"
|
||||
// universal
|
||||
| "is_empty"
|
||||
| "is_not_empty";
|
||||
|
||||
export type FilterRule = {
|
||||
id: string;
|
||||
field: string;
|
||||
operator: FilterOperator;
|
||||
value?: string | number | boolean | string[];
|
||||
/** Upper bound for "between" / "date_between". */
|
||||
valueTo?: string | number;
|
||||
/** Unit for relative date operators. */
|
||||
relativeUnit?: "days" | "weeks" | "months";
|
||||
/** Amount for relative date operators. */
|
||||
relativeAmount?: number;
|
||||
};
|
||||
|
||||
export type FilterGroup = {
|
||||
id: string;
|
||||
conjunction: "and" | "or";
|
||||
rules: Array<FilterRule | FilterGroup>;
|
||||
};
|
||||
|
||||
export type SortRule = {
|
||||
field: string;
|
||||
direction: "asc" | "desc";
|
||||
};
|
||||
|
||||
export type SavedView = {
|
||||
name: string;
|
||||
filters?: FilterGroup;
|
||||
sort?: SortRule[];
|
||||
columns?: string[];
|
||||
};
|
||||
|
||||
/** Minimal field descriptor needed by the filter system. */
|
||||
export type FieldMeta = {
|
||||
name: string;
|
||||
type: string; // "text" | "number" | "date" | "boolean" | "enum" | "relation" | "richtext" | "email" | "user"
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Operator metadata per field type
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type OperatorMeta = { value: FilterOperator; label: string };
|
||||
|
||||
const UNIVERSAL_OPS: OperatorMeta[] = [
|
||||
{ value: "is_empty", label: "is empty" },
|
||||
{ value: "is_not_empty", label: "is not empty" },
|
||||
];
|
||||
|
||||
const TEXT_OPS: OperatorMeta[] = [
|
||||
{ value: "contains", label: "contains" },
|
||||
{ value: "not_contains", label: "does not contain" },
|
||||
{ value: "equals", label: "equals" },
|
||||
{ value: "not_equals", label: "does not equal" },
|
||||
{ value: "starts_with", label: "starts with" },
|
||||
{ value: "ends_with", label: "ends with" },
|
||||
...UNIVERSAL_OPS,
|
||||
];
|
||||
|
||||
const NUMBER_OPS: OperatorMeta[] = [
|
||||
{ value: "eq", label: "=" },
|
||||
{ value: "neq", label: "≠" },
|
||||
{ value: "gt", label: ">" },
|
||||
{ value: "gte", label: "≥" },
|
||||
{ value: "lt", label: "<" },
|
||||
{ value: "lte", label: "≤" },
|
||||
{ value: "between", label: "between" },
|
||||
...UNIVERSAL_OPS,
|
||||
];
|
||||
|
||||
const DATE_OPS: OperatorMeta[] = [
|
||||
{ value: "on", label: "is" },
|
||||
{ value: "before", label: "before" },
|
||||
{ value: "after", label: "after" },
|
||||
{ value: "date_between", label: "between" },
|
||||
{ value: "relative_past", label: "in the last" },
|
||||
{ value: "relative_next", label: "in the next" },
|
||||
...UNIVERSAL_OPS,
|
||||
];
|
||||
|
||||
const ENUM_OPS: OperatorMeta[] = [
|
||||
{ value: "is", label: "is" },
|
||||
{ value: "is_not", label: "is not" },
|
||||
{ value: "is_any_of", label: "is any of" },
|
||||
{ value: "is_none_of", label: "is none of" },
|
||||
...UNIVERSAL_OPS,
|
||||
];
|
||||
|
||||
const BOOLEAN_OPS: OperatorMeta[] = [
|
||||
{ value: "is_true", label: "is true" },
|
||||
{ value: "is_false", label: "is false" },
|
||||
...UNIVERSAL_OPS,
|
||||
];
|
||||
|
||||
const RELATION_OPS: OperatorMeta[] = [
|
||||
{ value: "has_any", label: "has any of" },
|
||||
{ value: "has_none", label: "has none of" },
|
||||
{ value: "has_all", label: "has all of" },
|
||||
...UNIVERSAL_OPS,
|
||||
];
|
||||
|
||||
/**
|
||||
* Return the operators valid for a given field type.
|
||||
*/
|
||||
export function operatorsForFieldType(fieldType: string): OperatorMeta[] {
|
||||
switch (fieldType) {
|
||||
case "text":
|
||||
case "richtext":
|
||||
case "email":
|
||||
return TEXT_OPS;
|
||||
case "number":
|
||||
return NUMBER_OPS;
|
||||
case "date":
|
||||
return DATE_OPS;
|
||||
case "enum":
|
||||
return ENUM_OPS;
|
||||
case "boolean":
|
||||
return BOOLEAN_OPS;
|
||||
case "relation":
|
||||
case "user":
|
||||
return RELATION_OPS;
|
||||
default:
|
||||
return TEXT_OPS;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the default operator for a given field type.
|
||||
*/
|
||||
export function defaultOperatorForFieldType(fieldType: string): FilterOperator {
|
||||
switch (fieldType) {
|
||||
case "text":
|
||||
case "richtext":
|
||||
case "email":
|
||||
return "contains";
|
||||
case "number":
|
||||
return "eq";
|
||||
case "date":
|
||||
return "relative_past";
|
||||
case "enum":
|
||||
return "is";
|
||||
case "boolean":
|
||||
return "is_true";
|
||||
case "relation":
|
||||
case "user":
|
||||
return "has_any";
|
||||
default:
|
||||
return "contains";
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Type guard
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function isFilterGroup(
|
||||
rule: FilterRule | FilterGroup,
|
||||
): rule is FilterGroup {
|
||||
return "conjunction" in rule && "rules" in rule;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Client-side evaluator
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function coerceString(v: unknown): string {
|
||||
if (v == null) {return "";}
|
||||
if (typeof v === "string") {return v;}
|
||||
if (typeof v === "object") {return JSON.stringify(v);}
|
||||
return typeof v === "symbol" || typeof v === "function" ? "" : String(v as string | number | boolean | bigint);
|
||||
}
|
||||
|
||||
function coerceNumber(v: unknown): number | null {
|
||||
if (v == null) {return null;}
|
||||
const n = Number(v);
|
||||
return Number.isNaN(n) ? null : n;
|
||||
}
|
||||
|
||||
function parseRelationIds(v: unknown): string[] {
|
||||
if (v == null) {return [];}
|
||||
const s = coerceString(v).trim();
|
||||
if (!s) {return [];}
|
||||
if (s.startsWith("[")) {
|
||||
try {
|
||||
const arr = JSON.parse(s);
|
||||
if (Array.isArray(arr)) {return arr.map(String).filter(Boolean);}
|
||||
} catch {
|
||||
/* not JSON */
|
||||
}
|
||||
}
|
||||
return [s];
|
||||
}
|
||||
|
||||
function resolveRelativeDate(amount: number, unit: string, direction: "past" | "next"): Date {
|
||||
const now = new Date();
|
||||
const mult = direction === "past" ? -1 : 1;
|
||||
switch (unit) {
|
||||
case "days":
|
||||
now.setDate(now.getDate() + mult * amount);
|
||||
break;
|
||||
case "weeks":
|
||||
now.setDate(now.getDate() + mult * amount * 7);
|
||||
break;
|
||||
case "months":
|
||||
now.setMonth(now.getMonth() + mult * amount);
|
||||
break;
|
||||
}
|
||||
return now;
|
||||
}
|
||||
|
||||
function dateOnly(d: Date): string {
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a single FilterRule against one entry row.
|
||||
*/
|
||||
function evaluateRule(
|
||||
rule: FilterRule,
|
||||
entry: Record<string, unknown>,
|
||||
): boolean {
|
||||
const raw = entry[rule.field];
|
||||
const op = rule.operator;
|
||||
|
||||
// Universal operators
|
||||
if (op === "is_empty") {
|
||||
const s = coerceString(raw).trim();
|
||||
return s === "" || s === "null" || s === "[]";
|
||||
}
|
||||
if (op === "is_not_empty") {
|
||||
const s = coerceString(raw).trim();
|
||||
return s !== "" && s !== "null" && s !== "[]";
|
||||
}
|
||||
|
||||
// Boolean operators
|
||||
if (op === "is_true") {
|
||||
const s = coerceString(raw).toLowerCase();
|
||||
return s === "true" || s === "1" || s === "yes";
|
||||
}
|
||||
if (op === "is_false") {
|
||||
const s = coerceString(raw).toLowerCase();
|
||||
return s === "false" || s === "0" || s === "no" || s === "";
|
||||
}
|
||||
|
||||
// Text operators
|
||||
if (
|
||||
op === "contains" ||
|
||||
op === "not_contains" ||
|
||||
op === "equals" ||
|
||||
op === "not_equals" ||
|
||||
op === "starts_with" ||
|
||||
op === "ends_with"
|
||||
) {
|
||||
const haystack = coerceString(raw).toLowerCase();
|
||||
const needle = coerceString(rule.value).toLowerCase();
|
||||
switch (op) {
|
||||
case "contains":
|
||||
return haystack.includes(needle);
|
||||
case "not_contains":
|
||||
return !haystack.includes(needle);
|
||||
case "equals":
|
||||
return haystack === needle;
|
||||
case "not_equals":
|
||||
return haystack !== needle;
|
||||
case "starts_with":
|
||||
return haystack.startsWith(needle);
|
||||
case "ends_with":
|
||||
return haystack.endsWith(needle);
|
||||
}
|
||||
}
|
||||
|
||||
// Number operators
|
||||
if (
|
||||
op === "eq" ||
|
||||
op === "neq" ||
|
||||
op === "gt" ||
|
||||
op === "gte" ||
|
||||
op === "lt" ||
|
||||
op === "lte" ||
|
||||
op === "between"
|
||||
) {
|
||||
const n = coerceNumber(raw);
|
||||
if (n == null) {return false;}
|
||||
const v = coerceNumber(rule.value);
|
||||
if (v == null && op !== "between") {return false;}
|
||||
switch (op) {
|
||||
case "eq":
|
||||
return n === v;
|
||||
case "neq":
|
||||
return n !== v;
|
||||
case "gt":
|
||||
return n > v!;
|
||||
case "gte":
|
||||
return n >= v!;
|
||||
case "lt":
|
||||
return n < v!;
|
||||
case "lte":
|
||||
return n <= v!;
|
||||
case "between": {
|
||||
const lo = coerceNumber(rule.value);
|
||||
const hi = coerceNumber(rule.valueTo);
|
||||
if (lo == null || hi == null) {return false;}
|
||||
return n >= lo && n <= hi;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Date operators
|
||||
if (
|
||||
op === "on" ||
|
||||
op === "before" ||
|
||||
op === "after" ||
|
||||
op === "date_between" ||
|
||||
op === "relative_past" ||
|
||||
op === "relative_next"
|
||||
) {
|
||||
const dateStr = coerceString(raw).slice(0, 10); // YYYY-MM-DD
|
||||
if (!dateStr) {return false;}
|
||||
|
||||
if (op === "relative_past") {
|
||||
const amount = rule.relativeAmount ?? 7;
|
||||
const unit = rule.relativeUnit ?? "days";
|
||||
const boundary = dateOnly(resolveRelativeDate(amount, unit, "past"));
|
||||
const today = dateOnly(new Date());
|
||||
return dateStr >= boundary && dateStr <= today;
|
||||
}
|
||||
if (op === "relative_next") {
|
||||
const amount = rule.relativeAmount ?? 7;
|
||||
const unit = rule.relativeUnit ?? "days";
|
||||
const boundary = dateOnly(resolveRelativeDate(amount, unit, "next"));
|
||||
const today = dateOnly(new Date());
|
||||
return dateStr >= today && dateStr <= boundary;
|
||||
}
|
||||
|
||||
const target = coerceString(rule.value === "today" ? dateOnly(new Date()) : rule.value);
|
||||
switch (op) {
|
||||
case "on":
|
||||
return dateStr === target;
|
||||
case "before":
|
||||
return dateStr < target;
|
||||
case "after":
|
||||
return dateStr > target;
|
||||
case "date_between": {
|
||||
const from = coerceString(rule.value);
|
||||
const to = coerceString(rule.valueTo);
|
||||
return dateStr >= from && dateStr <= to;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Enum operators
|
||||
if (op === "is" || op === "is_not" || op === "is_any_of" || op === "is_none_of") {
|
||||
const cellVal = coerceString(raw);
|
||||
// For multi-select enums the cell may be a JSON array
|
||||
let cellValues: string[];
|
||||
try {
|
||||
const parsed = JSON.parse(cellVal);
|
||||
cellValues = Array.isArray(parsed) ? parsed.map(String) : [cellVal];
|
||||
} catch {
|
||||
cellValues = [cellVal];
|
||||
}
|
||||
|
||||
switch (op) {
|
||||
case "is":
|
||||
return cellValues.includes(coerceString(rule.value));
|
||||
case "is_not":
|
||||
return !cellValues.includes(coerceString(rule.value));
|
||||
case "is_any_of": {
|
||||
const vals = Array.isArray(rule.value) ? rule.value.map(String) : [coerceString(rule.value)];
|
||||
return cellValues.some((cv) => vals.includes(cv));
|
||||
}
|
||||
case "is_none_of": {
|
||||
const vals = Array.isArray(rule.value) ? rule.value.map(String) : [coerceString(rule.value)];
|
||||
return !cellValues.some((cv) => vals.includes(cv));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Relation / user operators
|
||||
if (op === "has_any" || op === "has_none" || op === "has_all") {
|
||||
const ids = parseRelationIds(raw);
|
||||
const targets = Array.isArray(rule.value) ? rule.value.map(String) : [coerceString(rule.value)];
|
||||
switch (op) {
|
||||
case "has_any":
|
||||
return ids.some((id) => targets.includes(id));
|
||||
case "has_none":
|
||||
return !ids.some((id) => targets.includes(id));
|
||||
case "has_all":
|
||||
return targets.every((t) => ids.includes(t));
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a FilterGroup (with nested groups) against one entry.
|
||||
*/
|
||||
function evaluateGroup(
|
||||
group: FilterGroup,
|
||||
entry: Record<string, unknown>,
|
||||
): boolean {
|
||||
const results = group.rules.map((rule) =>
|
||||
isFilterGroup(rule)
|
||||
? evaluateGroup(rule, entry)
|
||||
: evaluateRule(rule, entry),
|
||||
);
|
||||
|
||||
return group.conjunction === "and"
|
||||
? results.every(Boolean)
|
||||
: results.some(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter an array of entries client-side using a FilterGroup.
|
||||
* Returns entries that match the filter.
|
||||
*/
|
||||
export function matchesFilter(
|
||||
entries: Record<string, unknown>[],
|
||||
filters: FilterGroup | undefined,
|
||||
): Record<string, unknown>[] {
|
||||
if (!filters || filters.rules.length === 0) {return entries;}
|
||||
return entries.filter((entry) => evaluateGroup(filters, entry));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test a single entry against a FilterGroup.
|
||||
*/
|
||||
export function entryMatchesFilter(
|
||||
entry: Record<string, unknown>,
|
||||
filters: FilterGroup | undefined,
|
||||
): boolean {
|
||||
if (!filters || filters.rules.length === 0) {return true;}
|
||||
return evaluateGroup(filters, entry);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DuckDB SQL WHERE clause builder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Escape a string value for use in a DuckDB SQL literal.
|
||||
*/
|
||||
function sqlEscape(v: string): string {
|
||||
return v.replace(/'/g, "''");
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a field name is safe for SQL (alphanumeric + underscores).
|
||||
*/
|
||||
function safeName(name: string): string {
|
||||
// Allow letters, digits, underscores, hyphens, spaces (quoted)
|
||||
if (!/^[\w\s-]+$/.test(name)) {
|
||||
throw new Error(`Invalid field name: ${name}`);
|
||||
}
|
||||
return `"${name.replace(/"/g, '""')}"`;
|
||||
}
|
||||
|
||||
function buildRuleSQL(rule: FilterRule, fields: FieldMeta[]): string | null {
|
||||
const field = fields.find((f) => f.name === rule.field);
|
||||
if (!field) {return null;}
|
||||
|
||||
const col = safeName(rule.field);
|
||||
const op = rule.operator;
|
||||
|
||||
// Universal
|
||||
if (op === "is_empty") {return `(${col} IS NULL OR CAST(${col} AS VARCHAR) = '' OR CAST(${col} AS VARCHAR) = '[]')`;}
|
||||
if (op === "is_not_empty") {return `(${col} IS NOT NULL AND CAST(${col} AS VARCHAR) != '' AND CAST(${col} AS VARCHAR) != '[]')`;}
|
||||
|
||||
// Boolean
|
||||
if (op === "is_true") {return `(LOWER(CAST(${col} AS VARCHAR)) IN ('true', '1', 'yes'))`;}
|
||||
if (op === "is_false") {return `(LOWER(CAST(${col} AS VARCHAR)) IN ('false', '0', 'no', ''))`;}
|
||||
|
||||
// Text
|
||||
if (op === "contains") {return `(LOWER(CAST(${col} AS VARCHAR)) LIKE '%${sqlEscape(coerceString(rule.value).toLowerCase())}%')`;}
|
||||
if (op === "not_contains") {return `(LOWER(CAST(${col} AS VARCHAR)) NOT LIKE '%${sqlEscape(coerceString(rule.value).toLowerCase())}%')`;}
|
||||
if (op === "equals") {return `(LOWER(CAST(${col} AS VARCHAR)) = '${sqlEscape(coerceString(rule.value).toLowerCase())}')`;}
|
||||
if (op === "not_equals") {return `(LOWER(CAST(${col} AS VARCHAR)) != '${sqlEscape(coerceString(rule.value).toLowerCase())}')`;}
|
||||
if (op === "starts_with") {return `(LOWER(CAST(${col} AS VARCHAR)) LIKE '${sqlEscape(coerceString(rule.value).toLowerCase())}%')`;}
|
||||
if (op === "ends_with") {return `(LOWER(CAST(${col} AS VARCHAR)) LIKE '%${sqlEscape(coerceString(rule.value).toLowerCase())}')`;}
|
||||
|
||||
// Number
|
||||
const numVal = coerceNumber(rule.value);
|
||||
if (op === "eq" && numVal != null) {return `(CAST(${col} AS DOUBLE) = ${numVal})`;}
|
||||
if (op === "neq" && numVal != null) {return `(CAST(${col} AS DOUBLE) != ${numVal})`;}
|
||||
if (op === "gt" && numVal != null) {return `(CAST(${col} AS DOUBLE) > ${numVal})`;}
|
||||
if (op === "gte" && numVal != null) {return `(CAST(${col} AS DOUBLE) >= ${numVal})`;}
|
||||
if (op === "lt" && numVal != null) {return `(CAST(${col} AS DOUBLE) < ${numVal})`;}
|
||||
if (op === "lte" && numVal != null) {return `(CAST(${col} AS DOUBLE) <= ${numVal})`;}
|
||||
if (op === "between") {
|
||||
const lo = coerceNumber(rule.value);
|
||||
const hi = coerceNumber(rule.valueTo);
|
||||
if (lo != null && hi != null) {return `(CAST(${col} AS DOUBLE) BETWEEN ${lo} AND ${hi})`;}
|
||||
}
|
||||
|
||||
// Date
|
||||
if (op === "on") {
|
||||
const d = rule.value === "today" ? dateOnly(new Date()) : coerceString(rule.value);
|
||||
return `(CAST(${col} AS VARCHAR) LIKE '${sqlEscape(d)}%')`;
|
||||
}
|
||||
if (op === "before") {
|
||||
const d = rule.value === "today" ? dateOnly(new Date()) : coerceString(rule.value);
|
||||
return `(CAST(${col} AS DATE) < '${sqlEscape(d)}')`;
|
||||
}
|
||||
if (op === "after") {
|
||||
const d = rule.value === "today" ? dateOnly(new Date()) : coerceString(rule.value);
|
||||
return `(CAST(${col} AS DATE) > '${sqlEscape(d)}')`;
|
||||
}
|
||||
if (op === "date_between") {
|
||||
const from = coerceString(rule.value);
|
||||
const to = coerceString(rule.valueTo);
|
||||
return `(CAST(${col} AS DATE) BETWEEN '${sqlEscape(from)}' AND '${sqlEscape(to)}')`;
|
||||
}
|
||||
if (op === "relative_past") {
|
||||
const amount = rule.relativeAmount ?? 7;
|
||||
const unit = rule.relativeUnit ?? "days";
|
||||
const boundary = dateOnly(resolveRelativeDate(amount, unit, "past"));
|
||||
const today = dateOnly(new Date());
|
||||
return `(CAST(${col} AS DATE) BETWEEN '${sqlEscape(boundary)}' AND '${sqlEscape(today)}')`;
|
||||
}
|
||||
if (op === "relative_next") {
|
||||
const amount = rule.relativeAmount ?? 7;
|
||||
const unit = rule.relativeUnit ?? "days";
|
||||
const boundary = dateOnly(resolveRelativeDate(amount, unit, "next"));
|
||||
const today = dateOnly(new Date());
|
||||
return `(CAST(${col} AS DATE) BETWEEN '${sqlEscape(today)}' AND '${sqlEscape(boundary)}')`;
|
||||
}
|
||||
|
||||
// Enum
|
||||
if (op === "is") {return `(CAST(${col} AS VARCHAR) = '${sqlEscape(coerceString(rule.value))}')`;}
|
||||
if (op === "is_not") {return `(CAST(${col} AS VARCHAR) != '${sqlEscape(coerceString(rule.value))}')`;}
|
||||
if (op === "is_any_of") {
|
||||
const vals = Array.isArray(rule.value) ? rule.value : [coerceString(rule.value)];
|
||||
const list = vals.map((v) => `'${sqlEscape(String(v))}'`).join(", ");
|
||||
return `(CAST(${col} AS VARCHAR) IN (${list}))`;
|
||||
}
|
||||
if (op === "is_none_of") {
|
||||
const vals = Array.isArray(rule.value) ? rule.value : [coerceString(rule.value)];
|
||||
const list = vals.map((v) => `'${sqlEscape(String(v))}'`).join(", ");
|
||||
return `(CAST(${col} AS VARCHAR) NOT IN (${list}))`;
|
||||
}
|
||||
|
||||
// Relation / user — works on the raw text stored in the pivot view
|
||||
if (op === "has_any") {
|
||||
const vals = Array.isArray(rule.value) ? rule.value : [coerceString(rule.value)];
|
||||
const conditions = vals.map((v) => `CAST(${col} AS VARCHAR) LIKE '%${sqlEscape(String(v))}%'`);
|
||||
return `(${conditions.join(" OR ")})`;
|
||||
}
|
||||
if (op === "has_none") {
|
||||
const vals = Array.isArray(rule.value) ? rule.value : [coerceString(rule.value)];
|
||||
const conditions = vals.map((v) => `CAST(${col} AS VARCHAR) NOT LIKE '%${sqlEscape(String(v))}%'`);
|
||||
return `(${conditions.join(" AND ")})`;
|
||||
}
|
||||
if (op === "has_all") {
|
||||
const vals = Array.isArray(rule.value) ? rule.value : [coerceString(rule.value)];
|
||||
const conditions = vals.map((v) => `CAST(${col} AS VARCHAR) LIKE '%${sqlEscape(String(v))}%'`);
|
||||
return `(${conditions.join(" AND ")})`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildGroupSQL(group: FilterGroup, fields: FieldMeta[]): string | null {
|
||||
const parts: string[] = [];
|
||||
for (const rule of group.rules) {
|
||||
const sql = isFilterGroup(rule)
|
||||
? buildGroupSQL(rule, fields)
|
||||
: buildRuleSQL(rule, fields);
|
||||
if (sql) {parts.push(sql);}
|
||||
}
|
||||
if (parts.length === 0) {return null;}
|
||||
const joiner = group.conjunction === "and" ? " AND " : " OR ";
|
||||
return `(${parts.join(joiner)})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a SQL WHERE clause from a FilterGroup.
|
||||
* Returns the clause string (without the leading "WHERE"), or null if no filters.
|
||||
*/
|
||||
export function buildWhereClause(
|
||||
filters: FilterGroup | undefined,
|
||||
fields: FieldMeta[],
|
||||
): string | null {
|
||||
if (!filters || filters.rules.length === 0) {return null;}
|
||||
return buildGroupSQL(filters, fields);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a SQL ORDER BY clause from SortRule[].
|
||||
* Returns the clause string (without the leading "ORDER BY"), or null.
|
||||
*/
|
||||
export function buildOrderByClause(sort: SortRule[] | undefined): string | null {
|
||||
if (!sort || sort.length === 0) {return null;}
|
||||
return sort
|
||||
.map((s) => `${safeName(s.field)} ${s.direction === "desc" ? "DESC" : "ASC"}`)
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Serialization helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Encode a FilterGroup to a URL-safe base64 string. */
|
||||
export function serializeFilters(filters: FilterGroup): string {
|
||||
return btoa(JSON.stringify(filters));
|
||||
}
|
||||
|
||||
/** Decode a base64 filter string back to a FilterGroup. */
|
||||
export function deserializeFilters(encoded: string): FilterGroup | null {
|
||||
try {
|
||||
return JSON.parse(atob(encoded)) as FilterGroup;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Create an empty filter group. */
|
||||
export function emptyFilterGroup(): FilterGroup {
|
||||
return { id: "root", conjunction: "and", rules: [] };
|
||||
}
|
||||
|
||||
/** Generate a short unique ID for filter rules/groups. */
|
||||
export function filterId(): string {
|
||||
return Math.random().toString(36).slice(2, 9);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Human-readable filter description
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Return a short human-readable description of a filter rule. */
|
||||
export function describeRule(rule: FilterRule): string {
|
||||
const field = rule.field;
|
||||
const op = rule.operator;
|
||||
const val = Array.isArray(rule.value)
|
||||
? rule.value.join(", ")
|
||||
: String(rule.value ?? "");
|
||||
|
||||
if (op === "is_empty") {return `${field} is empty`;}
|
||||
if (op === "is_not_empty") {return `${field} is not empty`;}
|
||||
if (op === "is_true") {return `${field} is true`;}
|
||||
if (op === "is_false") {return `${field} is false`;}
|
||||
if (op === "between" || op === "date_between") {return `${field} between ${val} and ${rule.valueTo}`;}
|
||||
if (op === "relative_past") {return `${field} in the last ${rule.relativeAmount} ${rule.relativeUnit}`;}
|
||||
if (op === "relative_next") {return `${field} in the next ${rule.relativeAmount} ${rule.relativeUnit}`;}
|
||||
|
||||
const opLabels: Record<string, string> = {
|
||||
contains: "contains",
|
||||
not_contains: "doesn't contain",
|
||||
equals: "=",
|
||||
not_equals: "≠",
|
||||
starts_with: "starts with",
|
||||
ends_with: "ends with",
|
||||
eq: "=",
|
||||
neq: "≠",
|
||||
gt: ">",
|
||||
gte: "≥",
|
||||
lt: "<",
|
||||
lte: "≤",
|
||||
before: "before",
|
||||
after: "after",
|
||||
on: "is",
|
||||
is: "is",
|
||||
is_not: "is not",
|
||||
is_any_of: "is any of",
|
||||
is_none_of: "is none of",
|
||||
has_any: "has any of",
|
||||
has_none: "has none of",
|
||||
has_all: "has all of",
|
||||
};
|
||||
|
||||
return `${field} ${opLabels[op] ?? op} ${val}`;
|
||||
}
|
||||
@ -25,10 +25,10 @@ import { existsSync, readFileSync, readdirSync } from "node:fs";
|
||||
import { execSync } from "node:child_process";
|
||||
import { join } from "node:path";
|
||||
|
||||
const mockExistsSync = vi.mocked(existsSync);
|
||||
const mockReadFileSync = vi.mocked(readFileSync);
|
||||
const mockReaddirSync = vi.mocked(readdirSync);
|
||||
const mockExecSync = vi.mocked(execSync);
|
||||
const _mockExistsSync = vi.mocked(existsSync);
|
||||
const _mockReadFileSync = vi.mocked(readFileSync);
|
||||
const _mockReaddirSync = vi.mocked(readdirSync);
|
||||
const _mockExecSync = vi.mocked(execSync);
|
||||
|
||||
/** Helper to create mock Dirent entries. */
|
||||
function makeDirent(name: string, isDir: boolean): Dirent {
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
||||
import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
||||
import { execSync, exec } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { join, resolve, normalize, relative } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import YAML from "yaml";
|
||||
import type { SavedView } from "./object-filters";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
@ -542,6 +544,142 @@ export function parseSimpleYaml(
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// .object.yaml with nested views support
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Parsed representation of a .object.yaml file. */
|
||||
export type ObjectYamlConfig = {
|
||||
icon?: string;
|
||||
default_view?: string;
|
||||
views?: SavedView[];
|
||||
active_view?: string;
|
||||
/** Any other top-level keys. */
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse a .object.yaml file with full YAML support (handles nested views).
|
||||
* Falls back to parseSimpleYaml for files that only have flat keys.
|
||||
*/
|
||||
export function parseObjectYaml(content: string): ObjectYamlConfig {
|
||||
try {
|
||||
const parsed = YAML.parse(content);
|
||||
if (!parsed || typeof parsed !== "object") {return {};}
|
||||
return parsed as ObjectYamlConfig;
|
||||
} catch {
|
||||
// Fall back to the simple parser for minimal files
|
||||
return parseSimpleYaml(content) as ObjectYamlConfig;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and parse a .object.yaml from disk.
|
||||
* Returns null if the file does not exist.
|
||||
*/
|
||||
export function readObjectYaml(objectDir: string): ObjectYamlConfig | null {
|
||||
const yamlPath = join(objectDir, ".object.yaml");
|
||||
if (!existsSync(yamlPath)) {return null;}
|
||||
const raw = readFileSync(yamlPath, "utf-8");
|
||||
return parseObjectYaml(raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a .object.yaml file, merging view config with existing top-level keys.
|
||||
*/
|
||||
export function writeObjectYaml(objectDir: string, config: ObjectYamlConfig): void {
|
||||
const yamlPath = join(objectDir, ".object.yaml");
|
||||
|
||||
// Read existing to preserve keys we don't manage
|
||||
let existing: ObjectYamlConfig = {};
|
||||
if (existsSync(yamlPath)) {
|
||||
try {
|
||||
existing = parseObjectYaml(readFileSync(yamlPath, "utf-8"));
|
||||
} catch {
|
||||
existing = {};
|
||||
}
|
||||
}
|
||||
|
||||
const merged = { ...existing, ...config };
|
||||
|
||||
// Remove undefined values
|
||||
for (const key of Object.keys(merged)) {
|
||||
if (merged[key] === undefined) {delete merged[key];}
|
||||
}
|
||||
|
||||
const yamlStr = YAML.stringify(merged, { indent: 2, lineWidth: 0 });
|
||||
writeFileSync(yamlPath, yamlStr, "utf-8");
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the filesystem directory for an object by name.
|
||||
* Walks the workspace tree looking for a directory containing a .object.yaml
|
||||
* or a directory matching the object name inside the workspace.
|
||||
*/
|
||||
export function findObjectDir(objectName: string): string | null {
|
||||
const root = resolveWorkspaceRoot();
|
||||
if (!root) {return null;}
|
||||
|
||||
// Check direct match: workspace/{objectName}/
|
||||
const direct = join(root, objectName);
|
||||
if (existsSync(direct) && existsSync(join(direct, ".object.yaml"))) {
|
||||
return direct;
|
||||
}
|
||||
|
||||
// Search one level deep for a matching .object.yaml
|
||||
try {
|
||||
const entries = readdirSync(root, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) {continue;}
|
||||
const subDir = join(root, entry.name);
|
||||
if (entry.name === objectName && existsSync(join(subDir, ".object.yaml"))) {
|
||||
return subDir;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore read errors
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get saved views for an object from its .object.yaml.
|
||||
*/
|
||||
export function getObjectViews(objectName: string): {
|
||||
views: SavedView[];
|
||||
activeView: string | undefined;
|
||||
} {
|
||||
const dir = findObjectDir(objectName);
|
||||
if (!dir) {return { views: [], activeView: undefined };}
|
||||
|
||||
const config = readObjectYaml(dir);
|
||||
if (!config) {return { views: [], activeView: undefined };}
|
||||
|
||||
return {
|
||||
views: config.views ?? [],
|
||||
activeView: config.active_view,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Save views for an object to its .object.yaml.
|
||||
*/
|
||||
export function saveObjectViews(
|
||||
objectName: string,
|
||||
views: SavedView[],
|
||||
activeView?: string,
|
||||
): boolean {
|
||||
const dir = findObjectDir(objectName);
|
||||
if (!dir) {return false;}
|
||||
|
||||
writeObjectYaml(dir, {
|
||||
views: views.length > 0 ? views : undefined,
|
||||
active_view: activeView,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- System file protection ---
|
||||
|
||||
/** Always protected regardless of depth. */
|
||||
|
||||
File diff suppressed because one or more lines are too long
@ -57,6 +57,95 @@ fields:
|
||||
type: user
|
||||
```
|
||||
|
||||
### Saved Views and Filters
|
||||
|
||||
`.object.yaml` supports a `views` section for saved filter views. These views appear in the UI filter bar and can be created or modified by the agent to immediately change what the user sees (the UI live-reloads via the file watcher).
|
||||
|
||||
**Filter operators by field type:**
|
||||
|
||||
| Field Type | Operators |
|
||||
| ------------------- | ------------------------------------------------------------------------------------------ |
|
||||
| text/richtext/email | contains, not_contains, equals, not_equals, starts_with, ends_with, is_empty, is_not_empty |
|
||||
| number | eq, neq, gt, gte, lt, lte, between, is_empty, is_not_empty |
|
||||
| date | on, before, after, date_between, relative_past, relative_next, is_empty, is_not_empty |
|
||||
| enum | is, is_not, is_any_of, is_none_of, is_empty, is_not_empty |
|
||||
| boolean | is_true, is_false, is_empty, is_not_empty |
|
||||
| relation/user | has_any, has_none, has_all, is_empty, is_not_empty |
|
||||
|
||||
**Views template (append to .object.yaml):**
|
||||
|
||||
```yaml
|
||||
views:
|
||||
- name: "Active deals"
|
||||
filters:
|
||||
id: root
|
||||
conjunction: and
|
||||
rules:
|
||||
- id: f1
|
||||
field: status
|
||||
operator: is_any_of
|
||||
value:
|
||||
- "Negotiating"
|
||||
- "Proposal sent"
|
||||
- id: f2
|
||||
field: amount
|
||||
operator: gte
|
||||
value: 10000
|
||||
sort:
|
||||
- field: updated_at
|
||||
direction: desc
|
||||
columns:
|
||||
- name
|
||||
- status
|
||||
- amount
|
||||
- assignee
|
||||
|
||||
- name: "Overdue"
|
||||
filters:
|
||||
id: root
|
||||
conjunction: and
|
||||
rules:
|
||||
- id: f1
|
||||
field: due_date
|
||||
operator: before
|
||||
value: today
|
||||
- id: f2
|
||||
field: status
|
||||
operator: is_not
|
||||
value: Done
|
||||
|
||||
active_view: "Active deals"
|
||||
```
|
||||
|
||||
**Relative date filters** (e.g. "in the last 7 days"):
|
||||
|
||||
```yaml
|
||||
- id: f1
|
||||
field: created_at
|
||||
operator: relative_past
|
||||
relativeAmount: 7
|
||||
relativeUnit: days
|
||||
```
|
||||
|
||||
**OR groups** (match any rule):
|
||||
|
||||
```yaml
|
||||
filters:
|
||||
id: root
|
||||
conjunction: or
|
||||
rules:
|
||||
- id: f1
|
||||
field: status
|
||||
operator: is
|
||||
value: "Active"
|
||||
- id: f2
|
||||
field: priority
|
||||
operator: is
|
||||
value: "High"
|
||||
```
|
||||
|
||||
**When the user asks to filter/show/hide entries by natural language**, write the `.object.yaml` with the appropriate views and set `active_view`. The web UI will pick up the change instantly via SSE file watcher. Every rule needs a unique `id` (short alphanumeric string). The root filter group also needs `id: root`.
|
||||
|
||||
Generate by querying DuckDB then writing the file:
|
||||
|
||||
```bash
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user