refactor(web): enhance entry detail and object table components for improved data handling

This commit introduces several enhancements across the EntryDetailModal and ObjectTable components. Key changes include the addition of a FormattedFieldValue component for consistent display of various field types, improved handling of entry metadata, and the introduction of input type resolution for fields. Additionally, navigation callbacks for entries have been refined to support better interaction within the object table. These updates aim to streamline data presentation and enhance user experience.
This commit is contained in:
kumarabhirup 2026-03-03 22:54:12 -08:00
parent ad41e74205
commit 68015d6c14
No known key found for this signature in database
GPG Key ID: DB7CA2289CAB0167
8 changed files with 920 additions and 50 deletions

View File

@ -65,20 +65,40 @@ export async function GET(
`SELECT * FROM fields WHERE object_id = '${sqlEscape(obj.id)}' ORDER BY sort_order`,
);
const displayFieldName = resolveDisplayField(obj, fields);
const displayFieldId = fields.find((field) => field.name === displayFieldName)?.id;
// Optional search filter
const url = new URL(req.url);
const query = url.searchParams.get("q")?.trim() ?? "";
const escapedQuery = sqlEscape(query.toLowerCase());
const searchWhereSql = query
? `
AND (
LOWER(e.id) LIKE '%${escapedQuery}%'
OR EXISTS (
SELECT 1
FROM entry_fields search_ef
WHERE search_ef.entry_id = e.id
AND search_ef.value IS NOT NULL
AND LOWER(search_ef.value) LIKE '%${escapedQuery}%'
)
)
`
: "";
// Fetch entries with their display field value
// Fetch entries and always label by the effective display/main field.
// Search can match any field, but labels stay stable for foreign-entry pickers.
const rows = duckdbQueryOnFile<{ entry_id: string; label: string | null }>(dbFile,
`SELECT e.id as entry_id, ef.value as label
`SELECT
e.id as entry_id,
display_ef.value as label
FROM entries e
LEFT JOIN entry_fields ef ON ef.entry_id = e.id
LEFT JOIN fields f ON f.id = ef.field_id AND f.name = '${sqlEscape(displayFieldName)}'
LEFT JOIN entry_fields display_ef
ON display_ef.entry_id = e.id
${displayFieldId ? `AND display_ef.field_id = '${sqlEscape(displayFieldId)}'` : "AND 1 = 0"}
WHERE e.object_id = '${sqlEscape(obj.id)}'
${query ? `AND (ef.value IS NOT NULL AND LOWER(ef.value) LIKE '%${sqlEscape(query.toLowerCase())}%')` : ""}
ORDER BY ef.value ASC NULLS LAST
${searchWhereSql}
ORDER BY COALESCE(display_ef.value, e.id) ASC
LIMIT 200`,
);

View File

@ -2,6 +2,7 @@
import { useEffect, useState, useCallback, useRef } from "react";
import { RelationSelect } from "./relation-select";
import { FormattedFieldValue } from "./formatted-field-value";
function safeString(val: unknown): string {
@ -12,6 +13,9 @@ function safeString(val: unknown): string {
return "";
}
const CREATED_AT_KEYS = ["created_at", "Created", "createdAt", "created"] as const;
const UPDATED_AT_KEYS = ["updated_at", "Updated", "updatedAt", "updated"] as const;
// --- Types ---
type Field = {
@ -79,6 +83,36 @@ function parseRelationValue(value: string | null | undefined): string[] {
return [trimmed];
}
function inputTypeForField(fieldType: string): React.HTMLInputTypeAttribute {
switch (fieldType) {
case "number":
return "number";
case "date":
return "date";
case "email":
return "email";
case "phone":
return "tel";
case "url":
return "url";
default:
return "text";
}
}
function resolveEntryMetaValue(
entry: Record<string, unknown>,
candidateKeys: readonly string[],
): unknown {
for (const key of candidateKeys) {
const value = entry[key];
if (value !== null && value !== undefined && value !== "") {
return value;
}
}
return undefined;
}
// --- Cell renderers (lightweight variants of object-table ones) ---
function EnumBadge({
@ -148,7 +182,10 @@ function RelationChips({
{ids.map((id) => {
const label = fieldLabels?.[id] ?? id;
const handleClick = field.related_object_name && onNavigateEntry
? () => onNavigateEntry(field.related_object_name!, id)
? (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
onNavigateEntry(field.related_object_name!, id);
}
: undefined;
return (
<button
@ -275,19 +312,16 @@ function FieldValue({
/>
);
case "email":
return (
<a href={`mailto:${safeString(value)}`} className="underline underline-offset-2" style={{ color: "#60a5fa" }}>
{safeString(value)}
</a>
);
case "number":
case "date":
case "phone":
case "url":
case "file":
return <FormattedFieldValue value={value} fieldType={field.type} mode="detail" />;
case "richtext":
return <span className="whitespace-pre-wrap">{safeString(value)}</span>;
case "number":
return <span className="tabular-nums">{safeString(value)}</span>;
case "date":
return <span>{safeString(value)}</span>;
default:
return <span>{safeString(value)}</span>;
return <FormattedFieldValue value={value} fieldType={field.type} mode="detail" />;
}
}
@ -408,6 +442,8 @@ export function EntryDetailModal({
const title = displayField && data?.entry[displayField]
? safeString(data.entry[displayField])
: `${String(objectName)} entry`;
const createdAtValue = data ? resolveEntryMetaValue(data.entry, CREATED_AT_KEYS) : undefined;
const updatedAtValue = data ? resolveEntryMetaValue(data.entry, UPDATED_AT_KEYS) : undefined;
return (
<div
@ -565,7 +601,7 @@ export function EntryDetailModal({
) : (
<>
<input
type={field.type === "number" ? "number" : field.type === "date" ? "date" : "text"}
type={inputTypeForField(field.type)}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
autoFocus
@ -630,16 +666,20 @@ export function EntryDetailModal({
)}
{/* Timestamps */}
{(data.entry.created_at != null || data.entry.updated_at != null) && (
{(createdAtValue != null || updatedAtValue != null) && (
<div
className="pt-4 mt-4 border-t text-xs flex gap-6"
style={{ borderColor: "var(--color-border)", color: "var(--color-text-muted)" }}
>
{data.entry.created_at != null && (
<span>Created: {safeString(data.entry.created_at)}</span>
{createdAtValue != null && (
<span>
Created: <FormattedFieldValue value={createdAtValue} fieldType="date" mode="detail" className="inline" />
</span>
)}
{data.entry.updated_at != null && (
<span>Updated: {safeString(data.entry.updated_at)}</span>
{updatedAtValue != null && (
<span>
Updated: <FormattedFieldValue value={updatedAtValue} fieldType="date" mode="detail" className="inline" />
</span>
)}
</div>
)}

View File

@ -0,0 +1,100 @@
"use client";
import { formatWorkspaceFieldValue } from "@/lib/workspace-cell-format";
type FormattedFieldValueProps = {
value: unknown;
fieldType?: string;
mode?: "table" | "detail";
className?: string;
};
function EmptyValue() {
return <span style={{ color: "var(--color-text-muted)", opacity: 0.5 }}>--</span>;
}
function FileEmbed({
mediaType,
url,
label,
}: {
mediaType: "image" | "video" | "audio" | "pdf";
url: string;
label: string;
}) {
if (mediaType === "image") {
return (
<div className="mt-2 rounded-lg overflow-hidden border" style={{ borderColor: "var(--color-border)" }}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={url} alt={label} className="max-h-64 w-auto" />
</div>
);
}
if (mediaType === "video") {
return (
<video
src={url}
controls
className="mt-2 w-full max-h-72 rounded-lg border"
style={{ borderColor: "var(--color-border)", background: "#000" }}
/>
);
}
if (mediaType === "audio") {
return <audio src={url} controls className="mt-2 w-full" />;
}
return (
<iframe
src={`${url}#toolbar=0&navpanes=0&scrollbar=1`}
title={label}
className="mt-2 w-full h-72 rounded-lg border"
style={{ borderColor: "var(--color-border)", background: "white" }}
/>
);
}
export function FormattedFieldValue({
value,
fieldType,
mode = "table",
className,
}: FormattedFieldValueProps) {
const formatted = formatWorkspaceFieldValue(value, fieldType);
const isTableMode = mode === "table";
const textClassName = className ?? (isTableMode ? "truncate block max-w-[300px]" : "break-words");
if (formatted.kind === "empty") {
return <EmptyValue />;
}
if (formatted.kind === "link" && formatted.href) {
const openInNewTab = formatted.linkType === "url" || formatted.linkType === "file";
const canEmbedInModal = !isTableMode && !!formatted.embedUrl && !!formatted.mediaType;
return (
<div className={isTableMode ? "truncate block max-w-[300px]" : "w-full"}>
<a
href={formatted.href}
{...(openInNewTab ? { target: "_blank", rel: "noopener noreferrer" } : {})}
className={`underline underline-offset-2 ${isTableMode ? "truncate block" : ""}`}
style={{ color: "var(--color-accent)" }}
onClick={(e) => e.stopPropagation()}
>
{formatted.text}
</a>
{canEmbedInModal && (
<FileEmbed
mediaType={formatted.mediaType!}
url={formatted.embedUrl!}
label={formatted.text}
/>
)}
</div>
);
}
if (formatted.kind === "number" || formatted.kind === "currency") {
return <span className={`tabular-nums ${textClassName}`}>{formatted.text}</span>;
}
return <span className={textClassName}>{formatted.text}</span>;
}

View File

@ -4,6 +4,7 @@ import { useState, useMemo, useCallback, useRef, useEffect } from "react";
import { type ColumnDef, type CellContext } from "@tanstack/react-table";
import { DataTable, type RowAction } from "./data-table";
import { RelationSelect } from "./relation-select";
import { FormattedFieldValue } from "./formatted-field-value";
/* ─── Types ─── */
@ -44,6 +45,7 @@ type ObjectTableProps = {
relationLabels?: Record<string, Record<string, string>>;
reverseRelations?: ReverseRelation[];
onNavigateToObject?: (objectName: string) => void;
onNavigateToEntry?: (objectName: string, entryId: string) => void;
onEntryClick?: (entryId: string) => void;
onRefresh?: () => void;
/** Column visibility state keyed by field ID. */
@ -56,6 +58,9 @@ type ObjectTableProps = {
type EntryRow = Record<string, unknown> & { entry_id?: string };
const CREATED_AT_KEYS = ["created_at", "Created", "createdAt", "created"] as const;
const UPDATED_AT_KEYS = ["updated_at", "Updated", "updatedAt", "updated"] as const;
/* ─── Helpers ─── */
/** Safely convert unknown (DuckDB) value to string for display. */
@ -81,6 +86,36 @@ function parseRelationValue(value: string | null | undefined): string[] {
return [trimmed];
}
function inputTypeForField(fieldType: string): React.HTMLInputTypeAttribute {
switch (fieldType) {
case "number":
return "number";
case "date":
return "date";
case "email":
return "email";
case "phone":
return "tel";
case "url":
return "url";
default:
return "text";
}
}
function resolveEntryMetaValue(
entry: Record<string, unknown>,
candidateKeys: readonly string[],
): unknown {
for (const key of candidateKeys) {
const value = entry[key];
if (value !== null && value !== undefined && value !== "") {
return value;
}
}
return undefined;
}
/* ─── Cell Renderers (read-only display) ─── */
function EnumBadge({ value, enumValues, enumColors }: { value: string; enumValues?: string[]; enumColors?: string[] }) {
@ -119,11 +154,12 @@ function UserCell({ value, members }: { value: unknown; members?: Array<{ id: st
}
function RelationCell({
value, field, relationLabels, onNavigate,
value, field, relationLabels, onNavigateObject, onNavigateEntry,
}: {
value: unknown; field: Field;
relationLabels?: Record<string, Record<string, string>>;
onNavigate?: (objectName: string) => void;
onNavigateObject?: (objectName: string) => void;
onNavigateEntry?: (objectName: string, entryId: string) => void;
}) {
const fieldLabels = relationLabels?.[field.name];
const ids = parseRelationValue(String(value));
@ -133,8 +169,17 @@ function RelationCell({
{ids.map((id) => (
<span
key={id}
onClick={(e) => { if (field.related_object_name && onNavigate) { e.stopPropagation(); onNavigate(field.related_object_name); } }}
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-xs font-medium ${field.related_object_name && onNavigate ? "cursor-pointer" : ""}`}
onClick={(e) => {
if (!field.related_object_name) {return;}
if (!onNavigateEntry && !onNavigateObject) {return;}
e.stopPropagation();
if (onNavigateEntry) {
onNavigateEntry(field.related_object_name, id);
return;
}
onNavigateObject?.(field.related_object_name);
}}
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-xs font-medium ${field.related_object_name && (onNavigateEntry || onNavigateObject) ? "cursor-pointer" : ""}`}
style={{ background: "var(--color-chip-document)", color: "var(--color-chip-document-text)", border: "1px solid var(--color-border)" }}
>
<span className="truncate max-w-[180px]">{fieldLabels?.[id] ?? id}</span>
@ -144,10 +189,11 @@ function RelationCell({
);
}
function ReverseRelationCell({ links, sourceObjectName, onNavigate }: {
function ReverseRelationCell({ links, sourceObjectName, onNavigateObject, onNavigateEntry }: {
links: Array<{ id: string; label: string }>;
sourceObjectName: string;
onNavigate?: (objectName: string) => void;
onNavigateObject?: (objectName: string) => void;
onNavigateEntry?: (objectName: string, entryId: string) => void;
}) {
if (!links || links.length === 0) {return <span style={{ color: "var(--color-text-muted)", opacity: 0.5 }}>--</span>;}
const display = links.slice(0, 5);
@ -157,8 +203,16 @@ function ReverseRelationCell({ links, sourceObjectName, onNavigate }: {
{display.map((link) => (
<span
key={link.id}
onClick={(e) => { e.stopPropagation(); onNavigate?.(sourceObjectName); }}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-xs font-medium cursor-pointer"
onClick={(e) => {
if (!onNavigateEntry && !onNavigateObject) {return;}
e.stopPropagation();
if (onNavigateEntry) {
onNavigateEntry(sourceObjectName, link.id);
return;
}
onNavigateObject?.(sourceObjectName);
}}
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-xs font-medium ${onNavigateEntry || onNavigateObject ? "cursor-pointer" : ""}`}
style={{ background: "var(--color-chip-database)", color: "var(--color-chip-database-text)", border: "1px solid var(--color-border)" }}
>
<span className="truncate max-w-[180px]">{link.label}</span>
@ -179,7 +233,8 @@ function EditableCell({
field,
members,
relationLabels,
onNavigate,
onNavigateObject,
onNavigateEntry,
onLocalValueChange,
onSaved,
}: {
@ -190,7 +245,8 @@ function EditableCell({
field: Field;
members?: Array<{ id: string; name: string }>;
relationLabels?: Record<string, Record<string, string>>;
onNavigate?: (objectName: string) => void;
onNavigateObject?: (objectName: string) => void;
onNavigateEntry?: (objectName: string, entryId: string) => void;
onLocalValueChange?: (value: string) => void;
onSaved?: () => void;
}) {
@ -306,7 +362,7 @@ function EditableCell({
editInput = (
<input
ref={inputRef as React.RefObject<HTMLInputElement>}
type={field.type === "number" ? "number" : field.type === "date" ? "date" : "text"}
type={inputTypeForField(field.type)}
value={localValue}
onChange={(e) => handleChange(e.target.value)}
onBlur={handleBlur}
@ -340,7 +396,13 @@ function EditableCell({
className="cursor-cell min-h-[1.5em]"
title="Double-click to edit"
>
<RelationCell value={initialValue} field={field} relationLabels={relationLabels} onNavigate={onNavigate} />
<RelationCell
value={initialValue}
field={field}
relationLabels={relationLabels}
onNavigateObject={onNavigateObject}
onNavigateEntry={onNavigateEntry}
/>
</div>
);
}
@ -357,14 +419,8 @@ function EditableCell({
<EnumBadge value={safeString(displayValue)} enumValues={field.enum_values} enumColors={field.enum_colors} />
) : field.type === "boolean" ? (
<BooleanCell value={displayValue} />
) : field.type === "email" ? (
<a href={`mailto:${safeString(displayValue)}`} className="underline underline-offset-2" style={{ color: "var(--color-accent)" }} onClick={(e) => e.stopPropagation()}>
{safeString(displayValue)}
</a>
) : field.type === "number" ? (
<span className="tabular-nums">{safeString(displayValue)}</span>
) : (
<span className="truncate block max-w-[300px]">{safeString(displayValue)}</span>
<FormattedFieldValue value={displayValue} fieldType={field.type} mode="table" />
)}
</div>
);
@ -380,6 +436,7 @@ export function ObjectTable({
relationLabels,
reverseRelations,
onNavigateToObject,
onNavigateToEntry,
onEntryClick,
onRefresh,
columnVisibility,
@ -461,7 +518,8 @@ export function ObjectTable({
field={field}
members={members}
relationLabels={relationLabels}
onNavigate={onNavigateToObject}
onNavigateObject={onNavigateToObject}
onNavigateEntry={onNavigateToEntry}
onLocalValueChange={(value) => updateLocalEntryField(entryId, field.name, value)}
onSaved={onRefresh}
/>
@ -471,6 +529,38 @@ export function ObjectTable({
enableSorting: true,
}));
cols.push({
id: "created_at",
accessorFn: (row) => resolveEntryMetaValue(row, CREATED_AT_KEYS),
meta: { label: "Created", fieldName: "created_at" },
header: () => (
<span className="flex items-center gap-1" style={{ color: "var(--color-text-muted)" }}>
Created
</span>
),
cell: (info: CellContext<EntryRow, unknown>) => (
<FormattedFieldValue value={info.getValue()} fieldType="date" mode="table" />
),
size: 190,
enableSorting: true,
});
cols.push({
id: "updated_at",
accessorFn: (row) => resolveEntryMetaValue(row, UPDATED_AT_KEYS),
meta: { label: "Updated", fieldName: "updated_at" },
header: () => (
<span className="flex items-center gap-1" style={{ color: "var(--color-text-muted)" }}>
Updated
</span>
),
cell: (info: CellContext<EntryRow, unknown>) => (
<FormattedFieldValue value={info.getValue()} fieldType="date" mode="table" />
),
size: 190,
enableSorting: true,
});
// Add reverse relation columns
for (const rr of activeReverseRelations) {
cols.push({
@ -489,7 +579,14 @@ export function ObjectTable({
const eid = info.row.original.entry_id;
const entryId = String(eid != null && typeof eid === "object" ? JSON.stringify(eid) : (eid ?? ""));
const links = rr.entries[entryId] ?? [];
return <ReverseRelationCell links={links} sourceObjectName={rr.sourceObjectName} onNavigate={onNavigateToObject} />;
return (
<ReverseRelationCell
links={links}
sourceObjectName={rr.sourceObjectName}
onNavigateObject={onNavigateToObject}
onNavigateEntry={onNavigateToEntry}
/>
);
},
enableSorting: false,
size: 200,
@ -497,7 +594,7 @@ export function ObjectTable({
}
return cols;
}, [fields, activeReverseRelations, objectName, members, relationLabels, onNavigateToObject, onRefresh]);
}, [fields, activeReverseRelations, objectName, members, relationLabels, onNavigateToObject, onNavigateToEntry, onRefresh]);
// Add entry handler — opens modal instead of creating empty entry
const handleAdd = useCallback(() => {
@ -567,8 +664,9 @@ export function ObjectTable({
// Column reorder handler
const handleColumnReorder = useCallback(
async (newOrder: string[]) => {
// Map column IDs back to field IDs (exclude select, actions, and reverse relations)
const fieldIds = newOrder.filter((id) => !id.startsWith("rev_") && id !== "select" && id !== "actions");
// Persist only real object field IDs (ignore synthetic/system columns).
const fieldIdSet = new Set(fields.map((field) => field.id));
const fieldIds = newOrder.filter((id) => fieldIdSet.has(id));
try {
await fetch(`/api/workspace/objects/${encodeURIComponent(objectName)}/fields/reorder`, {
method: "PATCH",
@ -577,7 +675,7 @@ export function ObjectTable({
});
} catch { /* ignore */ }
},
[objectName],
[objectName, fields],
);
// Bulk actions toolbar
@ -804,7 +902,7 @@ function AddEntryModal({
</select>
) : (
<input
type={field.type === "number" ? "number" : field.type === "date" ? "date" : field.type === "email" ? "email" : "text"}
type={inputTypeForField(field.type)}
value={values[field.name] ?? ""}
onChange={(e) => updateField(field.name, e.target.value)}
className="w-full px-3 py-2 text-sm rounded-lg outline-none"

View File

@ -2613,6 +2613,7 @@ function ObjectView({
relationLabels={data.relationLabels}
reverseRelations={data.reverseRelations}
onNavigateToObject={onNavigateToObject}
onNavigateToEntry={onOpenEntry}
onEntryClick={onOpenEntry ? (entryId) => onOpenEntry(data.object.name, entryId) : undefined}
onRefresh={handleRefresh}
columnVisibility={columnVisibility}

View File

@ -0,0 +1,110 @@
import { describe, expect, it } from "vitest";
import {
buildRawFileUrl,
detectFileMediaType,
formatWorkspaceFieldValue,
} from "./workspace-cell-format";
describe("formatWorkspaceFieldValue", () => {
it("returns empty kind for nullish/blank values (prevents noisy placeholders)", () => {
expect(formatWorkspaceFieldValue(null, "text")).toEqual({
kind: "empty",
raw: "",
text: "",
});
expect(formatWorkspaceFieldValue(" ", "text")).toEqual({
kind: "empty",
raw: " ",
text: "",
});
});
it("formats schema email as clickable mailto link", () => {
const result = formatWorkspaceFieldValue("hello@example.com", "email");
expect(result.kind).toBe("link");
expect(result.linkType).toBe("email");
expect(result.href).toBe("mailto:hello@example.com");
});
it("formats schema phone as clickable tel link", () => {
const result = formatWorkspaceFieldValue("+1 (555) 123-4567", "phone");
expect(result.kind).toBe("link");
expect(result.linkType).toBe("phone");
expect(result.href).toBe("tel:+15551234567");
});
it("formats schema date into a normalized date payload", () => {
const result = formatWorkspaceFieldValue("2026-03-03", "date");
expect(result.kind).toBe("date");
expect(result.isoDate).toBeTruthy();
expect(result.text.length).toBeGreaterThan(0);
});
it("formats DuckDB timestamps with microseconds and short timezone offsets", () => {
const raw = "2026-03-03 19:27:02.880576-08";
const result = formatWorkspaceFieldValue(raw, "date");
expect(result.kind).toBe("date");
expect(result.isoDate).toBe("2026-03-04T03:27:02.880Z");
expect(result.text).not.toBe(raw);
});
it("formats schema number with dollar prefix as currency", () => {
const result = formatWorkspaceFieldValue("$1,234.50", "number");
expect(result.kind).toBe("currency");
expect(result.numericValue).toBe(1234.5);
});
it("falls back to URL heuristics for text fields", () => {
const result = formatWorkspaceFieldValue("www.example.com", "text");
expect(result.kind).toBe("link");
expect(result.linkType).toBe("url");
expect(result.href).toBe("https://www.example.com");
});
it("identifies workspace file links and includes embed metadata", () => {
const result = formatWorkspaceFieldValue("/workspace?path=docs%2Fdeck.pdf", "text");
expect(result.kind).toBe("link");
expect(result.linkType).toBe("file");
expect(result.filePath).toBe("docs/deck.pdf");
expect(result.href).toContain("/workspace?path=");
expect(result.mediaType).toBe("pdf");
expect(result.embedUrl).toBe("/api/workspace/raw-file?path=docs%2Fdeck.pdf");
});
it("identifies local absolute paths and routes embed URL via browse-file API", () => {
const result = formatWorkspaceFieldValue("/Users/me/Desktop/photo.png", "text");
expect(result.kind).toBe("link");
expect(result.linkType).toBe("file");
expect(result.embedUrl).toBe(
"/api/workspace/browse-file?path=%2FUsers%2Fme%2FDesktop%2Fphoto.png&raw=true",
);
});
it("avoids over-formatting richtext fields with link heuristics", () => {
const result = formatWorkspaceFieldValue("https://example.com", "richtext");
expect(result.kind).toBe("text");
expect(result.text).toBe("https://example.com");
});
});
describe("detectFileMediaType", () => {
it("classifies media extensions used by embeds", () => {
expect(detectFileMediaType("demo.png")).toBe("image");
expect(detectFileMediaType("demo.mp4")).toBe("video");
expect(detectFileMediaType("demo.mp3")).toBe("audio");
expect(detectFileMediaType("demo.pdf")).toBe("pdf");
expect(detectFileMediaType("demo.txt")).toBeUndefined();
});
});
describe("buildRawFileUrl", () => {
it("maps relative and absolute paths to the correct file APIs", () => {
expect(buildRawFileUrl("notes/today.md")).toBe("/api/workspace/raw-file?path=notes%2Ftoday.md");
expect(buildRawFileUrl("/Users/me/file.txt")).toBe(
"/api/workspace/browse-file?path=%2FUsers%2Fme%2Ffile.txt&raw=true",
);
expect(buildRawFileUrl("https://cdn.example.com/image.png")).toBe(
"https://cdn.example.com/image.png",
);
});
});

View File

@ -0,0 +1,501 @@
import { buildFileLink, parseWorkspaceLink } from "./workspace-links";
export type WorkspaceMediaType = "image" | "video" | "audio" | "pdf";
export type WorkspaceLinkType = "url" | "email" | "phone" | "file";
export type FormattedWorkspaceValue = {
kind: "empty" | "text" | "number" | "currency" | "date" | "link";
raw: string;
text: string;
linkType?: WorkspaceLinkType;
href?: string;
filePath?: string;
mediaType?: WorkspaceMediaType;
embedUrl?: string;
numericValue?: number;
isoDate?: string;
};
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const URL_RE = /^https?:\/\/\S+$/i;
const PHONE_RE = /^\+?[0-9().\-\s]{7,}$/;
const DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/;
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}(?:[T\s]\d{2}:\d{2}(?::\d{2}(?:\.\d{1,9})?)?(?:Z|[+-]\d{2}(?::?\d{2})?)?)?$/;
const SLASH_DATE_RE = /^\d{1,2}\/\d{1,2}\/\d{2,4}$/;
const CURRENCY_RE = /^([$\u20ac\u00a3\u00a5])\s*(-?\d[\d,]*(?:\.\d+)?)$/;
const NUMBER_RE = /^-?\d[\d,]*(?:\.\d+)?$/;
const IMAGE_EXTS = new Set([
"jpg",
"jpeg",
"png",
"gif",
"webp",
"svg",
"bmp",
"avif",
"heic",
"heif",
"ico",
"tiff",
"tif",
]);
const VIDEO_EXTS = new Set(["mp4", "webm", "mov", "avi", "mkv"]);
const AUDIO_EXTS = new Set(["mp3", "wav", "ogg", "m4a", "aac", "flac"]);
const PDF_EXTS = new Set(["pdf"]);
const KNOWN_FILE_EXTS = new Set([
...IMAGE_EXTS,
...VIDEO_EXTS,
...AUDIO_EXTS,
...PDF_EXTS,
"txt",
"md",
"json",
"yaml",
"yml",
"csv",
"sql",
"doc",
"docx",
"xls",
"xlsx",
"ppt",
"pptx",
"zip",
"tar",
"gz",
]);
function normalizeFieldType(fieldType: string | undefined): string {
return (fieldType ?? "").trim().toLowerCase();
}
export function toDisplayString(value: unknown): string {
if (value == null) {return "";}
if (typeof value === "string") {return value;}
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
return String(value);
}
if (typeof value === "object") {
try {
return JSON.stringify(value);
} catch {
return "";
}
}
return "";
}
function getExtension(pathLike: string): string {
const clean = pathLike.split("?")[0]?.split("#")[0] ?? pathLike;
const ext = clean.split(".").pop()?.toLowerCase() ?? "";
return ext;
}
export function detectFileMediaType(pathLike: string): WorkspaceMediaType | undefined {
const ext = getExtension(pathLike);
if (!ext) {return undefined;}
if (IMAGE_EXTS.has(ext)) {return "image";}
if (VIDEO_EXTS.has(ext)) {return "video";}
if (AUDIO_EXTS.has(ext)) {return "audio";}
if (PDF_EXTS.has(ext)) {return "pdf";}
return undefined;
}
function looksLikeAbsoluteFsPath(value: string): boolean {
return value.startsWith("/") || value.startsWith("~/") || /^[A-Za-z]:[\\/]/.test(value);
}
function decodeFileUrl(value: string): string | null {
if (!value.startsWith("file://")) {return null;}
try {
const url = new URL(value);
if (url.protocol !== "file:") {return null;}
const decoded = decodeURIComponent(url.pathname);
if (/^\/[A-Za-z]:\//.test(decoded)) {
return decoded.slice(1);
}
return decoded;
} catch {
return null;
}
}
function looksLikeFilePath(value: string): boolean {
const trimmed = value.trim();
if (!trimmed) {return false;}
if (trimmed.includes("://") && !trimmed.startsWith("file://")) {return false;}
if (trimmed.startsWith("./") || trimmed.startsWith("../") || looksLikeAbsoluteFsPath(trimmed)) {
return true;
}
const hasSlash = trimmed.includes("/") || trimmed.includes("\\");
const ext = getExtension(trimmed);
if (hasSlash) {
return ext.length > 0 && ext.length <= 8;
}
return KNOWN_FILE_EXTS.has(ext);
}
function inferFilePath(raw: string): string | null {
const parsed = parseWorkspaceLink(raw);
if (parsed?.kind === "file") {
return parsed.path;
}
const fromFileUrl = decodeFileUrl(raw);
if (fromFileUrl) {
return fromFileUrl;
}
const trimmed = raw.trim();
if (!looksLikeFilePath(trimmed)) {
return null;
}
return trimmed;
}
function normalizeUrl(raw: string): string | null {
const trimmed = raw.trim();
if (!trimmed) {return null;}
if (trimmed.startsWith("www.")) {
return `https://${trimmed}`;
}
if (!URL_RE.test(trimmed)) {
return null;
}
try {
const u = new URL(trimmed);
if (u.protocol !== "http:" && u.protocol !== "https:") {
return null;
}
return u.toString();
} catch {
return null;
}
}
function normalizePhone(raw: string): string | null {
const trimmed = raw.trim();
if (!PHONE_RE.test(trimmed)) {
return null;
}
const digits = trimmed.replace(/\D/g, "");
if (digits.length < 7) {
return null;
}
const telTarget = trimmed.replace(/[^\d+]/g, "");
if (!telTarget) {
return null;
}
return `tel:${telTarget}`;
}
function parseLooseNumber(raw: string): number | null {
const normalized = raw.replace(/,/g, "").trim();
if (!normalized) {return null;}
const parsed = Number(normalized);
return Number.isFinite(parsed) ? parsed : null;
}
function formatNumber(value: number): string {
return new Intl.NumberFormat(undefined, {
maximumFractionDigits: 6,
}).format(value);
}
function currencyCodeForSymbol(symbol: string): string | null {
switch (symbol) {
case "$":
return "USD";
case "\u20ac":
return "EUR";
case "\u00a3":
return "GBP";
case "\u00a5":
return "JPY";
default:
return null;
}
}
function formatCurrency(symbol: string, amount: number): string {
const code = currencyCodeForSymbol(symbol);
if (!code) {
return `${symbol}${formatNumber(amount)}`;
}
return new Intl.NumberFormat(undefined, {
style: "currency",
currency: code,
maximumFractionDigits: 2,
}).format(amount);
}
function normalizeIsoDateTime(raw: string): string {
let normalized = raw;
if (normalized.includes(" ")) {
normalized = normalized.replace(" ", "T");
}
// JS Date only keeps millisecond precision; trim extra fractional digits.
normalized = normalized.replace(
/(\.\d{3})\d+(?=(?:Z|[+-]\d{2}(?::?\d{2})?)?$)/,
"$1",
);
// DuckDB can emit short timezone offsets like -08; normalize to -08:00.
normalized = normalized.replace(/([+-]\d{2})$/, "$1:00");
// Also normalize compact offsets like +0530 -> +05:30.
normalized = normalized.replace(/([+-]\d{2})(\d{2})$/, "$1:$2");
return normalized;
}
function parseDate(raw: string): Date | null {
const trimmed = raw.trim();
if (DATE_ONLY_RE.test(trimmed)) {
const [y, m, d] = trimmed.split("-").map((part) => Number(part));
const localDate = new Date(y, (m ?? 1) - 1, d ?? 1);
return Number.isNaN(localDate.getTime()) ? null : localDate;
}
if (!(ISO_DATE_RE.test(trimmed) || SLASH_DATE_RE.test(trimmed))) {
return null;
}
const d = new Date(normalizeIsoDateTime(trimmed));
if (Number.isNaN(d.getTime())) {
return null;
}
return d;
}
function formatDate(raw: string): { text: string; iso: string } | null {
const d = parseDate(raw);
if (!d) {return null;}
const hasTime = raw.includes("T") || /\d{1,2}:\d{2}/.test(raw);
const text = hasTime
? new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short",
}).format(d)
: new Intl.DateTimeFormat(undefined, { dateStyle: "medium" }).format(d);
return { text, iso: d.toISOString() };
}
export function buildRawFileUrl(path: string): string {
if (path.startsWith("http://") || path.startsWith("https://")) {
return path;
}
if (looksLikeAbsoluteFsPath(path)) {
return `/api/workspace/browse-file?path=${encodeURIComponent(path)}&raw=true`;
}
return `/api/workspace/raw-file?path=${encodeURIComponent(path)}`;
}
function formatBySchema(raw: string, fieldType: string): FormattedWorkspaceValue | null {
if (fieldType === "email" && EMAIL_RE.test(raw.trim())) {
const email = raw.trim();
return {
kind: "link",
raw,
text: email,
linkType: "email",
href: `mailto:${email}`,
};
}
if (fieldType === "phone") {
const telHref = normalizePhone(raw);
if (telHref) {
return {
kind: "link",
raw,
text: raw.trim(),
linkType: "phone",
href: telHref,
};
}
}
if (fieldType === "url") {
const href = normalizeUrl(raw);
if (href) {
return {
kind: "link",
raw,
text: raw.trim(),
linkType: "url",
href,
mediaType: detectFileMediaType(href),
embedUrl: href,
};
}
}
if (fieldType === "date") {
const formatted = formatDate(raw);
if (formatted) {
return {
kind: "date",
raw,
text: formatted.text,
isoDate: formatted.iso,
};
}
}
if (fieldType === "number") {
const currMatch = raw.trim().match(CURRENCY_RE);
if (currMatch) {
const amount = parseLooseNumber(currMatch[2] ?? "");
if (amount != null) {
return {
kind: "currency",
raw,
text: formatCurrency(currMatch[1] ?? "$", amount),
numericValue: amount,
};
}
}
const n = parseLooseNumber(raw);
if (n != null) {
return {
kind: "number",
raw,
text: formatNumber(n),
numericValue: n,
};
}
}
if (fieldType === "file") {
const filePath = inferFilePath(raw);
if (filePath) {
return {
kind: "link",
raw,
text: filePath,
linkType: "file",
href: buildFileLink(filePath),
filePath,
mediaType: detectFileMediaType(filePath),
embedUrl: buildRawFileUrl(filePath),
};
}
}
return null;
}
function formatByHeuristics(raw: string): FormattedWorkspaceValue {
const trimmed = raw.trim();
const filePath = inferFilePath(trimmed);
if (filePath) {
return {
kind: "link",
raw,
text: filePath,
linkType: "file",
href: buildFileLink(filePath),
filePath,
mediaType: detectFileMediaType(filePath),
embedUrl: buildRawFileUrl(filePath),
};
}
if (EMAIL_RE.test(trimmed)) {
return {
kind: "link",
raw,
text: trimmed,
linkType: "email",
href: `mailto:${trimmed}`,
};
}
const url = normalizeUrl(trimmed);
if (url) {
return {
kind: "link",
raw,
text: trimmed,
linkType: "url",
href: url,
mediaType: detectFileMediaType(url),
embedUrl: url,
};
}
const tel = normalizePhone(trimmed);
if (tel) {
return {
kind: "link",
raw,
text: trimmed,
linkType: "phone",
href: tel,
};
}
const date = formatDate(trimmed);
if (date) {
return {
kind: "date",
raw,
text: date.text,
isoDate: date.iso,
};
}
const currMatch = trimmed.match(CURRENCY_RE);
if (currMatch) {
const amount = parseLooseNumber(currMatch[2] ?? "");
if (amount != null) {
return {
kind: "currency",
raw,
text: formatCurrency(currMatch[1] ?? "$", amount),
numericValue: amount,
};
}
}
if (NUMBER_RE.test(trimmed)) {
const n = parseLooseNumber(trimmed);
if (n != null) {
return {
kind: "number",
raw,
text: formatNumber(n),
numericValue: n,
};
}
}
return {
kind: "text",
raw,
text: raw,
};
}
export function formatWorkspaceFieldValue(
value: unknown,
fieldType?: string,
): FormattedWorkspaceValue {
const raw = toDisplayString(value);
if (!raw || raw.trim().length === 0) {
return { kind: "empty", raw, text: "" };
}
const schemaType = normalizeFieldType(fieldType);
const schemaFormatted = formatBySchema(raw, schemaType);
if (schemaFormatted) {
return schemaFormatted;
}
// Limit heuristic formatting on rich text / relation-like fields.
if (schemaType === "richtext" || schemaType === "relation" || schemaType === "enum" || schemaType === "user") {
return {
kind: "text",
raw,
text: raw,
};
}
return formatByHeuristics(raw);
}

File diff suppressed because one or more lines are too long