diff --git a/apps/kitchen-sink/src/demo-catalog.tsx b/apps/kitchen-sink/src/demo-catalog.tsx index 6d4f2105..c6ec85a5 100644 --- a/apps/kitchen-sink/src/demo-catalog.tsx +++ b/apps/kitchen-sink/src/demo-catalog.tsx @@ -61,6 +61,8 @@ import { ToastDemo } from "./demos/ToastDemo"; import { CommentsDemo } from "./demos/CommentsDemo"; import { GavelStylingComparisonDemo } from "./demos/GavelStylingComparisonDemo"; import { HierarchicalLookupDemo } from "./demos/HierarchicalLookupDemo"; +import { ProfilesDemo } from "./demos/ProfilesDemo"; +import { QueryBrowserDemo } from "./demos/QueryBrowserDemo"; import { TourDemo } from "./demos/TourDemo"; import { type StaticIconComponent, @@ -431,6 +433,8 @@ export const DEMO_GROUPS: DemoGroup[] = [ { title: "Clicky-RPC", items: [ + { id: "query-browser", label: "QueryBrowser", component: QueryBrowserDemo, icon: UiTerminal }, + { id: "profiles", label: "Profiles", component: ProfilesDemo, icon: UiListTree }, { id: "command-form", label: "CommandForm", diff --git a/apps/kitchen-sink/src/demos/ProfilesDemo.tsx b/apps/kitchen-sink/src/demos/ProfilesDemo.tsx new file mode 100644 index 00000000..ccc24831 --- /dev/null +++ b/apps/kitchen-sink/src/demos/ProfilesDemo.tsx @@ -0,0 +1,197 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { useMemo, useState } from "react"; +import type { + OperationsApiClient, + ResolvedOperation, +} from "@flanksource/clicky-ui"; +import { + configureProfiles, + ProfileEditor, + type ProfileSchema, +} from "@flanksource/clicky-ui/profiles"; +import { DemoSection } from "./Section"; + +const schema: ProfileSchema = { + type: "object", + required: ["profile", "provider"], + properties: { + profile: { type: "string", title: "Profile name" }, + namespace: { type: "string", title: "Namespace" }, + render: { type: "string", enum: ["table", "logs"] }, + query: { type: "string", title: "Query" }, + params: { + type: "array", + title: "Parameters", + items: { + type: "object", + properties: { + name: { type: "string" }, + label: { type: "string" }, + type: { + type: "string", + enum: ["string", "number", "boolean", "date", "enum", "list"], + }, + role: { + type: "string", + enum: ["filter", "limit", "offset", "time-from", "time-to"], + }, + required: { type: "boolean" }, + }, + }, + }, + imports: { type: "array", items: { type: "string" } }, + aliases: { + type: "array", + items: { + type: "object", + properties: { + name: { type: "string" }, + value: { type: "string" }, + }, + }, + }, + processors: { + type: "array", + items: { + type: "object", + properties: { + use: { type: "string", enum: ["example.normalize", "example.redact"] }, + }, + }, + }, + output: { + type: "object", + properties: { title: { type: "string" } }, + }, + provider: { + type: "object", + properties: { + type: { type: "string", enum: ["sql", "opensearch"] }, + }, + }, + }, + $defs: { + sql: { + type: "object", + properties: { + options: { + type: "object", + properties: { + database: { type: "string", title: "Database" }, + }, + }, + }, + }, + opensearch: { + type: "object", + properties: { + options: { + type: "object", + properties: { index: { type: "string", title: "Index" } }, + }, + }, + }, + }, +}; + +configureProfiles({ schema }); + +const client: OperationsApiClient = { + async getOpenAPISpec() { + return { + openapi: "3.0.0", + info: { title: "Profile examples", version: "1.0.0" }, + paths: {}, + }; + }, + async executeCommand() { + return { success: true, exit_code: 0 }; + }, + async submitForm() { + return { success: true, exit_code: 0, message: "Profile saved" }; + }, +}; + +const action: ResolvedOperation = { + path: "/api/v1/profiles/{id}", + method: "put", + operation: { + operationId: "profile_update", + summary: "Update profile", + responses: { "200": { description: "Updated" } }, + }, +}; + +const initialValue = { + profile: "service-health", + namespace: "observability", + render: "table", + provider: { type: "sql", options: { database: "operations" } }, + query: + "SELECT observed_at, service, status, duration_ms FROM service_health ORDER BY observed_at DESC", + params: [ + { + name: "service", + label: "Service", + type: "string", + role: "filter", + }, + ], + columns: [ + { + name: "observed_at", + label: "Observed", + type: "datetime", + kind: "timestamp", + }, + { + name: "service", + label: "Service", + type: "string", + filter: { kind: "terms", lookup: true }, + }, + { + name: "status", + label: "Status", + type: "string", + kind: "status", + }, + ], +}; + +export function ProfilesDemo() { + const queryClient = useMemo( + () => + new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }), + [], + ); + const [savedProfile, setSavedProfile] = useState(""); + + return ( + + {savedProfile ? ( +

+ Saved {savedProfile} +

+ ) : null} + +
+ undefined} + onSuccess={setSavedProfile} + /> +
+
+
+ ); +} diff --git a/apps/kitchen-sink/src/demos/QueryBrowserDemo.tsx b/apps/kitchen-sink/src/demos/QueryBrowserDemo.tsx new file mode 100644 index 00000000..d0254873 --- /dev/null +++ b/apps/kitchen-sink/src/demos/QueryBrowserDemo.tsx @@ -0,0 +1,179 @@ +import { + QueryBrowser, + type DataTableServerColumn, + type JsonSchemaObject, + type QueryBrowserRequest, + type QueryBrowserResult, +} from "@flanksource/clicky-ui"; +import { DemoSection } from "./Section"; + +const rows: Record[] = [ + { + observed_at: "2026-08-11T08:14:32Z", + service: "Checkout API", + status: "healthy", + region: "eu-west", + duration_ms: 84, + }, + { + observed_at: "2026-08-11T08:14:21Z", + service: "Ledger Worker", + status: "degraded", + region: "us-east", + duration_ms: 413, + }, + { + observed_at: "2026-08-11T08:13:58Z", + service: "Identity API", + status: "healthy", + region: "eu-west", + duration_ms: 126, + }, + { + observed_at: "2026-08-11T08:13:44Z", + service: "Reporting API", + status: "failed", + region: "ap-south", + duration_ms: 1305, + }, + { + observed_at: "2026-08-11T08:13:12Z", + service: "Checkout API", + status: "healthy", + region: "us-east", + duration_ms: 91, + }, + { + observed_at: "2026-08-11T08:12:47Z", + service: "Ledger Worker", + status: "healthy", + region: "eu-west", + duration_ms: 204, + }, +]; + +const columns: DataTableServerColumn[] = [ + { name: "observed_at", label: "Observed", kind: "timestamp" }, + { + name: "service", + label: "Service", + filterKey: "service", + filter: { + kind: "terms", + options: ["Checkout API", "Ledger Worker", "Identity API", "Reporting API"].map( + (value) => ({ value }), + ), + }, + }, + { + name: "status", + label: "Status", + kind: "status", + filterKey: "status", + filter: { + kind: "terms", + options: ["healthy", "degraded", "failed"].map((value) => ({ value })), + }, + }, + { name: "region", label: "Region" }, + { name: "duration_ms", label: "Duration (ms)" }, +]; + +const optionsSchema: JsonSchemaObject = { + type: "object", + properties: { + database: { + type: "string", + title: "Database", + enum: ["operations", "analytics"], + }, + readOnly: { type: "boolean", title: "Read only" }, + }, +}; + +async function execute(request: QueryBrowserRequest): Promise { + const filtered = rows.filter((row) => + Object.entries(request.filters ?? {}).every(([key, encoded]) => { + const value = String(row[key] ?? ""); + const tokens = encoded.split(",").filter(Boolean); + const included = tokens.filter((token) => !token.startsWith("!")); + const excluded = tokens.filter((token) => token.startsWith("!")).map((token) => token.slice(1)); + return (included.length === 0 || included.includes(value)) && !excluded.includes(value); + }), + ); + const limit = request.pagination?.limit ?? 4; + const offset = request.pagination?.offset ?? 0; + const page = filtered.slice(offset, offset + limit); + return { + rows: page, + columns, + durationMs: 18, + pagination: { + mode: "offset", + limit, + offset, + hasMore: offset + limit < filtered.length, + total: filtered.length, + totalRelation: "eq", + consistency: "snapshot", + }, + ...(request.debug + ? { + diagnostics: { + provider: "postgresql", + request: { + query: request.query, + options: request.options, + details: { transaction: "read-only", plan: "Index Scan" }, + }, + response: { + durationMs: 18, + returnedRows: page.length, + contentType: "application/json", + preview: JSON.stringify(page), + }, + }, + } + : {}), + }; +} + +export function QueryBrowserDemo() { + return ( + + ({ name: column.name })), + }, + ], + }, + ], + }} + execute={execute} + className="h-[680px] min-h-0" + /> + + ); +} diff --git a/apps/kitchen-sink/src/demos/examples.test.tsx b/apps/kitchen-sink/src/demos/examples.test.tsx index e1c27fd5..1aed8bf5 100644 --- a/apps/kitchen-sink/src/demos/examples.test.tsx +++ b/apps/kitchen-sink/src/demos/examples.test.tsx @@ -4,6 +4,8 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { afterEach, describe, expect, it } from "vitest"; import { findDemoEntry } from "../demo-catalog"; import { HierarchicalLookupDemo } from "./HierarchicalLookupDemo"; +import { ProfilesDemo } from "./ProfilesDemo"; +import { QueryBrowserDemo } from "./QueryBrowserDemo"; import { TourDemo } from "./TourDemo"; afterEach(cleanup); @@ -14,6 +16,8 @@ describe("kitchen sink examples", () => { expect(findDemoEntry("hierarchical-lookup")?.component).toBe( HierarchicalLookupDemo, ); + expect(findDemoEntry("profiles")?.component).toBe(ProfilesDemo); + expect(findDemoEntry("query-browser")?.component).toBe(QueryBrowserDemo); }); it("starts the provider-managed guided tour from the demo", async () => { @@ -26,6 +30,14 @@ describe("kitchen sink examples", () => { ).toBeTruthy(); }); + it("renders the query browser starter SQL as multiple lines", () => { + const { container } = render(); + const editor = container.querySelector(".cm-content"); + + expect(editor?.textContent).toContain("FROM service_health"); + expect(editor?.textContent).not.toContain("\\n"); + }); + it("opens hierarchical lookup options as a tree", async () => { render(); diff --git a/apps/kitchen-sink/tsconfig.json b/apps/kitchen-sink/tsconfig.json index 36c24ec9..041121d3 100644 --- a/apps/kitchen-sink/tsconfig.json +++ b/apps/kitchen-sink/tsconfig.json @@ -5,7 +5,8 @@ "types": ["vite/client"], "paths": { "@flanksource/clicky-ui": ["../../packages/ui/src/index.ts"], - "@flanksource/clicky-ui/icons": ["../../packages/ui/src/icons.ts"] + "@flanksource/clicky-ui/icons": ["../../packages/ui/src/icons.ts"], + "@flanksource/clicky-ui/profiles": ["../../packages/ui/src/profiles.ts"] } }, "include": ["src", "vite.config.ts"] diff --git a/apps/kitchen-sink/vite.config.ts b/apps/kitchen-sink/vite.config.ts index d112a928..c4f92b59 100644 --- a/apps/kitchen-sink/vite.config.ts +++ b/apps/kitchen-sink/vite.config.ts @@ -24,6 +24,10 @@ export default defineConfig({ find: /^@flanksource\/clicky-ui\/rpc$/, replacement: resolve(uiSrc, "rpc.ts"), }, + { + find: /^@flanksource\/clicky-ui\/profiles$/, + replacement: resolve(uiSrc, "profiles.ts"), + }, { find: /^@flanksource\/clicky-ui\/chat$/, replacement: resolve(uiSrc, "chat.ts"), diff --git a/apps/storybook/.storybook/main.ts b/apps/storybook/.storybook/main.ts index 91c4cbe6..4a29deae 100644 --- a/apps/storybook/.storybook/main.ts +++ b/apps/storybook/.storybook/main.ts @@ -59,6 +59,15 @@ const config: StorybookConfig = { "react/jsx-runtime", "react/jsx-dev-runtime", "@flanksource/clicky-ui > clsx", + "@flanksource/clicky-ui > @codemirror/autocomplete", + "@flanksource/clicky-ui > @codemirror/commands", + "@flanksource/clicky-ui > @codemirror/lang-json", + "@flanksource/clicky-ui > @codemirror/lang-sql", + "@flanksource/clicky-ui > @codemirror/state", + "@flanksource/clicky-ui > @codemirror/view", + "@flanksource/clicky-ui > @monaco-editor/react", + "@flanksource/clicky-ui > monaco-editor", + "@flanksource/clicky-ui > monaco-yaml", "@flanksource/clicky-ui > tailwind-merge", "@flanksource/clicky-ui > class-variance-authority", "@flanksource/clicky-ui > @radix-ui/react-slot", diff --git a/packages/ui/package.json b/packages/ui/package.json index 4a17b49a..1290f5ec 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -92,6 +92,11 @@ "import": "./dist/monaco-schema.js", "require": "./dist/monaco-schema.cjs" }, + "./profiles": { + "types": "./dist/profiles.d.ts", + "import": "./dist/profiles.js", + "require": "./dist/profiles.cjs" + }, "./chat": { "types": "./dist/chat.d.ts", "import": "./dist/chat.js", diff --git a/packages/ui/src/components.ts b/packages/ui/src/components.ts index abc5e4b0..16518cc6 100644 --- a/packages/ui/src/components.ts +++ b/packages/ui/src/components.ts @@ -236,6 +236,12 @@ export { } from "./components/use-list-menu-selection"; export { JsonSchemaForm } from "./components/JsonSchemaForm"; +export { + createUnitFormExtensions, + formatUnitAwareValue, + parseUnitAwareValue, + type UnitInputKind, +} from "./components/unit-form-extension"; export { FormLookupProvider } from "./components/FormLookupProvider"; export { useLookupFetcher, diff --git a/packages/ui/src/components/ErrorWrapper.test.tsx b/packages/ui/src/components/ErrorWrapper.test.tsx index 983174cd..36c5266c 100644 --- a/packages/ui/src/components/ErrorWrapper.test.tsx +++ b/packages/ui/src/components/ErrorWrapper.test.tsx @@ -34,7 +34,7 @@ describe("ErrorWrapper", () => { ).toBeInTheDocument(); expect(fallback).toHaveTextContent("Unable to load the account dashboard"); - fireEvent.click(screen.getByRole("button", { name: "Copy error details" })); + fireEvent.click(screen.getByRole("button", { name: "Copy error report" })); await waitFor(() => expect(writeText).toHaveBeenCalledOnce()); const report = writeText.mock.calls[0]?.[0]; @@ -71,7 +71,7 @@ describe("ErrorWrapper", () => { const fallback = screen.getByRole("alert"); expect(liveRegion(fallback)).toHaveTextContent(""); - fireEvent.click(screen.getByRole("button", { name: "Copy error details" })); + fireEvent.click(screen.getByRole("button", { name: "Copy error report" })); await waitFor(() => expect(liveRegion(fallback)).toHaveTextContent( diff --git a/packages/ui/src/components/ErrorWrapper.tsx b/packages/ui/src/components/ErrorWrapper.tsx index e900d11f..7d1f0e9c 100644 --- a/packages/ui/src/components/ErrorWrapper.tsx +++ b/packages/ui/src/components/ErrorWrapper.tsx @@ -127,7 +127,11 @@ function ErrorFallback({ onClick={() => void copyDetails()} > - {copyState === "copied" ? "Copied" : "Copy error details"} + {/* Distinct from ErrorDetails' own "Copy error details" control + below: this copies the page-level report (URL, user agent, + React component stack), and two identically named buttons in + one view are ambiguous to assistive tech. */} + {copyState === "copied" ? "Copied" : "Copy error report"} { + const canvas = within(canvasElement); + // The header identifies the item; "Item 1" never appears. + await expect(canvas.getByText("/api/v1/users")).toBeInTheDocument(); + await expect(canvas.queryByText("Item 1")).not.toBeInTheDocument(); + + // Every card is editable at once, and the header follows the edit. + // `path` is required, so its label reads "Path*" — match the prefix. + const paths = canvas.getAllByLabelText(/^Path/); + await expect(paths).toHaveLength(2); + await userEvent.clear(paths[1]!); + await userEvent.type(paths[1]!, "/api/v2/events"); + await waitFor(() => expect(canvas.getByText("/api/v2/events")).toBeInTheDocument()); + + await userEvent.click(canvas.getByRole("button", { name: "Add route" })); + await waitFor(() => expect(canvas.getByText("New route")).toBeInTheDocument()); + }, +}; + const nestedObjectSchema: JsonSchemaObject = { type: "object", properties: { diff --git a/packages/ui/src/components/UnitStepControls.tsx b/packages/ui/src/components/UnitStepControls.tsx new file mode 100644 index 00000000..2c0d648e --- /dev/null +++ b/packages/ui/src/components/UnitStepControls.tsx @@ -0,0 +1,55 @@ +import { useLayoutEffect, useRef, useState, type ReactNode } from "react"; +import { UiAdd, UiRemove } from "../icons"; +import { IconButton } from "./IconButton"; + +export function UnitStepControls({ + label, + suffix, + decrease, + increase, + schemaReadOnly, + onChange, +}: { + label: string; + suffix: ReactNode; + decrease: string | null; + increase: string | null; + schemaReadOnly: boolean; + onChange: (next: string) => void; +}) { + const controlsRef = useRef(null); + const [readOnly, setReadOnly] = useState(schemaReadOnly); + useLayoutEffect(() => { + const input = controlsRef.current + ?.closest("[data-jsf-control]") + ?.querySelector("input[data-jsf-input]"); + setReadOnly(schemaReadOnly || input?.disabled === true); + }); + + if (readOnly) { + return {suffix}; + } + return ( + + {suffix} + event.preventDefault()} + {...(decrease !== null ? { onClick: () => onChange(decrease) } : {})} + /> + event.preventDefault()} + {...(increase !== null ? { onClick: () => onChange(increase) } : {})} + /> + + ); +} diff --git a/packages/ui/src/components/json-schema-form-accordion-array.tsx b/packages/ui/src/components/json-schema-form-accordion-array.tsx index 3d36c7cb..96e50086 100644 --- a/packages/ui/src/components/json-schema-form-accordion-array.tsx +++ b/packages/ui/src/components/json-schema-form-accordion-array.tsx @@ -6,17 +6,15 @@ import { type MutableRefObject, } from "react"; import { cn } from "../lib/utils"; -import { Icon, LabelIcon } from "../data/Icon"; -import { - UiAdd, - UiAsterisk, - UiChevronDown, - UiChevronRight, - UiChevronUp, - UiCopy, - UiTrash, -} from "../icons"; +import { Icon } from "../data/Icon"; +import { UiAdd, UiChevronDown, UiChevronRight } from "../icons"; import { appendInstancePath } from "./json-schema-form-errors"; +import { + ItemBadge, + ItemGlyph, + ItemRowActions, + RequiredMark, +} from "./json-schema-form-item-row"; import { addItemLabel, emptyItemsCopy, @@ -25,8 +23,7 @@ import { noItemsLabel, resolveItemSpec, } from "./json-schema-form-item-summary"; -import { controlHeightClass, labelSizeClass, type FormSize } from "./json-schema-form-size"; -import { TONE_GLYPH_CLASS } from "./json-schema-form-tone"; +import { labelSizeClass } from "./json-schema-form-size"; import { duplicateIndex, fieldInputId, @@ -35,11 +32,7 @@ import { seedFromSchema, setIndex, } from "./json-schema-form-utils"; -import type { - ArrayItemSummary, - FieldControl, - RenderContext, -} from "./json-schema-form-types"; +import type { FieldControl, RenderContext } from "./json-schema-form-types"; // AccordionArray renders an object-item array as a list of one-line summary // rows, expanding one at a time into the item's own sub-form. It exists because @@ -301,90 +294,3 @@ function AddItemRow({ ); } - -function ItemGlyph({ glyph }: { glyph?: ArrayItemSummary["glyph"] }) { - if (!glyph) return null; - return ( - - {glyph.icon != null && } - - ); -} - -function ItemBadge({ badge }: { badge?: ArrayItemSummary["badge"] }) { - if (!badge) return null; - return ( - - {badge.icon != null && } - {badge.label} - - ); -} - -function RequiredMark() { - return ( - - - - ); -} - -function ItemRowActions({ - index, - title, - size, - onUp, - onDown, - onDuplicate, - onRemove, -}: { - index: number; - title: string; - size: FormSize; - onUp?: () => void; - onDown?: () => void; - onDuplicate: () => void; - onRemove: () => void; -}) { - // Hidden until the row is hovered or something inside it takes focus, so a - // long list is not a wall of permanently dim icons — but keyboard users see - // them the moment they arrive. - const action = cn( - "inline-flex aspect-square items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-30", - controlHeightClass[size], - ); - return ( -
- - - - -
- ); -} diff --git a/packages/ui/src/components/json-schema-form-array-inline.test.tsx b/packages/ui/src/components/json-schema-form-array-inline.test.tsx new file mode 100644 index 00000000..e270e55f --- /dev/null +++ b/packages/ui/src/components/json-schema-form-array-inline.test.tsx @@ -0,0 +1,90 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { JsonSchemaForm } from "./JsonSchemaForm"; +import type { JsonSchemaObject } from "./json-schema-form-types"; + +// FieldWrapper's inline row is `col-span-2 grid grid-cols-subgrid`, which only +// means anything when its parent is the FieldsGrid that owns the label/value +// tracks. The default ArrayControl used to drop each item into a plain +// `min-w-0` div, so `subgrid` resolved to `none` and every array item collapsed +// into a stacked-looking column while the rest of the form stayed aligned. +const INLINE_TEMPLATE = "fit-content(40ch) minmax(0, 600px)"; + +function inlineForm(schema: JsonSchemaObject, value: Record) { + return render( + {}} + layout={{ mode: "inline" }} + showPreferencesMenu={false} + />, + ); +} + +// The row the renderer produced for one item, and the element it is parented to. +function subgridRowFor(labelText: string): { row: HTMLElement; parent: HTMLElement } { + const row = screen.getByText(labelText).closest(".grid-cols-subgrid"); + if (!row) throw new Error(`no subgrid row around "${labelText}"`); + const parent = row.parentElement; + if (!parent) throw new Error(`subgrid row for "${labelText}" has no parent`); + return { row, parent }; +} + +describe("array items in inline layout", () => { + it("gives a scalar item row the label/value tracks its subgrid inherits", () => { + // Integer items miss the scalar-string TagArray branch, so each item renders + // as its own labelled row — the case where a track-less subgrid is visible. + const schema: JsonSchemaObject = { + type: "object", + properties: { + ports: { type: "array", title: "Ports", items: { type: "integer" } }, + }, + }; + inlineForm(schema, { ports: [8080, 9090] }); + + const { parent } = subgridRowFor("Item 1"); + expect(parent.style.gridTemplateColumns).toBe(INLINE_TEMPLATE); + }); + + it("aligns every item on the same tracks, not per-item grids", () => { + const schema: JsonSchemaObject = { + type: "object", + properties: { + ports: { type: "array", title: "Ports", items: { type: "integer" } }, + }, + }; + inlineForm(schema, { ports: [8080, 9090] }); + + const first = subgridRowFor("Item 1"); + const second = subgridRowFor("Item 2"); + // Each item keeps its own reorder/remove column, so the grids are siblings + // rather than one shared node — but both must define the same tracks, which + // is what makes the labels line up down the list. + expect(first.parent.style.gridTemplateColumns).toBe( + second.parent.style.gridTemplateColumns, + ); + expect(first.parent.style.gridTemplateColumns).toBe(INLINE_TEMPLATE); + }); + + it("keeps an object item's section inside a grid so col-span-full applies", () => { + const schema: JsonSchemaObject = { + type: "object", + properties: { + servers: { + type: "array", + title: "Servers", + items: { + type: "object", + properties: { name: { type: "string", title: "Name" } }, + }, + }, + }, + }; + inlineForm(schema, { servers: [{ name: "api" }] }); + + const section = screen.getByText("Item 1").closest(".col-span-full"); + if (!section) throw new Error("no col-span-full section around the item"); + expect(section.parentElement?.style.gridTemplateColumns).toBe(INLINE_TEMPLATE); + }); +}); diff --git a/packages/ui/src/components/json-schema-form-array.tsx b/packages/ui/src/components/json-schema-form-array.tsx index 3a9fb2d5..64497947 100644 --- a/packages/ui/src/components/json-schema-form-array.tsx +++ b/packages/ui/src/components/json-schema-form-array.tsx @@ -13,6 +13,8 @@ import { type FormSize, } from "./json-schema-form-size"; import { AccordionArray } from "./json-schema-form-accordion-array"; +import { CardsArray } from "./json-schema-form-cards-array"; +import { FieldsGrid } from "./json-schema-form-layout"; import { isScalarStringItems } from "./json-schema-form-resolve"; import { appendInstancePath } from "./json-schema-form-errors"; import { TableArray } from "./json-schema-form-table-array"; @@ -65,6 +67,11 @@ export function ArrayControl({ ) { return ; } + // Same guard as the accordion, and for the same reason: a card is headed by + // the item's own summary, which a list of bare strings cannot supply. + if (field.arrayDisplay === "cards" && hasObjectItemProperties(field.itemSchema)) { + return ; + } if (isScalarStringItems(field.itemSchema)) { return ( {items.map((item, i) => (
-
+ {/* The item's row is a FieldWrapper (or a full-width ObjectSection), + both of which are grid children of a FieldsGrid — inline mode's + `grid-cols-subgrid` resolves to `none` without one, collapsing + every item into a stacked column while the rest of the form stays + aligned. Fixed at one column: an item is a single row, and any + multi-column layout belongs to the item's own object body. */} + {ctx.render.renderFieldRow( { key: `${field.key}[${i}]`, @@ -106,7 +119,7 @@ export function ArrayControl({ childCtx, { labelOverride: `Item ${i + 1}` }, )} -
+ {!readOnly && ( }) { + const [value, setValue] = useState(initial); + return ( + + ); +} + +function cards(): HTMLElement[] { + return [...document.querySelectorAll("article")]; +} + +describe("x-array-display: cards", () => { + it("renders one card per item, titled from x-item rather than Item N", () => { + render(); + expect(cards()).toHaveLength(2); + expect(screen.getByText("namespace")).toBeInTheDocument(); + expect(screen.getByText("limit")).toBeInTheDocument(); + expect(screen.queryByText("Item 1")).not.toBeInTheDocument(); + }); + + it("falls back to the declared fallback title when the title property is empty", () => { + render(); + expect(screen.getByText("Untitled parameter")).toBeInTheDocument(); + }); + + it("carries the item's tone on the card's left edge", () => { + // The hue is what makes a long stack scannable before it is read; it comes + // from x-enum-tones via the x-item glyph property, not from the display. + render(); + const [first, second] = cards(); + expect(first?.className).toContain("border-l-slate-400"); + expect(second?.className).toContain("border-l-violet-400"); + }); + + it("shows the summary line and the required flag from x-item", () => { + render(); + const first = cards()[0]!; + expect(within(first).getByText("metadata.namespace")).toBeInTheDocument(); + expect(within(first).getByTitle("Required")).toBeInTheDocument(); + expect(within(cards()[1]!).queryByTitle("Required")).not.toBeInTheDocument(); + }); + + it("keeps every item's fields open and editable", () => { + render(); + // Both cards are expanded at once — that is the difference from the + // accordion, which opens one row at a time. + expect(screen.getAllByLabelText("Field")).toHaveLength(2); + + fireEvent.change(within(cards()[1]!).getByLabelText("Field"), { + target: { value: "spec.max" }, + }); + expect(within(cards()[1]!).getByLabelText("Field")).toHaveValue("spec.max"); + }); + + it("adds an item using the noun the schema declared", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "Add parameter" })); + expect(cards()).toHaveLength(3); + }); + + it("removes and reorders by item title, not by index", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "Move limit up" })); + expect(cards()[0]).toHaveTextContent("limit"); + + fireEvent.click(screen.getByRole("button", { name: "Remove limit" })); + expect(cards()).toHaveLength(1); + expect(screen.queryByText("limit")).not.toBeInTheDocument(); + }); + + it("offers no mutation controls when the form is read-only", () => { + render( + {}} + readOnly + showPreferencesMenu={false} + />, + ); + expect(screen.queryByRole("button", { name: "Add parameter" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Remove namespace" })).not.toBeInTheDocument(); + }); + + it("ignores the cards display for a plain string array", () => { + // A list of bare strings has no properties to summarize, so it stays on the + // compact tag editor rather than becoming a stack of untitled cards. + const schema: JsonSchemaObject = { + type: "object", + properties: { + tags: { + type: "array", + title: "Tags", + "x-array-display": "cards", + items: { type: "string" }, + }, + }, + }; + render( + {}} showPreferencesMenu={false} />, + ); + expect(cards()).toHaveLength(0); + expect(screen.getByText("a")).toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/components/json-schema-form-cards-array.tsx b/packages/ui/src/components/json-schema-form-cards-array.tsx new file mode 100644 index 00000000..567cf84e --- /dev/null +++ b/packages/ui/src/components/json-schema-form-cards-array.tsx @@ -0,0 +1,134 @@ +import { cn } from "../lib/utils"; +import { Icon } from "../data/Icon"; +import { UiAdd } from "../icons"; +import { Button } from "./button"; +import { appendInstancePath } from "./json-schema-form-errors"; +import { + addItemLabel, + emptyItemsCopy, + itemSummaryFor, + resolveItemSpec, +} from "./json-schema-form-item-summary"; +import { ItemBadge, ItemRowActions, RequiredMark } from "./json-schema-form-item-row"; +import { inputSizeClass } from "./json-schema-form-size"; +import { TONE_EDGE_CLASS } from "./json-schema-form-tone"; +import { + duplicateIndex, + moveItem, + removeIndex, + seedFromSchema, + setIndex, +} from "./json-schema-form-utils"; +import type { FieldControl, RenderContext } from "./json-schema-form-types"; + +// CardsArray renders an object-item array as a stack of titled cards: every +// item stays open, but each one is headed by what it actually is rather than +// "Item 3", and carries the item type's hue on its left edge so a long stack is +// scannable at a glance. +// +// It is the everything-visible counterpart to AccordionArray. Both read the +// same `x-item` summary, so a consumer switches between them by changing +// `x-array-display` alone — no second vocabulary to learn. +export function CardsArray({ + field, + ctx, + readOnly, +}: { + field: FieldControl; + ctx: RenderContext; + readOnly: boolean; +}) { + const items = Array.isArray(field.value) ? field.value : []; + const itemSchema = field.itemSchema ?? { type: "object" }; + const spec = field.itemSpec ?? resolveItemSpec(field.schema, itemSchema); + const emptyCopy = emptyItemsCopy(spec, field.schema); + // The array's help mode governs its whole subtree, exactly as it does for the + // accordion: a card whose fields each carry a permanent two-line paragraph is + // back to the height the card layout exists to reclaim. + const childCtx: RenderContext = { + ...ctx, + readOnly, + depth: ctx.depth + 1, + ...(field.helpDisplay ? { layout: { ...ctx.layout, help: field.helpDisplay } } : {}), + }; + + function commit(next: unknown[]) { + field.onChange(next); + } + + return ( +
+ {items.map((item, i) => { + const summary = + field.itemSummary?.({ item, index: i }) ?? + itemSummaryFor({ item, index: i, spec, itemSchema }); + return ( +
+
+ + {i + 1} + + {summary.title} + + {summary.flagged && } + {summary.summary && ( + + {summary.summary} + + )} + {!readOnly && ( + 0 ? { onUp: () => commit(moveItem(items, i, i - 1)) } : {})} + {...(i < items.length - 1 + ? { onDown: () => commit(moveItem(items, i, i + 1)) } + : {})} + onDuplicate={() => commit(duplicateIndex(items, i))} + onRemove={() => commit(removeIndex(items, i))} + /> + )} +
+
+ {/* Recurse through the shared pipeline so consumer pre/post + extensions still apply to the item and its properties — and so + the item's own x-columns reaches its ObjectControl. */} + {ctx.render.renderFieldNodes( + { + key: `${field.key}[${i}]`, + prop: itemSchema, + required: false, + value: item, + onChange: (next) => commit(setIndex(items, i, next)), + instancePath: appendInstancePath(ctx.instancePath, i), + }, + childCtx, + )?.value ?? null} +
+
+ ); + })} + {items.length === 0 && emptyCopy && ( +

{emptyCopy}

+ )} + {!readOnly && ( + + )} +
+ ); +} diff --git a/packages/ui/src/components/json-schema-form-item-row.tsx b/packages/ui/src/components/json-schema-form-item-row.tsx new file mode 100644 index 00000000..4e3f1f66 --- /dev/null +++ b/packages/ui/src/components/json-schema-form-item-row.tsx @@ -0,0 +1,99 @@ +import { cn } from "../lib/utils"; +import { Icon, LabelIcon } from "../data/Icon"; +import { UiAsterisk, UiChevronDown, UiChevronUp, UiCopy, UiTrash } from "../icons"; +import { controlHeightClass, type FormSize } from "./json-schema-form-size"; +import { TONE_GLYPH_CLASS } from "./json-schema-form-tone"; +import type { ArrayItemSummary } from "./json-schema-form-types"; + +// The parts an array item's identifying row is made of, shared by every +// object-array display. They read only from ArrayItemSummary — the derived, +// render-ready description of one item — so a display never learns what the +// item is, and `x-item` stays the single place a consumer says how to +// summarize one. + +export function ItemGlyph({ glyph }: { glyph?: ArrayItemSummary["glyph"] }) { + if (!glyph) return null; + return ( + + {glyph.icon != null && } + + ); +} + +export function ItemBadge({ badge }: { badge?: ArrayItemSummary["badge"] }) { + if (!badge) return null; + return ( + + {badge.icon != null && } + {badge.label} + + ); +} + +export function RequiredMark() { + return ( + + + + ); +} + +export function ItemRowActions({ + index, + title, + size, + onUp, + onDown, + onDuplicate, + onRemove, +}: { + index: number; + title: string; + size: FormSize; + onUp?: () => void; + onDown?: () => void; + onDuplicate: () => void; + onRemove: () => void; +}) { + // Hidden until the row is hovered or something inside it takes focus, so a + // long list is not a wall of permanently dim icons — but keyboard users see + // them the moment they arrive. Needs `group` on the item container. + const action = cn( + "inline-flex aspect-square items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-30", + controlHeightClass[size], + ); + return ( +
+ + + + +
+ ); +} diff --git a/packages/ui/src/components/json-schema-form-render.tsx b/packages/ui/src/components/json-schema-form-render.tsx index 15e8e734..e6aa0d46 100644 --- a/packages/ui/src/components/json-schema-form-render.tsx +++ b/packages/ui/src/components/json-schema-form-render.tsx @@ -247,11 +247,13 @@ export function renderFieldRow( // instead of cramming it into the inline value column. // An accordion joins this list: crammed into the 600px inline value column it // is unusable, and it needs the ObjectSection header to carry the array's own - // title, required marker and help. + // title, required marker and help. Cards are the same shape of thing — a + // full-width stack of item panels — so they join it too. if ( field.kind === "object" || field.layout === "table" || - field.arrayDisplay === "accordion" + field.arrayDisplay === "accordion" || + field.arrayDisplay === "cards" ) { return ( 0)) { return true; } - return prop["x-layout"] === "table" || prop["x-array-display"] === "accordion"; + return ( + prop["x-layout"] === "table" || + prop["x-array-display"] === "accordion" || + prop["x-array-display"] === "cards" + ); } // renderApi is the RenderContext injection bundle: the root form stores it on diff --git a/packages/ui/src/components/json-schema-form-resolve.ts b/packages/ui/src/components/json-schema-form-resolve.ts index b5d44bc1..6a290645 100644 --- a/packages/ui/src/components/json-schema-form-resolve.ts +++ b/packages/ui/src/components/json-schema-form-resolve.ts @@ -112,7 +112,7 @@ function enumDisplay(prop: JsonSchemaProperty): EnumDisplay | undefined { function arrayDisplay(prop: JsonSchemaProperty): ArrayDisplay | undefined { const d = prop["x-array-display"]; - return d === "filter-pills" || d === "accordion" ? d : undefined; + return d === "filter-pills" || d === "accordion" || d === "cards" ? d : undefined; } // helpDisplay reads the per-field `x-help-display` override. Returns undefined diff --git a/packages/ui/src/components/json-schema-form-types.ts b/packages/ui/src/components/json-schema-form-types.ts index 0b2d336b..479c7c52 100644 --- a/packages/ui/src/components/json-schema-form-types.ts +++ b/packages/ui/src/components/json-schema-form-types.ts @@ -71,8 +71,10 @@ export interface JsonSchemaProperty { // Force the enum presentation: "combobox" (default), "radio", "grid", or // "segmented". "x-enum-display"?: EnumDisplay; - // Force the presentation for an enum-backed array. "filter-pills" renders - // each enum item as a compact toggle; an empty stored array means all options. + // Force an array's presentation. "filter-pills" renders each enum item as a + // compact toggle (an empty stored array means all options); "accordion" and + // "cards" render object items as summary rows or titled cards, both reading + // `x-item` for the summary. "x-array-display"?: ArrayDisplay; // Force how this field's description is presented, overriding the form-level // `FormLayout.help`. Defaults to "inline" (a paragraph under the control). @@ -211,8 +213,11 @@ export type GridColumns = number | "auto"; // "hover" moves it behind a `?` beside the label, costing no vertical space. export type HelpDisplay = "inline" | "hover"; -// How an array control renders when the item schema has enum options. -export type ArrayDisplay = "filter-pills" | "accordion"; +// How an array control renders. "filter-pills" needs enum item options; +// "accordion" and "cards" both need object items and both read `x-item` — the +// accordion collapses every item to one line and opens one at a time, cards +// keep every item open under a titled, hue-edged header. +export type ArrayDisplay = "filter-pills" | "accordion" | "cards"; // ArrayItemSpec is the `x-item` extension on an ARRAY schema: it says how to // summarize one element of this list in a collapsed row. Every value names a diff --git a/packages/ui/src/components/unit-form-extension.test.tsx b/packages/ui/src/components/unit-form-extension.test.tsx new file mode 100644 index 00000000..f1fa7541 --- /dev/null +++ b/packages/ui/src/components/unit-form-extension.test.tsx @@ -0,0 +1,163 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { JsonSchemaForm } from "./JsonSchemaForm"; +import { + createUnitFormExtensions, + formatUnitAwareValue, + parseUnitAwareValue, + type UnitInputKind, +} from "./unit-form-extension"; +import type { JsonSchemaObject } from "./json-schema-form-types"; + +const schema: JsonSchemaObject = { + type: "object", + properties: { + rows: { + type: "string", + title: "Rows", + pattern: "^[1-9][0-9]*$", + "x-clicky-unit": "count", + "x-input-suffix": "rows", + }, + memory: { + type: "string", + title: "Memory", + pattern: "^[1-9][0-9]*$", + "x-clicky-unit": "bytes", + }, + }, +}; + +const value = { rows: "1000000", memory: "268435456" }; +const extensions = createUnitFormExtensions(); + +describe("createUnitFormExtensions", () => { + it("displays canonical count and byte strings using human units", () => { + render( + , + ); + + expect(screen.getByRole("textbox", { name: "Rows" })).toHaveValue("1M"); + expect(screen.getByRole("textbox", { name: "Memory" })).toHaveValue("256MiB"); + }); + + it.each([ + ["Rows", "2.5M", { rows: "2500000", memory: "268435456" }], + ["Memory", "1.5GiB", { rows: "1000000", memory: "1610612736" }], + ])("commits a %s edit as a canonical integer when exactly representable", (name, input, expected) => { + const onChange = vi.fn(); + render( + , + ); + + fireEvent.change(screen.getByRole("textbox", { name }), { target: { value: input } }); + + expect(onChange).toHaveBeenLastCalledWith(expected); + }); + + it.each([ + ["Decrease Rows", { rows: "500000", memory: "268435456" }], + ["Increase Rows", { rows: "2000000", memory: "268435456" }], + ["Decrease Memory", { rows: "1000000", memory: "134217728" }], + ["Increase Memory", { rows: "1000000", memory: "536870912" }], + ])("commits %s as a canonical integer", (name, expected) => { + const onChange = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name })); + + expect(onChange).toHaveBeenLastCalledWith(expected); + }); + + it("preserves an inexact byte edit so schema validation can reject it", () => { + const onChange = vi.fn(); + render( + , + ); + + fireEvent.change(screen.getByRole("textbox", { name: "Memory" }), { + target: { value: "1.2KiB" }, + }); + + expect(onChange).toHaveBeenLastCalledWith({ rows: "1000000", memory: "1.2KiB" }); + }); + + it("humanizes and disables unit fields in read-only forms", () => { + const { rerender } = render( + , + ); + + expect(screen.getByRole("textbox", { name: "Rows" })).toHaveValue("1M"); + expect(screen.getByRole("textbox", { name: "Rows" })).toBeDisabled(); + expect(screen.getByRole("textbox", { name: "Memory" })).toHaveValue("256MiB"); + expect(screen.getByRole("textbox", { name: "Memory" })).toBeDisabled(); + expect(screen.queryByRole("button", { name: "Decrease Rows" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Increase Memory" })).not.toBeInTheDocument(); + + rerender( + , + ); + expect(screen.getByRole("button", { name: "Decrease Rows" })).toBeEnabled(); + expect(screen.getByRole("button", { name: "Increase Memory" })).toBeEnabled(); + }); +}); + +describe("unit values", () => { + it.each<[string, UnitInputKind, string | null]>([ + ["2.5M", "count", "2500000"], + ["1,500K", "count", "1500000"], + ["256MB", "bytes", "268435456"], + ["1.5GiB", "bytes", "1610612736"], + ["1.2KiB", "bytes", null], + ["0", "count", null], + ])("parses %s as a %s value", (input, kind, expected) => { + expect(parseUnitAwareValue(input, kind)).toBe(expected); + }); + + it.each<[string, UnitInputKind, string]>([ + ["1500000", "count", "1.5M"], + ["1536", "bytes", "1.5KiB"], + ["1025", "bytes", "1025B"], + ])("formats canonical %s as a %s value", (input, kind, expected) => { + expect(formatUnitAwareValue(input, kind)).toBe(expected); + }); +}); diff --git a/packages/ui/src/components/unit-form-extension.tsx b/packages/ui/src/components/unit-form-extension.tsx new file mode 100644 index 00000000..747a7f23 --- /dev/null +++ b/packages/ui/src/components/unit-form-extension.tsx @@ -0,0 +1,141 @@ +import { cn } from "../lib/utils"; +import { UnitStepControls } from "./UnitStepControls"; +import type { FieldControl, PreExtension } from "./json-schema-form-types"; + +export type UnitInputKind = "count" | "bytes"; + +type UnitScale = { + label: string; + multiplier: bigint; +}; + +const COUNT_SCALES: UnitScale[] = [ + { label: "", multiplier: 1n }, + { label: "K", multiplier: 1_000n }, + { label: "M", multiplier: 1_000_000n }, + { label: "B", multiplier: 1_000_000_000n }, + { label: "T", multiplier: 1_000_000_000_000n }, +]; + +const BYTE_SCALES: UnitScale[] = [ + { label: "B", multiplier: 1n }, + { label: "KiB", multiplier: 1_024n }, + { label: "MiB", multiplier: 1_048_576n }, + { label: "GiB", multiplier: 1_073_741_824n }, + { label: "TiB", multiplier: 1_099_511_627_776n }, +]; + +const scales = (kind: UnitInputKind) => kind === "bytes" ? BYTE_SCALES : COUNT_SCALES; + +function canonicalInteger(value: unknown): string | null { + if (typeof value === "number") { + return Number.isSafeInteger(value) && value > 0 ? String(value) : null; + } + if (typeof value !== "string" || !/^[1-9][0-9]*$/.test(value)) return null; + return value; +} + +function scaledDecimal(value: bigint, multiplier: bigint): string | null { + const integer = value / multiplier; + let remainder = value % multiplier; + if (remainder === 0n) return String(integer); + + let fraction = ""; + while (remainder !== 0n && fraction.length < 3) { + remainder *= 10n; + fraction += String(remainder / multiplier); + remainder %= multiplier; + } + return remainder === 0n ? `${integer}.${fraction}` : null; +} + +export function formatUnitAwareValue(value: unknown, kind: UnitInputKind): unknown { + const canonical = canonicalInteger(value); + if (canonical === null) return value; + + const integer = BigInt(canonical); + for (const scale of scales(kind).toReversed()) { + if (integer < scale.multiplier) continue; + const amount = scaledDecimal(integer, scale.multiplier); + if (amount !== null) return `${amount}${scale.label}`; + } + return canonical; +} + +function scaleForLabel(kind: UnitInputKind, label: string): UnitScale | undefined { + if (kind === "count") { + return COUNT_SCALES.find((scale) => scale.label.toLowerCase() === label.toLowerCase()); + } + const normalized = label.toLowerCase(); + const aliases: Record = { + "": "B", + k: "KiB", + kb: "KiB", + m: "MiB", + mb: "MiB", + g: "GiB", + gb: "GiB", + t: "TiB", + tb: "TiB", + }; + const canonical = aliases[normalized] ?? label; + return BYTE_SCALES.find((scale) => scale.label.toLowerCase() === canonical.toLowerCase()); +} + +export function parseUnitAwareValue(value: string, kind: UnitInputKind): string | null { + const match = value.trim().replaceAll(",", "").replaceAll("_", "").match( + /^(\d+(?:\.\d+)?|\.\d+)\s*([a-zA-Z]*)$/, + ); + if (!match) return null; + + const amount = match[1]; + const scale = scaleForLabel(kind, match[2] ?? ""); + if (!amount || !scale) return null; + const [whole, fraction = ""] = amount.split("."); + const denominator = 10n ** BigInt(fraction.length); + const numerator = BigInt(`${whole || "0"}${fraction}`) * scale.multiplier; + if (numerator % denominator !== 0n) return null; + + const canonical = numerator / denominator; + return canonical > 0n ? String(canonical) : null; +} + +function unitKind(field: FieldControl): UnitInputKind | null { + const unit = field.schema["x-clicky-unit"]; + return unit === "count" || unit === "bytes" ? unit : null; +} + +function steppedValue(value: unknown, direction: "decrease" | "increase"): string | null { + const canonical = canonicalInteger(value); + if (canonical === null) return null; + const integer = BigInt(canonical); + const next = direction === "increase" ? integer * 2n : integer / 2n; + return next > 0n ? String(next) : null; +} + +export function createUnitFormExtensions(): { pre: PreExtension[] } { + const pre: PreExtension = (field) => { + const kind = unitKind(field); + if (!kind) return field; + return { + ...field, + value: formatUnitAwareValue(field.value, kind), + suffix: ( + + ), + inputClassName: cn(field.inputClassName, field.suffix ? "pr-28" : "pr-20"), + onChange: (next) => { + const text = typeof next === "string" ? next : String(next ?? ""); + field.onChange(parseUnitAwareValue(text, kind) ?? text); + }, + }; + }; + return { pre: [pre] }; +} diff --git a/packages/ui/src/data/DataTable.stories.tsx b/packages/ui/src/data/DataTable.stories.tsx index 033dcbe0..96cb0e64 100644 --- a/packages/ui/src/data/DataTable.stories.tsx +++ b/packages/ui/src/data/DataTable.stories.tsx @@ -18,6 +18,7 @@ import { DataTable, type DataTableColumn, type DataTableMenuAction, + type DataTableProps, } from "./DataTable"; type Row = { @@ -233,7 +234,7 @@ const wideColumns: DataTableColumn[] = [ { key: "notes", label: "Notes", grow: true }, ]; -function DataTableShowcase() { +function DataTableShowcase(args: DataTableProps) { const [timeFrom, setTimeFrom] = useState("now-24h"); const [timeTo, setTimeTo] = useState("now"); const [dateFrom, setDateFrom] = useState(""); @@ -241,10 +242,12 @@ function DataTableShowcase() { return ( , + render: (args) => , args: { data: rows, columns, - autoFilter: false, + loading: false, + loadingMessage: "Loading services…", + loadingRowCount: 8, + emptyMessage: "No services", + autoFilter: true, showGlobalFilter: true, + globalFilterPlaceholder: "Search all columns…", + defaultSort: { key: "restarts", dir: "asc" }, resizableColumns: true, hideableColumns: true, persistColumnWidths: true, persistColumnVisibility: true, + persistDensity: true, + showDensityControl: true, + showThemeControl: false, showHeaderFilters: true, showFullscreenControl: false, + fullscreenTitle: "Services", + fullscreenButtonLabel: "Open table full screen", + }, + argTypes: { + data: { control: false, table: { category: "Data" } }, + columns: { control: false, table: { category: "Data" } }, + loading: { control: "boolean", table: { category: "State" } }, + loadingMessage: { control: "text", table: { category: "State" } }, + loadingRowCount: { + control: { type: "range", min: 1, max: 20, step: 1 }, + table: { category: "State" }, + }, + emptyMessage: { control: "text", table: { category: "State" } }, + autoFilter: { control: "boolean", table: { category: "Filtering" } }, + showGlobalFilter: { + control: "boolean", + table: { category: "Filtering" }, + }, + globalFilterPlaceholder: { + control: "text", + table: { category: "Filtering" }, + }, + showHeaderFilters: { + control: "boolean", + table: { category: "Filtering" }, + }, + resizableColumns: { + control: "boolean", + table: { category: "Columns" }, + }, + persistColumnWidths: { + control: "boolean", + table: { category: "Columns" }, + }, + hideableColumns: { + control: "boolean", + table: { category: "Columns" }, + }, + persistColumnVisibility: { + control: "boolean", + table: { category: "Columns" }, + }, + persistDensity: { + control: "boolean", + table: { category: "Preferences" }, + }, + showDensityControl: { + control: "boolean", + table: { category: "Preferences" }, + }, + showThemeControl: { + control: "boolean", + table: { category: "Preferences" }, + }, + showFullscreenControl: { + control: "boolean", + table: { category: "Fullscreen" }, + }, + fullscreenTitle: { control: "text", table: { category: "Fullscreen" } }, + fullscreenButtonLabel: { + control: "text", + table: { category: "Fullscreen" }, + }, }, parameters: { docs: { @@ -972,6 +1047,19 @@ type Story = StoryObj; export const Default: Story = {}; +export const Playground: Story = { + args: { + showFullscreenControl: true, + fullscreenButtonLabel: "Open controlled table", + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + canvas.getByRole("button", { name: "Open controlled table" }), + ).toBeVisible(); + }, +}; + export const FewColumns: Story = { render: () => , }; diff --git a/packages/ui/src/data/diagnostics/ErrorDetails.test.tsx b/packages/ui/src/data/diagnostics/ErrorDetails.test.tsx new file mode 100644 index 00000000..cd9f0248 --- /dev/null +++ b/packages/ui/src/data/diagnostics/ErrorDetails.test.tsx @@ -0,0 +1,75 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ErrorDetails } from "./ErrorDetails"; +import type { ErrorDiagnostics } from "./error-diagnostics"; + +const diagnostics: ErrorDiagnostics = { + message: 'invalid profile sample request: json: unknown field "_id"', + trace: "trace-42", + time: "2026-08-11T09:30:00Z", + context: [ + ["Query", "SELECT * FROM telemetry.logs"], + ["Language", "sql"], + ], + stacktrace: "sample request failed\n at profileQuery.ts:42:7", +}; + +const clipboardDescriptor = Object.getOwnPropertyDescriptor(navigator, "clipboard"); + +describe("ErrorDetails", () => { + afterEach(() => { + vi.restoreAllMocks(); + if (clipboardDescriptor) { + Object.defineProperty(navigator, "clipboard", clipboardDescriptor); + } else { + Reflect.deleteProperty(navigator, "clipboard"); + } + }); + + it("copies the complete diagnostic report without expanding the details", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + value: { writeText }, + configurable: true, + }); + render(); + + const details = screen.getByText(diagnostics.message).closest("details"); + if (!details) throw new Error("ErrorDetails did not render a details element"); + expect(details).not.toHaveAttribute("open"); + expect(screen.getByText("More details")).toBeVisible(); + + fireEvent.click(screen.getByRole("button", { name: "Copy error details" })); + + await waitFor(() => expect(writeText).toHaveBeenCalledOnce()); + expect(writeText).toHaveBeenCalledWith( + [ + `Error: ${diagnostics.message}`, + `Trace: ${diagnostics.trace}`, + `Time: ${diagnostics.time}`, + "", + "Context:", + "Query: SELECT * FROM telemetry.logs", + "Language: sql", + "", + "Stack trace:", + diagnostics.stacktrace, + ].join("\n"), + ); + expect(details).not.toHaveAttribute("open"); + expect(screen.getByRole("button", { name: "Copied" })).toBeInTheDocument(); + }); + + it("surfaces clipboard failures without expanding the details", async () => { + Object.defineProperty(navigator, "clipboard", { + value: { writeText: vi.fn().mockRejectedValue(new Error("denied")) }, + configurable: true, + }); + render(); + + fireEvent.click(screen.getByRole("button", { name: "Copy error details" })); + + expect(await screen.findByRole("button", { name: "Copy failed" })).toBeInTheDocument(); + expect(screen.getByText(diagnostics.message).closest("details")).not.toHaveAttribute("open"); + }); +}); diff --git a/packages/ui/src/data/diagnostics/ErrorDetails.tsx b/packages/ui/src/data/diagnostics/ErrorDetails.tsx index 1d9a9d9e..2db59374 100644 --- a/packages/ui/src/data/diagnostics/ErrorDetails.tsx +++ b/packages/ui/src/data/diagnostics/ErrorDetails.tsx @@ -1,6 +1,13 @@ -import { type ReactNode } from "react"; +import { useState, type MouseEvent, type ReactNode } from "react"; import { Icon } from "../Icon"; -import { UiDebugStepOver, UiMethod, UiChevronRight, UiCopy, UiWarningTriangle } from "../../icons"; +import { + UiCheck, + UiChevronRight, + UiCopy, + UiDebugStepOver, + UiMethod, + UiWarningTriangle, +} from "../../icons"; import { compactStackPath, isApplicationStackFrame, @@ -19,6 +26,7 @@ export type ErrorDetailsProps = { }; export function ErrorDetails({ diagnostics, renderJsonContext }: ErrorDetailsProps) { + const [copyState, setCopyState] = useState<"idle" | "copied" | "failed">("idle"); const scalarContext = diagnostics.context.filter( ([, value]) => !parseInlineJsonContextValue(value), ); @@ -31,6 +39,20 @@ export function ErrorDetails({ diagnostics, renderJsonContext }: ErrorDetailsPro .filter( (entry): entry is { label: string; value: string; data: unknown } => entry.data !== null, ); + const copyDiagnostics = async (event: MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + if (!navigator.clipboard?.writeText) { + setCopyState("failed"); + return; + } + try { + await navigator.clipboard.writeText(diagnosticReport(diagnostics)); + setCopyState("copied"); + } catch { + setCopyState("failed"); + } + }; return (
@@ -41,10 +63,38 @@ export function ErrorDetails({ diagnostics, renderJsonContext }: ErrorDetailsPro {diagnostics.message}
- +
+ + + More details + Less details + + +
{(diagnostics.trace || diagnostics.time) && ( @@ -116,6 +166,23 @@ export function ErrorDetails({ diagnostics, renderJsonContext }: ErrorDetailsPro ); } +function diagnosticReport(diagnostics: ErrorDiagnostics): string { + const lines = [`Error: ${diagnostics.message}`]; + if (diagnostics.trace) lines.push(`Trace: ${diagnostics.trace}`); + if (diagnostics.time) lines.push(`Time: ${diagnostics.time}`); + if (diagnostics.context.length > 0) { + lines.push( + "", + "Context:", + ...diagnostics.context.map(([label, value]) => `${label}: ${value}`), + ); + } + if (diagnostics.stacktrace) { + lines.push("", "Stack trace:", diagnostics.stacktrace); + } + return lines.join("\n"); +} + export function PrettyStackTrace({ stacktrace }: { stacktrace: string }) { const parsed = parseDiagnosticsStackTrace(stacktrace); if (parsed.frames.length === 0) { diff --git a/packages/ui/src/data/diagnostics/error-diagnostics.ts b/packages/ui/src/data/diagnostics/error-diagnostics.ts index 45f5962e..1b35329a 100644 --- a/packages/ui/src/data/diagnostics/error-diagnostics.ts +++ b/packages/ui/src/data/diagnostics/error-diagnostics.ts @@ -35,9 +35,9 @@ export function normalizeErrorDiagnostics( } const record = objectRecord(value); if (!record) return null; - const nested = objectRecord(record.error) ?? objectRecord(record.diagnostics); - if (nested && nested !== record) { - return normalizeErrorDiagnostics(nested, fallback); + const nestedError = objectRecord(record.error); + if (nestedError && nestedError !== record) { + return normalizeErrorDiagnostics(nestedError, fallback); } const message = firstString(record, ["error", "message", "msg", "reason", "detail", "details"]) ?? fallback; @@ -45,7 +45,13 @@ export function normalizeErrorDiagnostics( const stacktrace = firstString(record, ["stacktrace", "stack_trace", "stackTrace", "stack"]); const time = firstString(record, ["time", "timestamp", "created_at"]); const context = contextEntries(record.context); - if (!message && !trace && !stacktrace && context.length === 0) return null; + if (!message && !trace && !stacktrace && !time && context.length === 0) { + const nestedDiagnostics = objectRecord(record.diagnostics); + if (nestedDiagnostics && nestedDiagnostics !== record) { + return normalizeErrorDiagnostics(nestedDiagnostics, fallback); + } + } + if (!message && !trace && !stacktrace && !time && context.length === 0) return null; return { message: message ?? "Action failed", context, diff --git a/packages/ui/src/data/query-browser/QueryBrowser.paging.test.tsx b/packages/ui/src/data/query-browser/QueryBrowser.paging.test.tsx index a3385f4c..e7e5939d 100644 --- a/packages/ui/src/data/query-browser/QueryBrowser.paging.test.tsx +++ b/packages/ui/src/data/query-browser/QueryBrowser.paging.test.tsx @@ -195,4 +195,36 @@ describe("QueryBrowser paging and provider diagnostics", () => { ), ).toBeVisible(); }); + + it("renders Oops context returned with an execution error", async () => { + const execute = vi.fn().mockRejectedValue( + new QueryBrowserExecutionError("query failed", undefined, { + message: "query failed", + trace: "trace-query-1", + time: "2026-08-11T12:00:00Z", + context: [["connection", "tenant-x"]], + stacktrace: "query failed\n--- at example/query.go:42 runQuery", + }), + ); + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Run" })); + const details = ( + await screen.findByRole("button", { name: "Copy error details" }) + ).closest("details"); + expect(details).not.toBeNull(); + + fireEvent.click(within(details!).getByText("More details")); + expect(within(details!).getByText("trace-query-1")).toBeVisible(); + expect(within(details!).getByText("tenant-x")).toBeVisible(); + expect(within(details!).getByText("SELECT broken")).toBeVisible(); + expect(within(details!).getByText(/example\/query\.go:42/)).toBeVisible(); + }); }); diff --git a/packages/ui/src/data/query-browser/QueryBrowser.stories.tsx b/packages/ui/src/data/query-browser/QueryBrowser.stories.tsx new file mode 100644 index 00000000..783efc7a --- /dev/null +++ b/packages/ui/src/data/query-browser/QueryBrowser.stories.tsx @@ -0,0 +1,230 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, userEvent, within } from "storybook/test"; +import type { JsonSchemaObject } from "../../components/json-schema-form-types"; +import type { DataTableServerColumn } from "../data-table-server-filters"; +import { QueryBrowser } from "./QueryBrowser"; +import { + QueryBrowserExecutionError, + type QueryBrowserRequest, + type QueryBrowserResult, +} from "./QueryBrowser.types"; + +const rows: Record[] = [ + { + observed_at: "2026-08-11T08:14:32Z", + service: "Checkout API", + status: "healthy", + region: "eu-west", + duration_ms: 84, + }, + { + observed_at: "2026-08-11T08:14:21Z", + service: "Ledger Worker", + status: "degraded", + region: "us-east", + duration_ms: 413, + }, + { + observed_at: "2026-08-11T08:13:58Z", + service: "Identity API", + status: "healthy", + region: "eu-west", + duration_ms: 126, + }, + { + observed_at: "2026-08-11T08:13:44Z", + service: "Reporting API", + status: "failed", + region: "ap-south", + duration_ms: 1305, + }, + { + observed_at: "2026-08-11T08:13:12Z", + service: "Checkout API", + status: "healthy", + region: "us-east", + duration_ms: 91, + }, + { + observed_at: "2026-08-11T08:12:47Z", + service: "Ledger Worker", + status: "healthy", + region: "eu-west", + duration_ms: 204, + }, +]; + +const columns: DataTableServerColumn[] = [ + { name: "observed_at", label: "Observed", kind: "timestamp" }, + { + name: "service", + label: "Service", + filterKey: "service", + filter: { + kind: "terms", + options: ["Checkout API", "Ledger Worker", "Identity API", "Reporting API"].map( + (value) => ({ value }), + ), + }, + }, + { + name: "status", + label: "Status", + kind: "status", + filterKey: "status", + filter: { + kind: "terms", + options: ["healthy", "degraded", "failed"].map((value) => ({ value })), + }, + }, + { name: "region", label: "Region" }, + { name: "duration_ms", label: "Duration (ms)" }, +]; + +const optionsSchema: JsonSchemaObject = { + type: "object", + properties: { + database: { + type: "string", + title: "Database", + enum: ["operations", "analytics"], + }, + readOnly: { type: "boolean", title: "Read only" }, + }, +}; + +async function executeSampleQuery( + request: QueryBrowserRequest, +): Promise { + const filtered = rows.filter((row) => + Object.entries(request.filters ?? {}).every(([key, encoded]) => { + const value = String(row[key] ?? ""); + const tokens = encoded.split(",").filter(Boolean); + const included = tokens.filter((token) => !token.startsWith("!")); + const excluded = tokens.filter((token) => token.startsWith("!")).map((token) => token.slice(1)); + return (included.length === 0 || included.includes(value)) && !excluded.includes(value); + }), + ); + const limit = request.pagination?.limit ?? 4; + const offset = request.pagination?.offset ?? 0; + const page = filtered.slice(offset, offset + limit); + + return { + rows: page, + columns, + durationMs: 18, + pagination: { + mode: "offset", + limit, + offset, + hasMore: offset + limit < filtered.length, + total: filtered.length, + totalRelation: "eq", + consistency: "snapshot", + }, + ...(request.debug + ? { + diagnostics: { + provider: "postgresql", + request: { + query: request.query, + options: request.options, + details: { transaction: "read-only", plan: "Index Scan" }, + }, + response: { + durationMs: 18, + returnedRows: page.length, + contentType: "application/json", + preview: JSON.stringify(page), + }, + }, + } + : {}), + }; +} + +const meta = { + title: "Data/QueryBrowser", + component: QueryBrowser, + parameters: { + layout: "fullscreen", + docs: { + description: { + component: + "A provider-neutral query workspace with CodeMirror editing, optional schema-driven options, remembered history, source-described filters, pagination, result details and provider diagnostics. The examples use an in-memory SQL executor, so no backend is required.", + }, + }, + }, + argTypes: { + execute: { table: { disable: true } }, + lookupFilterValues: { table: { disable: true } }, + renderResults: { table: { disable: true } }, + navigator: { table: { disable: true } }, + }, + render: (args) => ( +
+ +
+ ), +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const SqlResults: Story = { + args: { + id: "storybook-query-browser-sql", + title: "Service health", + language: "sql", + queryLabel: "PostgreSQL query", + initialQuery: + "SELECT observed_at, service, status, region, duration_ms\nFROM service_health\nORDER BY observed_at DESC", + optionsSchema, + initialOptions: { database: "operations", readOnly: true }, + completion: { + kind: "sql", + dialect: "postgresql", + defaultSchema: "public", + schemas: [ + { + name: "public", + relations: [ + { + name: "service_health", + columns: columns.map((column) => ({ name: column.name })), + }, + ], + }, + ], + }, + execute: executeSampleQuery, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("button", { name: "Run" })); + await expect(canvas.findByText("Checkout API")).resolves.toBeVisible(); + await expect(canvas.findByText("Page 1 of 2")).resolves.toBeVisible(); + }, +}; + +export const ProviderError: Story = { + args: { + id: "storybook-query-browser-error", + title: "Broken query", + language: "sql", + initialQuery: "SELECT missing_column FROM service_health", + execute: async () => { + throw new QueryBrowserExecutionError("query execution failed", { + provider: "postgresql", + request: { query: "SELECT missing_column FROM service_health" }, + response: { details: { code: "42703" } }, + error: "column missing_column does not exist", + }); + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("button", { name: "Run" })); + await expect(canvas.findByText("query execution failed")).resolves.toBeVisible(); + }, +}; diff --git a/packages/ui/src/data/query-browser/QueryBrowser.tsx b/packages/ui/src/data/query-browser/QueryBrowser.tsx index c1032b73..78d48755 100644 --- a/packages/ui/src/data/query-browser/QueryBrowser.tsx +++ b/packages/ui/src/data/query-browser/QueryBrowser.tsx @@ -17,6 +17,7 @@ import { type DataTableServerColumn, } from "../data-table-server-filters"; import { ErrorDetails } from "../diagnostics/ErrorDetails"; +import type { ErrorDiagnostics } from "../diagnostics/error-diagnostics"; import { queryBrowserEditorExtensions, queryBrowserLanguageExtension, @@ -64,6 +65,7 @@ export function QueryBrowser({ message: string; query: string; diagnostics?: QueryBrowserDiagnostics; + errorDetails?: ErrorDiagnostics; } | null>(null); const [pending, setPending] = useState(false); const [debug, setDebug] = useState(false); @@ -109,6 +111,9 @@ export function QueryBrowser({ ...(err instanceof QueryBrowserExecutionError && err.diagnostics ? { diagnostics: err.diagnostics } : {}), + ...(err instanceof QueryBrowserExecutionError && err.errorDetails + ? { errorDetails: err.errorDetails } + : {}), }); } finally { setPending(false); @@ -414,8 +419,12 @@ export function QueryBrowser({
; - completion?: QueryBrowserCompletion; - onQueryChange?: (query: string) => void; - onOptionsChange?: (options: Record) => void; - navigator?: ReactNode; + title?: string | undefined; + language?: QueryBrowserLanguage | undefined; + initialQuery?: string | undefined; + queryLabel?: string | undefined; + optionsSchema?: JsonSchemaObject | undefined; + initialOptions?: Record | undefined; + completion?: QueryBrowserCompletion | undefined; + onQueryChange?: ((query: string) => void) | undefined; + onOptionsChange?: ((options: Record) => void) | undefined; + navigator?: ReactNode | undefined; execute: (request: QueryBrowserRequest) => Promise; - lookupFilterValues?: QueryBrowserFilterLookup; - renderResults?: (context: QueryBrowserResultContext) => ReactNode; - className?: string; + lookupFilterValues?: QueryBrowserFilterLookup | undefined; + renderResults?: ((context: QueryBrowserResultContext) => ReactNode) | undefined; + className?: string | undefined; }; diff --git a/packages/ui/src/lib/string.test.ts b/packages/ui/src/lib/string.test.ts index 855958e6..01524b68 100644 --- a/packages/ui/src/lib/string.test.ts +++ b/packages/ui/src/lib/string.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { stripLeadingSlashes, stripTrailingSlashes } from "./string"; +import { + stripLeadingSlashes, + stripSurroundingDashes, + stripTrailingSlashes, +} from "./string"; describe("slash stripping", () => { it("strips long leading and trailing slash runs", () => { @@ -10,3 +14,12 @@ describe("slash stripping", () => { expect(stripTrailingSlashes(slashes)).toBe(""); }); }); + +describe("dash stripping", () => { + it("strips long leading and trailing dash runs", () => { + const dashes = "-".repeat(50_000); + expect(stripSurroundingDashes(`${dashes}slug${dashes}`)).toBe("slug"); + expect(stripSurroundingDashes(dashes)).toBe(""); + expect(stripSurroundingDashes("a-b")).toBe("a-b"); + }); +}); diff --git a/packages/ui/src/lib/string.ts b/packages/ui/src/lib/string.ts index bbd32864..80c87b8d 100644 --- a/packages/ui/src/lib/string.ts +++ b/packages/ui/src/lib/string.ts @@ -9,3 +9,13 @@ export function stripTrailingSlashes(value: string): string { while (end > 0 && value[end - 1] === "/") end--; return value.slice(0, end); } + +// Linear scan instead of `.replace(/^-+|-+$/g, "")`: the anchored `-+` +// alternatives backtrack polynomially on slugs made of many dashes. +export function stripSurroundingDashes(value: string): string { + let start = 0; + let end = value.length; + while (start < end && value[start] === "-") start++; + while (end > start && value[end - 1] === "-") end--; + return value.slice(start, end); +} diff --git a/packages/ui/src/profiles.ts b/packages/ui/src/profiles.ts new file mode 100644 index 00000000..2753806a --- /dev/null +++ b/packages/ui/src/profiles.ts @@ -0,0 +1,73 @@ +/** + * Profile authoring: the editor, the wizard, and the query builder behind them. + * + * These components author a commons-db `query.Profile` — the shape shared by + * trace profiles, view specs and ad-hoc reports — so every app that stores + * profiles edits them through one UI instead of growing its own. + * + * Call configureProfiles({ schema, basePath }) once at startup: the schema is + * generated from commons-db's Go types and served by the host, and basePath is + * where that host mounts the profile service (default `/api/v1`). + */ +// profileEditorRaw is deliberately absent. ProfileEditor reaches it through +// React.lazy so Monaco stays out of the initial chunk; re-exporting it here +// would make every importer of this entry load Monaco eagerly, and Monaco is +// an optional peer dependency a consumer may not have installed at all. +// testSchema is a test fixture, not API. + +export * from "./profiles/catalogTree"; +export * from "./profiles/connectionBrowserModel"; +export * from "./profiles/connectionQueryWorkspace"; +export * from "./profiles/connectionQueryWorkspaceModel"; +export * from "./profiles/esFieldValues"; +export * from "./profiles/esParamMappingModel"; +export * from "./profiles/esParamMappingPill"; +export * from "./profiles/esParamOperandExtension"; +export * from "./profiles/esQueryBuilder"; +export * from "./profiles/esQueryBuilderExtension"; +export * from "./profiles/esQueryBuilderForm"; +export * from "./profiles/esQueryBuilderModel"; +export * from "./profiles/esQueryClauseGroup"; +export * from "./profiles/esQueryCompile"; +export * from "./profiles/esQueryConditionRow"; +export * from "./profiles/esQueryGroupModel"; +export * from "./profiles/esQueryOccur"; +export * from "./profiles/esQueryOperandEditors"; +export * from "./profiles/esQueryOperandModel"; +export * from "./profiles/esQueryOperators"; +export * from "./profiles/esQueryOutputEditor"; +export * from "./profiles/esQueryOutputModel"; +export * from "./profiles/esQueryPreview"; +export * from "./profiles/esQuerySortEditor"; +export * from "./profiles/esQuerySortModel"; +export * from "./profiles/esValueCombobox"; +export * from "./profiles/jsonPathSample"; +export * from "./profiles/jsonPathSampleRow"; +export * from "./profiles/profileApi"; +export * from "./profiles/profileBuilder"; +export * from "./profiles/profileBuilderExtension"; +export * from "./profiles/profileBuilderWorkspace"; +export * from "./profiles/profileColumnModel"; +export * from "./profiles/profileColumnPicker"; +export * from "./profiles/profileEditor"; +export * from "./profiles/profileEditorModel"; +export * from "./profiles/profileEditorPreview"; +export * from "./profiles/profileEditorRail"; +export * from "./profiles/profileEditorRoutes"; +export * from "./profiles/profileEditorSections"; +export * from "./profiles/profileFieldEditor"; +export * from "./profiles/profileFieldGrid"; +export * from "./profiles/profileFieldManager"; +export * from "./profiles/profileFieldState"; +export * from "./profiles/profileFieldTypes"; +export * from "./profiles/profileParamModel"; +export * from "./profiles/profileWizard"; +export * from "./profiles/profileWizardHelp"; +export * from "./profiles/profileWizardModel"; +export * from "./profiles/profileWizardQueryStep"; +export * from "./profiles/profileWizardSteps"; +export * from "./profiles/profileYaml"; +export * from "./profiles/prometheusResults"; +export * from "./profiles/queryRowLimits"; +export * from "./profiles/queryRowLimitsModel"; +export * from "./profiles/queryTargetPicker"; diff --git a/packages/ui/src/profiles/.widen.py b/packages/ui/src/profiles/.widen.py new file mode 100644 index 00000000..ea0a2211 --- /dev/null +++ b/packages/ui/src/profiles/.widen.py @@ -0,0 +1,62 @@ +"""Widen optional properties to `?: T | undefined` in named type declarations. + +clicky-ui builds with exactOptionalPropertyTypes, where an optional property may +be absent but not present-and-undefined. For the draft models and component +props here that distinction is noise -- a React caller passing a possibly-absent +value is ordinary -- and the package's own components already declare +`prop?: T | undefined` (see data/CodeBlock.tsx). This aligns these declarations +with that convention. + +It deliberately does NOT touch the places where absent-vs-undefined is real: +those are the objects serialized to the server, and they were fixed to `delete` +the key instead. +""" + +import pathlib +import re +import sys + +TARGETS = { + "esQueryBuilderModel.ts": ["EsSearch", "EsSortBy", "EsCondition"], + "profileEditorModel.ts": ["ProfileSectionStatus"], + "profileWizardModel.ts": ["ProfileWizardDraft", "ProfileRowLimits", "ParamDraft"], + "esQueryPreview.tsx": ["EsCompilation"], + "connectionQueryWorkspace.tsx": ["ConnectionQueryWorkspaceProps"], +} + +PROP = re.compile(r"^(\s+)([A-Za-z_$][\w$]*)\?: ([^;]+);$") + + +def widen_block(lines, start): + """Widen `x?: T;` lines of the type literal opening at `start` until its `};`.""" + depth = 0 + changed = 0 + for index in range(start, len(lines)): + depth += lines[index].count("{") - lines[index].count("}") + match = PROP.match(lines[index]) + if match and "| undefined" not in match.group(3): + indent, name, type_text = match.groups() + lines[index] = f"{indent}{name}?: {type_text} | undefined;" + changed += 1 + if depth <= 0 and index > start: + return index, changed + return len(lines) - 1, changed + + +total = 0 +for filename, type_names in TARGETS.items(): + path = pathlib.Path(filename) + if not path.exists(): + sys.exit(f"missing {filename}") + lines = path.read_text().split("\n") + for type_name in type_names: + for index, line in enumerate(lines): + if re.match(rf"^export type {type_name} = .*\{{\s*$", line): + _, changed = widen_block(lines, index) + total += changed + print(f"{filename}:{type_name}: widened {changed}") + break + else: + print(f"{filename}:{type_name}: NOT FOUND") + path.write_text("\n".join(lines)) +print(f"total {total}") diff --git a/packages/ui/src/profiles/ProfileEditor.stories.tsx b/packages/ui/src/profiles/ProfileEditor.stories.tsx new file mode 100644 index 00000000..05ff71db --- /dev/null +++ b/packages/ui/src/profiles/ProfileEditor.stories.tsx @@ -0,0 +1,145 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { useMemo, type ComponentProps } from "react"; +import { expect, userEvent, within } from "storybook/test"; +import type { ResolvedOperation } from "../rpc/types"; +import type { OperationsApiClient } from "../rpc/useOperations"; +import { ProfileEditor } from "./profileEditor"; +import { configureProfiles } from "./profileApi"; +import { testProfileSchema } from "./testSchema"; + +configureProfiles({ schema: testProfileSchema }); + +const client: OperationsApiClient = { + async getOpenAPISpec() { + return { + openapi: "3.0.0", + info: { title: "Profile examples", version: "1.0.0" }, + paths: {}, + }; + }, + async executeCommand() { + return { success: true, exit_code: 0 }; + }, + async submitForm() { + return { success: true, exit_code: 0, message: "Profile saved" }; + }, +}; + +const action: ResolvedOperation = { + path: "/api/v1/profiles/{id}", + method: "put", + operation: { + operationId: "profile_update", + summary: "Update profile", + responses: { "200": { description: "Updated" } }, + }, +}; + +const initialValue = { + profile: "service-health", + namespace: "observability", + render: "table", + provider: { type: "sql", options: {} }, + query: + "SELECT observed_at, service, status, duration_ms FROM service_health ORDER BY observed_at DESC", + params: [ + { + name: "service", + label: "Service", + type: "string", + role: "filter", + }, + ], + columns: [ + { + name: "observed_at", + label: "Observed", + type: "datetime", + kind: "timestamp", + }, + { + name: "service", + label: "Service", + type: "string", + filter: { kind: "terms", lookup: true }, + }, + { + name: "status", + label: "Status", + type: "string", + kind: "status", + }, + ], +}; + +function ProfileEditorStory(props: ComponentProps) { + const queryClient = useMemo( + () => + new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }), + [], + ); + return ( + +
+ +
+
+ ); +} + +const meta = { + title: "Profiles/ProfileEditor", + component: ProfileEditor, + parameters: { + layout: "fullscreen", + docs: { + description: { + component: + "The route-sized editor for a commons-db query profile. Its section rail, field grid, inspector and preview use the shared Workspace layout. Hosts inject the generated profile schema with `configureProfiles` and provide an `OperationsApiClient` for save and lookup operations; this example supplies both in memory.", + }, + }, + }, + args: { + client, + action, + surfaceKey: "profile-service-health", + initialValue, + onClose: () => undefined, + onSuccess: () => undefined, + }, + argTypes: { + client: { table: { disable: true } }, + action: { table: { disable: true } }, + initialValue: { table: { disable: true } }, + onClose: { table: { disable: true } }, + onSuccess: { table: { disable: true } }, + }, + render: (args) => , +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const General: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("Profile identity")).toBeVisible(); + await expect(canvas.getByDisplayValue("service-health")).toBeVisible(); + }, +}; + +export const Columns: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click( + canvas.getByRole("button", { name: /^Columns Fields, labels, expressions/ }), + ); + await expect(canvas.findByText("3 of 3 included")).resolves.toBeVisible(); + await expect( + canvas.findByRole("textbox", { name: "Label for observed_at" }), + ).resolves.toBeVisible(); + }, +}; diff --git a/packages/ui/src/profiles/catalogTree.tsx b/packages/ui/src/profiles/catalogTree.tsx new file mode 100644 index 00000000..8fdb8448 --- /dev/null +++ b/packages/ui/src/profiles/catalogTree.tsx @@ -0,0 +1,153 @@ +import { Icon } from "../data/Icon"; +import { TreeNode } from "../data/TreeNode"; +import { + UiActivity, + UiDatabase, + UiLink, + UiNamespace, + UiSqlColumn, + UiSqlDatabase, + UiSqlIndex, + UiSqlView, + UiTable, +} from "../icons"; +import type { CatalogNode } from "./connectionBrowserModel"; + +export function CatalogTree({ + nodes, + loading, + error, + databases, + database, + onDatabaseChange, + onSelect, +}: { + nodes: CatalogNode[]; + loading: boolean; + error: unknown; + databases: string[]; + database: string; + onDatabaseChange: (database: string) => void; + onSelect: (node: CatalogNode) => void; +}) { + return ( +
+

+ + Catalog +

+ {databases.length > 0 ? ( + + ) : null} + {loading && ( +
+ Loading catalog… +
+ )} + {error ? ( +
+

Unable to load catalog

+

{catalogErrorMessage(error)}

+
+ ) : null} + {!loading && !error && nodes.length === 0 ? ( +
+ No catalog objects found. +
+ ) : null} + +
+ ); +} + +function catalogErrorMessage(error: unknown): string { + if (error instanceof Error && error.message.trim()) { + return error.message.trim(); + } + if (typeof error === "string" && error.trim()) { + return error.trim(); + } + return "The catalog request failed. Check the connection settings and try again."; +} + +function CatalogNodes({ + nodes, + onSelect, +}: { + nodes: CatalogNode[]; + onSelect: (node: CatalogNode) => void; +}) { + return ( +
+ {nodes.map((node) => ( + item.id} + getChildren={(item) => item.children} + defaultOpen={(item) => item.kind === "schema"} + isSecondary={(item) => item.kind === "column"} + onSelect={(item) => { + if (item.query) onSelect(item); + }} + indentPx={14} + basePaddingPx={8} + renderRow={({ node: item }) => ( +
+ + {item.label} +
+ )} + /> + ))} +
+ ); +} + +function catalogIcon(kind: string) { + switch (kind) { + case "schema": + return UiNamespace; + case "table": + return UiTable; + case "view": + return UiSqlView; + case "column": + return UiSqlColumn; + case "index": + return UiSqlIndex; + case "alias": + return UiLink; + case "data_stream": + return UiActivity; + default: + return UiDatabase; + } +} diff --git a/packages/ui/src/profiles/connectionBrowserModel.test.ts b/packages/ui/src/profiles/connectionBrowserModel.test.ts new file mode 100644 index 00000000..674f8b6c --- /dev/null +++ b/packages/ui/src/profiles/connectionBrowserModel.test.ts @@ -0,0 +1,107 @@ +import { QueryBrowserExecutionError } from "../data/query-browser/QueryBrowser.types"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { fetchJSON, mergeProviderOptions } from "./connectionBrowserModel"; + +afterEach(() => vi.unstubAllGlobals()); + +it("preserves provider diagnostics from a failed JSON request", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + error: "query failed", + trace: "trace-query-1", + time: "2026-08-11T12:00:00Z", + context: { connection: "tenant-x" }, + stacktrace: "query failed\n--- at example/query.go:42 runQuery", + diagnostics: { + provider: "clickhouse", + request: { query: "SELECT broken" }, + error: "unknown identifier broken", + }, + }), + { status: 422, headers: { "Content-Type": "application/json" } }, + ), + ), + ); + + try { + await fetchJSON("/query"); + throw new Error("expected fetchJSON to reject"); + } catch (error) { + expect(error).toBeInstanceOf(QueryBrowserExecutionError); + expect((error as QueryBrowserExecutionError).message).toBe("query failed"); + expect((error as QueryBrowserExecutionError).diagnostics?.request.query).toBe( + "SELECT broken", + ); + expect((error as QueryBrowserExecutionError).errorDetails).toEqual({ + message: "query failed", + trace: "trace-query-1", + time: "2026-08-11T12:00:00Z", + context: [["connection", "tenant-x"]], + stacktrace: "query failed\n--- at example/query.go:42 runQuery", + raw: { + error: "query failed", + trace: "trace-query-1", + time: "2026-08-11T12:00:00Z", + context: { connection: "tenant-x" }, + stacktrace: "query failed\n--- at example/query.go:42 runQuery", + diagnostics: { + provider: "clickhouse", + request: { query: "SELECT broken" }, + error: "unknown identifier broken", + }, + }, + }); + } +}); + +describe("provider option layering", () => { + const stored = { index: "logs-2024", limit: "100" }; + const catalog = { index: "logs-2025" }; + const live = { limit: "500", targetKind: "data_stream" }; + + it("lets each later layer override the one before it", () => { + expect( + mergeProviderOptions({ layers: [stored, catalog, live] }), + ).toEqual({ index: "logs-2025", limit: "500" }); + }); + + it("skips layers a host has not supplied", () => { + expect(mergeProviderOptions({ layers: [undefined, stored] })).toEqual( + stored, + ); + }); + + // targetKind only tells the inspection endpoint which mappings to fetch. It + // is not a provider option, so it must not reach the stored profile. + it("drops targetKind from the options a query runs with", () => { + expect(mergeProviderOptions({ layers: [live] })).toEqual({ limit: "500" }); + expect( + mergeProviderOptions({ layers: [live], keepTargetKind: true }), + ).toEqual(live); + }); + + it("pins the active database over whatever the layers carried", () => { + expect( + mergeProviderOptions({ + layers: [{ database: "stale" }], + database: "analytics", + }), + ).toEqual({ database: "analytics" }); + }); + + // An empty database means the connection's own default, which the backend + // resolves — sending "" would ask for a database literally named "". + it("leaves the database alone when none is active", () => { + expect( + mergeProviderOptions({ layers: [{ database: "app" }], database: "" }), + ).toEqual({ database: "app" }); + }); + + it("does not mutate the layers it merges", () => { + mergeProviderOptions({ layers: [live], database: "analytics" }); + expect(live).toEqual({ limit: "500", targetKind: "data_stream" }); + }); +}); diff --git a/packages/ui/src/profiles/connectionBrowserModel.ts b/packages/ui/src/profiles/connectionBrowserModel.ts new file mode 100644 index 00000000..3d3421f8 --- /dev/null +++ b/packages/ui/src/profiles/connectionBrowserModel.ts @@ -0,0 +1,393 @@ +/** + * The data layer behind every query browser: the descriptor and inspection + * shapes the server serves, and the hook that turns a selection (database, + * index) into the catalog and completion a browser renders. All three hosts — + * the connection browser, the profile wizard and the profile builder — drive + * the same plumbing from here so the surfaces cannot drift apart. + */ + +import type { ComboboxOption } from "../components/Combobox"; +import type { JsonSchemaObject } from "../components/json-schema-form-types"; +import { normalizeErrorDiagnostics } from "../data/diagnostics/error-diagnostics"; +import type { QueryBrowserCompletion } from "../data/query-browser/QueryBrowser.completion"; +import type { QueryBrowserDiagnostics } from "../data/query-browser/QueryBrowser.types"; +import { profileApiPath } from "./profileApi"; +import { QueryBrowserExecutionError } from "../data/query-browser/QueryBrowser.types"; +import { useQuery } from "@tanstack/react-query"; +import { useMemo, type ReactNode } from "react"; + +export type BrowserDescriptor = { + kind: "query" | "cache"; + provider?: string; + language?: "sql" | "json" | "text"; + queryLabel?: string; + defaultQuery?: string; + resultView?: "table" | "logs" | "timeseries"; + optionsSchema?: JsonSchemaObject; + initialOptions?: Record; + catalog?: boolean; + /** + * What a query runs against when the source picks one flat target — the + * `index` option. Set, the browser offers a target combobox instead of a + * catalog tree. + */ + targetLabel?: string; + /** + * The row caps that apply when a profile declares none of its own: the page a + * caller gets by default, the largest page it may ask for, and where an + * all-row export stops. The query's own `limit` option is none of them. + */ + rowLimits?: BrowserRowLimits; +}; + +/** The defaults the server serves, shown as what an unset cap inherits. */ +export type BrowserRowLimits = { + pageSize: number; + maxPageSize: number; + maxExportRows: number; +}; + +/** The caps a profile sets for itself; each unset one takes its default. */ +export type ProfileRowLimits = { + pageSize?: number; + maxPageSize?: number; + maxExportRows?: number; +}; + +export type TargetKind = "index" | "alias" | "data_stream" | "pattern"; + +export type InspectionTarget = { + name: string; + kind: TargetKind; + /** The rotation wildcard this concrete index rolls up into. */ + pattern?: string; + /** How many rotations a `pattern` target covers. */ + count?: number; +}; + +export type CatalogNode = { + id: string; + label: string; + kind: string; + query?: string; + options?: Record; + children?: CatalogNode[]; +}; + +export type InspectionField = { + name: string; + dataType?: string; + types?: string[]; + searchable?: boolean; + aggregatable?: boolean; + conflicting?: boolean; +}; + +export type BrowserInspection = { + kind: "sql" | "opensearch"; + dialect?: "postgresql" | "mysql" | "mssql" | "standard"; + database?: string; + databases?: string[]; + defaultSchema?: string; + schemas?: { + name: string; + relations: { + name: string; + type?: "table" | "view"; + columns: InspectionField[]; + }[]; + }[]; + targets?: InspectionTarget[]; + nodes?: CatalogNode[]; + selected?: { + target: InspectionTarget; + fields: InspectionField[]; + }; + truncated?: boolean; + truncateReason?: string; +}; + +export type ConnectionProfileActionRenderer = (context: { + connectionName: string; + providerType: string; + providerOptions?: Record; +}) => ReactNode; + +/** + * savedConnectionID reads the id out of a `connection://` reference. An + * inline URL has no id, and so no catalog to browse — hence null rather than a + * guess. + */ +export function savedConnectionID(value: string | undefined): string | null { + const prefix = "connection://"; + if (!value?.startsWith(prefix)) return null; + return value.slice(prefix.length).trim() || null; +} + +export function browserBaseUrl(connectionID: string): string { + return profileApiPath(`connection/${encodeURIComponent(connectionID)}/browser`); +} + +export async function fetchJSON(url: string, init?: RequestInit): Promise { + const response = await fetch(url, init); + if (!response.ok) { + const body = await response.text(); + const fallback = body.trim() || `request failed: ${response.status}`; + try { + const parsed = JSON.parse(body) as { + error?: unknown; + diagnostics?: QueryBrowserDiagnostics; + }; + if (typeof parsed.error === "string") { + const errorDetails = normalizeErrorDiagnostics(parsed, parsed.error); + throw new QueryBrowserExecutionError( + errorDetails?.message ?? parsed.error, + parsed.diagnostics, + errorDetails ?? undefined, + ); + } + } catch (error) { + if (error instanceof QueryBrowserExecutionError) throw error; + } + throw new Error(fallback); + } + return response.json() as Promise; +} + +/** + * Rotations lead: a cluster with fifty-three daily jaeger indexes has one + * target an author actually means, and it is `jaeger-span-*`. The concrete + * indexes stay listed last so a single day is still reachable. + */ +const targetGroups: { kind: TargetKind; label: string }[] = [ + { kind: "pattern", label: "Index patterns" }, + { kind: "alias", label: "Aliases" }, + { kind: "data_stream", label: "Data streams" }, + { kind: "index", label: "Indexes" }, +]; + +export function openSearchIndexOptions( + inspection?: BrowserInspection, +): ComboboxOption[] { + if (inspection?.kind !== "opensearch") return []; + const targets = inspection.targets ?? []; + return targetGroups.flatMap(({ kind, label }) => + targets + .filter((target) => target.kind === kind) + .map((target) => ({ + value: target.name, + label: target.count ? `${target.name} · ${target.count} indexes` : target.name, + selectedLabel: target.name, + group: label, + title: target.count + ? `${target.name} · ${target.count} rotated indexes` + : `${target.name} · ${target.kind.replace("_", " ")}`, + })), + ); +} + +/** + * openSearchTargetKind resolves how to inspect a picked target. An undiscovered + * name containing a wildcard is a pattern by construction — the server inspects + * it without requiring it to have been enumerated. + */ +export function openSearchTargetKind( + inspection: BrowserInspection | undefined, + name: string, +): string { + const discovered = (inspection?.targets ?? []).find( + (target) => target.name === name, + ); + if (discovered) return discovered.kind; + return name.includes("*") ? "pattern" : ""; +} + +/** + * withTarget applies a picked target over a host's options, clearing both keys + * when the picker is emptied so a stale index cannot survive the selection. + */ +export function withTarget( + options: Record, + target: { index: string; targetKind: string } | undefined, +): Record { + if (!target) return options; + const next = { ...options }; + if (target.index) { + next.index = target.index; + next.targetKind = target.targetKind; + } else { + delete next.index; + delete next.targetKind; + } + return next; +} + +/** + * queryBrowserOptionsSchema is what the inline options form edits — the leftover + * options, once the navigator has claimed the ones that belong with the query. + * The target has its own combobox, and where the source has a structured search + * the builder owns both the search and the limit it returns, so none of the + * three is rendered a second time as a generic field. + */ +export function queryBrowserOptionsSchema( + descriptor: BrowserDescriptor, +): JsonSchemaObject | undefined { + if (!descriptor.optionsSchema) return undefined; + const properties = { ...descriptor.optionsSchema.properties }; + if (properties.search) { + delete properties.search; + delete properties.limit; + } + if (descriptor.targetLabel) delete properties.index; + return { ...descriptor.optionsSchema, properties }; +} + +export function completionForInspection( + inspection?: BrowserInspection, + selectedInspection?: BrowserInspection, +): QueryBrowserCompletion | undefined { + if (inspection?.kind === "sql" && inspection.dialect) { + return { + kind: "sql", + dialect: inspection.dialect, + ...(inspection.defaultSchema + ? { defaultSchema: inspection.defaultSchema } + : {}), + schemas: (inspection.schemas ?? []).map((schema) => ({ + name: schema.name, + relations: schema.relations.map((relation) => ({ + name: relation.name, + ...(relation.type ? { type: relation.type } : {}), + columns: relation.columns.map((column) => ({ + name: column.name, + types: column.dataType ? [column.dataType] : [], + })), + })), + })), + }; + } + if ( + selectedInspection?.kind === "opensearch" && + selectedInspection.selected + ) { + return { + kind: "json-fields", + vocabulary: "opensearch", + fields: selectedInspection.selected.fields, + }; + } + return undefined; +} + +/** + * mergeProviderOptions layers the option sources a browser draws on, in + * increasing precedence, and pins the active database when there is one. + * `targetKind` only tells the inspection endpoint which field mappings to + * fetch, so it is dropped unless the caller is feeding the browser itself. + */ +export function mergeProviderOptions(input: { + layers: Array | undefined>; + database?: string; + keepTargetKind?: boolean; +}): Record { + const merged: Record = {}; + for (const layer of input.layers) Object.assign(merged, layer ?? {}); + if (input.database) merged.database = input.database; + if (!input.keepTargetKind) delete merged.targetKind; + return merged; +} + +export type InspectionScope = { + /** Query-cache namespace, so each host keeps its own inspection cache. */ + cacheKey: string; + id: string; + baseUrl: string; + enabled: boolean; + /** The database the author picked; empty means the connection's default. */ + database: string; + /** A database carried by the stored provider options, tried before the default. */ + fallbackDatabase?: string; + /** The selected index, alias or data stream. */ + target: string; + /** An explicit target kind; resolved from the catalog when absent. */ + targetKind?: string; +}; + +export type Inspection = { + data?: BrowserInspection | undefined; + nodes: CatalogNode[]; + databases: string[]; + activeDatabase: string; + /** The database to send with a query — empty unless the source is SQL. */ + sqlDatabase: string; + targetKind: string; + loading: boolean; + error: unknown; + completion?: QueryBrowserCompletion | undefined; +}; + +/** + * useInspection resolves the catalog for a browser: the base inspection, the + * per-database one a SQL author switched to, and the per-target field mappings + * an OpenSearch author needs for completion. + */ +export function useInspection(scope: InspectionScope): Inspection { + const { cacheKey, id, baseUrl } = scope; + const base = useQuery({ + queryKey: [cacheKey, id], + queryFn: () => fetchJSON(`${baseUrl}/inspect`), + enabled: scope.enabled, + retry: 0, + staleTime: 5 * 60_000, + }); + const switchedDatabase = + scope.database !== "" && scope.database !== base.data?.database; + const database = useQuery({ + queryKey: [cacheKey, id, scope.database], + queryFn: () => { + const params = new URLSearchParams({ database: scope.database }); + return fetchJSON(`${baseUrl}/inspect?${params}`); + }, + enabled: base.data?.kind === "sql" && switchedDatabase, + retry: 0, + staleTime: 5 * 60_000, + }); + const active = switchedDatabase ? database : base; + const data = active.data ?? base.data; + + const targetKind = + scope.targetKind ?? + data?.targets?.find((target) => target.name === scope.target)?.kind ?? + ""; + const target = useQuery({ + queryKey: [cacheKey, id, targetKind, scope.target], + queryFn: () => { + const params = new URLSearchParams({ + target: scope.target, + targetKind, + }); + return fetchJSON(`${baseUrl}/inspect?${params}`); + }, + enabled: data?.kind === "opensearch" && scope.target !== "" && targetKind !== "", + retry: 0, + staleTime: 5 * 60_000, + }); + + const activeDatabase = + scope.database || scope.fallbackDatabase || data?.database || ""; + const completion = useMemo( + () => completionForInspection(data, target.data), + [data, target.data], + ); + return { + data, + nodes: data?.nodes ?? [], + databases: base.data?.databases ?? [], + activeDatabase, + sqlDatabase: data?.kind === "sql" ? activeDatabase : "", + targetKind, + loading: active.isLoading, + error: active.error, + completion, + }; +} diff --git a/packages/ui/src/profiles/connectionQueryWorkspace.test.tsx b/packages/ui/src/profiles/connectionQueryWorkspace.test.tsx new file mode 100644 index 00000000..b5537935 --- /dev/null +++ b/packages/ui/src/profiles/connectionQueryWorkspace.test.tsx @@ -0,0 +1,224 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; +import type { BrowserDescriptor, Inspection } from "./connectionBrowserModel"; +import { ConnectionQueryWorkspace } from "./connectionQueryWorkspace"; +import { initialNavigatorTab, navigatorTabs, supportsQueryBuilder } from "./connectionQueryWorkspaceModel"; +import { EsCompileRequest } from "./esQueryCompile"; + +// The compile request only leaves the browser once effects run, which server +// rendering never does — so the wiring is asserted on what the hook was handed. +const compileInputs = vi.hoisted(() => [] as EsCompileRequest[]); +vi.mock("./esQueryCompile", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + useCompiledSearch: (input: EsCompileRequest) => { + compileInputs.push(input); + return original.useCompiledSearch(input); + }, + }; +}); + +const searchSchema = { + type: "object" as const, + properties: { + search: { + type: "object" as const, + "x-clicky-component": "es-query-builder", + "x-es-operators": [ + { op: "term", label: "term", arity: "single", fieldTypes: ["keyword"] }, + ], + }, + }, +}; + +const openSearch: BrowserDescriptor = { + kind: "query", + provider: "opensearch", + language: "json", + catalog: true, + targetLabel: "Index", + optionsSchema: searchSchema, +}; + +const sql: BrowserDescriptor = { + kind: "query", + provider: "sql", + language: "sql", + catalog: true, +}; + +describe("supportsQueryBuilder", () => { + it("accepts a source whose options schema carries an operator catalog", () => { + expect(supportsQueryBuilder(openSearch)).toBe(true); + }); + + it("rejects a source with no options schema", () => { + expect(supportsQueryBuilder(sql)).toBe(false); + }); + + it("rejects an options schema that describes no structured search", () => { + expect( + supportsQueryBuilder({ + ...openSearch, + optionsSchema: { type: "object", properties: { index: { type: "string" } } }, + }), + ).toBe(false); + }); +}); + +describe("navigatorTabs", () => { + it("offers the two authoring modes as tabs, form first", () => { + expect(navigatorTabs({ descriptor: openSearch, builder: true })).toEqual([ + { id: "form", label: "Form" }, + { id: "json", label: "JSON" }, + ]); + }); + + it("keeps the catalog tab for a hierarchical source with a builder", () => { + expect( + navigatorTabs({ + descriptor: { ...openSearch, targetLabel: undefined }, + builder: true, + }), + ).toEqual([ + { id: "catalog", label: "Catalog" }, + { id: "form", label: "Form" }, + { id: "json", label: "JSON" }, + ]); + }); + + it("offers Catalog alone for a source with no structured search", () => { + expect(navigatorTabs({ descriptor: sql, builder: false })).toEqual([ + { id: "catalog", label: "Catalog" }, + ]); + }); + + it("offers no tabs when the target picker is the whole navigator", () => { + expect(navigatorTabs({ descriptor: openSearch, builder: false })).toEqual([]); + }); + + it("offers no navigator when there is neither a catalog nor a builder", () => { + expect( + navigatorTabs({ descriptor: { ...sql, catalog: false }, builder: false }), + ).toEqual([]); + }); +}); + +describe("initialNavigatorTab", () => { + it("starts in the form so filters are always built, not opted into", () => { + expect( + initialNavigatorTab({ + tabs: navigatorTabs({ descriptor: openSearch, builder: true }), + search: undefined, + query: "", + }), + ).toBe("form"); + }); + + it("opens a stored specification in the form", () => { + expect( + initialNavigatorTab({ + tabs: navigatorTabs({ descriptor: openSearch, builder: true }), + search: {}, + query: "", + }), + ).toBe("form"); + }); + + it("opens a stored raw query in JSON rather than discarding it", () => { + expect( + initialNavigatorTab({ + tabs: navigatorTabs({ descriptor: openSearch, builder: true }), + search: undefined, + query: '{"query":{"term":{"level":"error"}}}', + }), + ).toBe("json"); + }); + + it("treats the descriptor's own starter query as nothing to preserve", () => { + expect( + initialNavigatorTab({ + tabs: navigatorTabs({ descriptor: openSearch, builder: true }), + search: undefined, + query: '{"query":{"match_all":{}}}', + defaultQuery: '{"query":{"match_all":{}}}', + }), + ).toBe("form"); + }); + + it("falls back to the only tab a builder-less source has", () => { + expect( + initialNavigatorTab({ + tabs: navigatorTabs({ descriptor: sql, builder: false }), + search: undefined, + query: "SELECT 1", + }), + ).toBe("catalog"); + }); +}); + +// The preview is compiled server-side, so an operand that interpolates +// {{.params.…}} resolves only if the host's parameter values travel with the +// specification. Without them the panel shows the compiler's refusal to guess. +describe("ConnectionQueryWorkspace compilation", () => { + const inspection: Inspection = { + nodes: [], + databases: [], + activeDatabase: "", + sqlDatabase: "", + targetKind: "index", + loading: false, + error: undefined, + }; + + const renderWorkspace = (extra: Record) => { + compileInputs.length = 0; + renderToStaticMarkup( + + {}} + query="" + onQueryChange={() => {}} + options={{ index: "logs-*" }} + onOptionsChange={() => {}} + onCatalogSelect={() => {}} + search={{ + query: { + op: "term", + field: "service.name", + value: "{{.params.service}}", + }, + }} + onSearchChange={() => {}} + compileBaseUrl="/api/v1/connection/abc/browser" + execute={async () => ({ rows: [] })} + {...extra} + /> + , + ); + return compileInputs; + }; + + it("compiles the specification against the host's parameter values", () => { + const inputs = renderWorkspace({ + params: [{ name: "service" }, { name: "since", role: "time-from" }], + paramValues: { service: "payments", since: "now-1h" }, + paramRoles: { since: "time-from" }, + }); + expect(inputs.length).toBeGreaterThan(0); + expect(inputs[0]?.params).toEqual({ service: "payments", since: "now-1h" }); + expect(inputs[0]?.roles).toEqual({ since: "time-from" }); + }); + + it("sends no parameter values when the host declares none", () => { + const inputs = renderWorkspace({}); + expect(inputs.length).toBeGreaterThan(0); + expect(inputs[0]?.params).toBeUndefined(); + }); +}); diff --git a/packages/ui/src/profiles/connectionQueryWorkspace.tsx b/packages/ui/src/profiles/connectionQueryWorkspace.tsx new file mode 100644 index 00000000..a406239c --- /dev/null +++ b/packages/ui/src/profiles/connectionQueryWorkspace.tsx @@ -0,0 +1,306 @@ +/** + * The single query-browser host. The connection browser, the profile wizard and + * the profile-edit builder all render this: they own where the query and the + * options are stored and what a run means, and this owns the browser itself — + * the catalog navigator, the structured filter builder, the completion, and the + * descriptor's result view. + */ + +import type { JsonSchemaObject } from "../components/json-schema-form-types"; +import { LogsTable } from "../data/LogsTable"; +import { QueryBrowser } from "../data/query-browser/QueryBrowser"; +import type { QueryBrowserFilterLookup, QueryBrowserRequest, QueryBrowserResult, QueryBrowserResultContext } from "../data/query-browser/QueryBrowser.types"; +import { Tabs } from "../layout/Tabs"; +import { useEffect, useRef, useState, type ReactNode } from "react"; +import { CatalogTree } from "./catalogTree"; +import { + withTarget, + type BrowserDescriptor, + type CatalogNode, + type Inspection, + type ProfileRowLimits +} from "./connectionBrowserModel"; +import { makeFieldValueLookup } from "./esFieldValues"; +import { EsQueryBuilder } from "./esQueryBuilder"; +import { + toBuilderMode, + toRawMode, + type EsSearch, + type QueryModeTransition +} from "./esQueryBuilderModel"; +import { + esBuilderVocabulary + } from "./esQueryOperators"; +import { PrometheusResults } from "./prometheusResults"; +import { QueryRowLimits } from "./queryRowLimits"; +import { QueryTargetPicker } from "./queryTargetPicker"; +import type { ParamMappingEdit } from "./esParamMappingModel"; +import type { ParamDraft } from "./profileWizardModel"; +import { initialNavigatorTab, navigatorTabs, supportsQueryBuilder } from "./connectionQueryWorkspaceModel"; +import { esQueryFields } from "./esQueryBuilderForm"; +import { useCompiledSearch } from "./esQueryCompile"; + + +export type ConnectionQueryWorkspaceProps = { + id: string; + title: string; + descriptor: BrowserDescriptor; + inspection: Inspection; + onDatabaseChange: (database: string) => void; + query: string; + onQueryChange?: (query: string) => void | undefined; + options: Record; + onOptionsChange: (options: Record) => void; + onCatalogSelect: (node: CatalogNode) => void; + optionsSchema?: JsonSchemaObject | undefined; + /** + * The structured specification this host stores, when it stores one. + * `undefined` means the raw query is the artifact. + */ + search?: EsSearch | undefined; + /** + * Every change reports the specification and the raw query together, and one + * of the two is always empty. The host stores both in one write, so it can + * never end up holding a specification and a query at once — a state the + * server rejects. + */ + onSearchChange?: (transition: QueryModeTransition) => void | undefined; + /** + * The row caps the edited profile sets for itself. They are profile settings, + * not provider options, so they are stored beside the query rather than + * through `onOptionsChange`. A host that edits no profile — the connection + * browser — passes neither, and the caps are then not offered at all. + */ + limits?: ProfileRowLimits | undefined; + onLimitsChange?: (limits: ProfileRowLimits | undefined) => void; + /** Declared profile parameters an operand can bind to. */ + params?: ParamDraft[] | undefined; + onParamMappingChange?: (edit: ParamMappingEdit) => void | undefined; + /** + * What those parameters currently resolve to. The server binds a {param:…} + * operand from them and interpolates a {{.params.…}} one, so without values + * the preview shows template text — or the compiler's refusal to guess. + */ + paramValues?: Record | undefined; + /** Those parameters' roles, so the compiled preview folds them as a run would. */ + paramRoles?: Record | undefined; + /** Where POST /compile lives. Empty leaves the preview unresolved. */ + compileBaseUrl?: string | undefined; + execute: (request: QueryBrowserRequest) => Promise; + /** + * Answers a filter's value type-ahead. Absent leaves every filter the source + * described showing only the values the result itself carried. + */ + lookupFilterValues?: QueryBrowserFilterLookup | undefined; + renderResults?: (context: QueryBrowserResultContext) => ReactNode | undefined; + className?: string | undefined; +}; + +export function ConnectionQueryWorkspace({ + id, + title, + descriptor, + inspection, + onDatabaseChange, + query, + onQueryChange, + options, + onOptionsChange, + onCatalogSelect, + optionsSchema, + search, + onSearchChange, + limits, + onLimitsChange, + params, + onParamMappingChange, + paramValues, + paramRoles, + compileBaseUrl = "", + execute, + lookupFilterValues, + renderResults, + className +}: ConnectionQueryWorkspaceProps) { + const builder = Boolean(onSearchChange) && supportsQueryBuilder(descriptor); + const tabs = navigatorTabs({ descriptor, builder }); + const [tab, setTab] = useState(() => + initialNavigatorTab({ + tabs, + search, + query, + ...(descriptor.defaultQuery ? { defaultQuery: descriptor.defaultQuery } : {}) + }), + ); + // Picking a target has to reach the browser, which only resyncs its options + // when `initialOptions` changes identity. The pick is layered here rather than + // round-tripped through the host, so an options-form keystroke — which the + // host also stores — cannot resync the browser out from under the author. + const [picked, setPicked] = useState>(); + // What the browser last reported, so a pick keeps the author's other edits. + const edited = useRef(options); + const seed = useRef(options); + if (seed.current !== options) { + seed.current = options; + edited.current = options; + if (picked) setPicked(undefined); + } + const browserOptions = picked ?? options; + const applyOptions = (next: Record) => { + edited.current = next; + setPicked(next); + onOptionsChange(next); + }; + const compilation = useCompiledSearch({ + baseUrl: compileBaseUrl, + search: search ?? {}, + ...(paramValues ? { params: paramValues } : {}), + ...(paramRoles ? { roles: paramRoles } : {}), + enabled: Boolean(search) && compileBaseUrl !== "" + }); + const values = makeFieldValueLookup({ + baseUrl: compileBaseUrl, + index: String(browserOptions.index ?? ""), + ...(paramValues ? { params: paramValues } : {}), + ...(paramRoles ? { roles: paramRoles } : {}) + }); + + // While a specification is active the editor mirrors what it compiles to. It + // is a preview, not an input: the query is not stored alongside the spec, so + // there is no keystroke for a compile to overwrite. + const specMode = search !== undefined; + const active = tabs.some((entry) => entry.id === tab) ? tab : tabs[0]?.id; + + // The form tab is the specification, so being on it means holding one. A tab + // that stores nothing would leave the builder rendering a query it cannot + // edit, and this is also what makes filters the default rather than an opt-in. + useEffect(() => { + if (active === "form" && onSearchChange && search === undefined) { + onSearchChange(toBuilderMode()); + } + }, [active, onSearchChange, search]); + + // Switching tabs is the mode switch. Each mode stores its own artifact and the + // server rejects holding both, so leaving the form hands the raw editor the + // DSL the specification last compiled to and drops the specification. + const selectTab = (next: string) => { + if (next === "json" && search && onSearchChange) { + onSearchChange(toRawMode(search, compilation.query, query)); + } + setTab(next); + }; + + return ( + { + edited.current = next; + onOptionsChange(next); + }} + className={className} + navigator={ + tabs.length === 0 && !descriptor.targetLabel ? undefined : ( +
+ {descriptor.targetLabel ? ( + + applyOptions(withTarget(edited.current, { index, targetKind })) + } + /> + ) : null} + {builder ? ( + + applyOptions({ ...edited.current, limit }) + } + {...(descriptor.rowLimits + ? { defaults: descriptor.rowLimits } + : {})} + {...(limits ? { limits } : {})} + {...(onLimitsChange ? { onLimitsChange } : {})} + /> + ) : null} + {tabs.length > 1 ? ( + + ) : null} + {active === "form" && onSearchChange && search ? ( + onSearchChange({ search: next, query: "" })} + fields={esQueryFields(inspection.completion)} + vocabulary={esBuilderVocabulary(descriptor.optionsSchema)} + {...(params ? { params } : {})} + {...(onParamMappingChange + ? { onMappingChange: onParamMappingChange } + : {})} + {...(values ? { values } : {})} + compilation={compilation} + /> + ) : active === "json" ? ( +

+ The {descriptor.queryLabel ?? "query"} editor holds the query. + Switch back to Form to build it from filters — the raw query is + dropped then, since only one of the two is stored. +

+ ) : active === "catalog" ? ( + + ) : null} +
+ ) + } + execute={(request) => + execute( + specMode + ? { ...request, query: "", options: { ...request.options, search } } + : request, + ) + } + {...(lookupFilterValues ? { lookupFilterValues } : {})} + renderResults={renderResults ?? descriptorResultView(descriptor)} + /> + ); +} + +/** + * descriptorResultView honours the view the server nominated for this provider. + * A host that renders its own results (the profile builder's column picker) + * passes renderResults and takes over entirely. + */ +function descriptorResultView( + descriptor: BrowserDescriptor, +): ((context: QueryBrowserResultContext) => ReactNode) | undefined { + if (descriptor.resultView === "logs") { + return ({ result, defaultView }) => + result.rows?.length ? ( + + ) : ( + defaultView + ); + } + if (descriptor.resultView === "timeseries") { + return ({ result, defaultView }) => ( + + ); + } + return undefined; +} diff --git a/packages/ui/src/profiles/connectionQueryWorkspaceModel.ts b/packages/ui/src/profiles/connectionQueryWorkspaceModel.ts new file mode 100644 index 00000000..cf3e039a --- /dev/null +++ b/packages/ui/src/profiles/connectionQueryWorkspaceModel.ts @@ -0,0 +1,66 @@ +/** + * Which navigator tabs the query workspace offers, and which one it opens on. + * + * Kept apart from the workspace component so that module exports only + * components (react/only-export-components). + */ + +import type { BrowserDescriptor } from "./connectionBrowserModel"; +import type { EsSearch } from "./esQueryBuilderModel"; +import { operatorCatalogFromSchema } from "./esQueryOperators"; + +export type NavigatorTab = { id: "catalog" | "form" | "json"; label: string }; + +/** + * A source supports the builder when the server described a structured search on + * it. The operator catalog travels with the schema, so no provider name is + * hardcoded here — adding a structured provider in Go reaches the editor on its + * own. + */ +export function supportsQueryBuilder(descriptor: BrowserDescriptor): boolean { + return operatorCatalogFromSchema(descriptor.optionsSchema).length > 0; +} + +/** + * navigatorTabs is what the left pane offers. Where the source has a structured + * search, the two ways of authoring one — the form and the raw DSL — are tabs + * rather than a one-way door: they are the same query, and the tab says which + * of the two is stored. A source that picks one flat target has a combobox + * pinned above the tabs instead of a catalog tree — its targets are a list of + * index names, and a list is not worth navigating. + */ +export function navigatorTabs(input: { + descriptor: BrowserDescriptor; + builder: boolean; +}): NavigatorTab[] { + const tabs: NavigatorTab[] = []; + if (input.descriptor.catalog && !input.descriptor.targetLabel) { + tabs.push({ id: "catalog", label: "Catalog" }); + } + if (input.builder) { + tabs.push({ id: "form", label: "Form" }, { id: "json", label: "JSON" }); + } + return tabs; +} + +/** + * initialNavigatorTab opens on the form: filters are what the builder is for, + * so it is where authoring starts rather than something to opt into. The one + * thing that overrides it is a raw query already worth preserving — and the + * starter query the descriptor supplies is not one, since nobody wrote it. + */ +export function initialNavigatorTab(input: { + tabs: NavigatorTab[]; + search: EsSearch | undefined; + query: string; + defaultQuery?: string; +}): string | undefined { + const has = (id: NavigatorTab["id"]) => + input.tabs.some((tab) => tab.id === id); + if (!has("form")) return input.tabs[0]?.id; + if (input.search) return "form"; + const authored = input.query.trim(); + return authored && authored !== (input.defaultQuery ?? "").trim() + ? "json" + : "form"; +} diff --git a/packages/ui/src/profiles/esFieldValues.test.ts b/packages/ui/src/profiles/esFieldValues.test.ts new file mode 100644 index 00000000..03e1a9f3 --- /dev/null +++ b/packages/ui/src/profiles/esFieldValues.test.ts @@ -0,0 +1,170 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { makeFieldValueLookup, valueLookupField } from "./esFieldValues"; +import type { EsFieldMapping } from "./esQueryOperators"; + +const fields: EsFieldMapping[] = [ + { name: "@timestamp", dataType: "date", aggregatable: true }, + { name: "service.name", dataType: "keyword", aggregatable: true }, + { name: "message", dataType: "text", aggregatable: false }, + { name: "message.keyword", dataType: "keyword", aggregatable: true }, + { name: "trace.id", dataType: "text", aggregatable: false }, +]; + +describe("valueLookupField", () => { + it("aggregates a keyword field on itself", () => { + expect(valueLookupField(fields, "service.name")).toBe("service.name"); + }); + + it("aggregates an analyzed text field through its keyword sibling", () => { + expect(valueLookupField(fields, "message")).toBe("message.keyword"); + }); + + it("offers no lookup for a text field without a keyword sibling", () => { + expect(valueLookupField(fields, "trace.id")).toBeUndefined(); + }); + + it("offers no lookup for a date field, whose values are all distinct", () => { + expect(valueLookupField(fields, "@timestamp")).toBeUndefined(); + }); + + it("offers no lookup for a field the mappings do not describe", () => { + expect(valueLookupField(fields, "unmapped")).toBeUndefined(); + expect(valueLookupField(fields, undefined)).toBeUndefined(); + }); +}); + +const baseUrl = "/api/v1/connection/abc/browser"; +const search = { query: { op: "term", field: "env", value: "prod" } }; + +const respondWith = (body: unknown, status = 200) => + ({ + ok: status >= 200 && status < 300, + status, + json: async () => body, + text: async () => JSON.stringify(body), + }) as Response; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("makeFieldValueLookup", () => { + it("asks nothing without a connection or an index", () => { + expect(makeFieldValueLookup({ baseUrl: "", index: "logs-*" })).toBeUndefined(); + expect(makeFieldValueLookup({ baseUrl, index: "" })).toBeUndefined(); + }); + + it("posts the field, the substring and the scope to the browser", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue( + respondWith({ values: [{ value: "payments", count: 3 }], total: 9, scoped: true }), + ); + vi.stubGlobal("fetch", fetchMock); + + const source = makeFieldValueLookup({ + baseUrl, + index: "logs-*", + roles: { since: "time-from" }, + }); + const result = await source!({ field: "service.name", search }).fetch("pay"); + + expect(result).toEqual({ + values: [{ value: "payments", count: 3 }], + total: 9, + scoped: true, + }); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe(`${baseUrl}/values`); + expect(JSON.parse(init.body)).toMatchObject({ + index: "logs-*", + field: "service.name", + q: "pay", + search, + roles: { since: "time-from" }, + }); + }); + + // A sibling condition left half-finished cannot compile, and an empty value + // list would read as "this field holds nothing". The whole index is asked + // instead, and the answer says the scope was widened. + it("retries across the whole index when the scope will not compile", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(respondWith("condition has no value", 422)) + .mockResolvedValueOnce(respondWith({ values: [], total: 0, scoped: false })); + vi.stubGlobal("fetch", fetchMock); + + const source = makeFieldValueLookup({ baseUrl, index: "logs-*" }); + const result = await source!({ field: "service.name", search }).fetch(""); + + expect(result.scoped).toBe(false); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(JSON.parse(fetchMock.mock.calls[1][1].body).search).toBeUndefined(); + }); + + it("surfaces a lookup that fails for any other reason", async () => { + const fetchMock = vi.fn().mockResolvedValue(respondWith("index_not_found", 404)); + vi.stubGlobal("fetch", fetchMock); + + const source = makeFieldValueLookup({ baseUrl, index: "logs-*" }); + await expect(source!({ field: "service.name", search }).fetch("")).rejects.toThrow( + /index_not_found/, + ); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + // The scope is compiled server-side, so a sibling condition interpolating + // {{.params.env}} only narrows the suggestions if the values travel with it. + it("posts the parameter values the scope is compiled against", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(respondWith({ values: [], total: 0, scoped: true })); + vi.stubGlobal("fetch", fetchMock); + + const source = makeFieldValueLookup({ + baseUrl, + index: "logs-*", + params: { env: "prod" }, + }); + await source!({ field: "service.name", search }).fetch(""); + + expect(JSON.parse(fetchMock.mock.calls[0][1].body).params).toEqual({ + env: "prod", + }); + }); + + it("leaves the parameter values off when none are declared", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(respondWith({ values: [], total: 0, scoped: true })); + vi.stubGlobal("fetch", fetchMock); + + const source = makeFieldValueLookup({ baseUrl, index: "logs-*", params: {} }); + await source!({ field: "service.name", search }).fetch(""); + + expect(JSON.parse(fetchMock.mock.calls[0][1].body)).not.toHaveProperty("params"); + }); + + it("keys a lookup by every input that changes the compiled value scope", () => { + const source = makeFieldValueLookup({ baseUrl, index: "logs-*" })!; + const scoped = source({ field: "service.name", search }); + expect(scoped.key).not.toBe(source({ field: "service.name" }).key); + expect(scoped.key).not.toBe(source({ field: "host.name", search }).key); + expect(scoped.key).toBe(source({ field: "service.name", search }).key); + + const parameterized = makeFieldValueLookup({ + baseUrl, + index: "logs-*", + params: { env: "prod" }, + })!; + expect(parameterized({ field: "service.name", search }).key).not.toBe(scoped.key); + + const withRole = makeFieldValueLookup({ + baseUrl, + index: "logs-*", + roles: { since: "time-from" }, + })!; + expect(withRole({ field: "service.name", search }).key).not.toBe(scoped.key); + }); +}); diff --git a/packages/ui/src/profiles/esFieldValues.ts b/packages/ui/src/profiles/esFieldValues.ts new file mode 100644 index 00000000..071a6c9d --- /dev/null +++ b/packages/ui/src/profiles/esFieldValues.ts @@ -0,0 +1,132 @@ +/** + * What a field actually holds. The browser answers a terms aggregation over the + * selected index, so an operand is picked from real values rather than typed + * from memory. Which field the aggregation can run on is not always the field + * being filtered — an analyzed text field aggregates through its keyword + * sibling — and that resolution is owned here. + */ + +import type { EsSearch } from "./esQueryBuilderModel"; +import { fieldFamily, type EsFieldMapping } from "./esQueryOperators"; + +export type FieldValue = { value: string; count: number }; + +export type FieldValuesResult = { + values: FieldValue[]; + total: number; + /** Whether the values reflect the rest of the query or the whole index. */ + scoped: boolean; +}; + +/** + * One resolved lookup. `key` identifies what is being asked — field and scope — + * so a consumer can cache the answer without re-serializing the request. + */ +export type FieldValuesQuery = { + key: string; + fetch: (query: string) => Promise; +}; + +/** A host's lookup, bound to a connection and an index. */ +export type FieldValuesSource = (request: { + field: string; + search?: EsSearch; +}) => FieldValuesQuery; + +const valueLimit = 100; + +/** + * valueLookupField resolves the field a terms aggregation can run on, or + * undefined when none can. A text field is analyzed, so its own doc values are + * absent and the aggregation goes through the keyword sibling _field_caps + * reports beside it. Dates are excluded deliberately: every timestamp is + * distinct, so a value list says nothing the date-math presets do not. + */ +export function valueLookupField( + fields: EsFieldMapping[], + name: string | undefined, +): string | undefined { + if (!name) return undefined; + const field = fields.find((entry) => entry.name === name); + if (!field || fieldFamily(field) === "date") return undefined; + if (field.aggregatable !== false) return field.name; + const keyword = fields.find( + (entry) => entry.name === `${name}.keyword` && entry.aggregatable !== false, + ); + return keyword?.name; +} + +type ValuesRequestBody = { + index: string; + field: string; + q?: string; + limit?: number; + search?: EsSearch; + params?: Record; + roles?: Record; +}; + +async function postValues( + baseUrl: string, + body: ValuesRequestBody, +): Promise { + const response = await fetch(`${baseUrl}/values`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!response.ok) { + const text = (await response.text()).trim(); + const error = new Error(text || `value lookup failed: ${response.status}`); + (error as Error & { status?: number }).status = response.status; + throw error; + } + return (await response.json()) as FieldValuesResult; +} + +/** + * makeFieldValueLookup binds a lookup to a connection and an index. A scope the + * server cannot compile — a sibling condition left half-finished — is answered + * across the whole index instead, and the result says so, so the widened scope + * is visible rather than silently assumed. The scope is compiled server-side + * against `params`, so a sibling condition that interpolates `{{.params.…}}` + * narrows the suggestions the same way a run would. + */ +export function makeFieldValueLookup(options: { + baseUrl: string; + index: string; + params?: Record; + roles?: Record; +}): FieldValuesSource | undefined { + const { baseUrl, index, params, roles } = options; + if (!baseUrl || !index) return undefined; + return ({ field, search }) => ({ + key: JSON.stringify([ + baseUrl, + index, + field, + search ?? null, + params ?? null, + roles ?? null, + ]), + fetch: async (query) => { + const body: ValuesRequestBody = { + index, + field, + q: query, + limit: valueLimit, + ...(search ? { search } : {}), + ...(params && Object.keys(params).length ? { params } : {}), + ...(roles && Object.keys(roles).length ? { roles } : {}), + }; + try { + return await postValues(baseUrl, body); + } catch (error) { + const status = (error as Error & { status?: number }).status; + if (!search || status !== 422) throw error; + const { search: _dropped, ...unscoped } = body; + return postValues(baseUrl, unscoped); + } + }, + }); +} diff --git a/packages/ui/src/profiles/esParamMappingModel.test.ts b/packages/ui/src/profiles/esParamMappingModel.test.ts new file mode 100644 index 00000000..1af0940c --- /dev/null +++ b/packages/ui/src/profiles/esParamMappingModel.test.ts @@ -0,0 +1,307 @@ +import { describe, expect, it } from "vitest"; +import type { EsSearch } from "./esQueryBuilderModel"; +import { + addParamMapping, + bindParamOperand, + paramMappings, + reconcileParamMappings, + reconcileSearchParamMappings, + removeParamMapping, +} from "./esParamMappingModel"; +import type { ParamDraft } from "./profileWizardModel"; + +const filterParams: ParamDraft[] = [ + { name: "service", type: "string", role: "filter" }, + { name: "schemes", type: "list", role: "filter" }, +]; + +describe("parameter query mappings", () => { + it("adds more than one scalar condition for the same parameter", () => { + const first = addParamMapping({ + search: {}, + params: filterParams, + name: "service", + field: "service.name", + }); + const second = addParamMapping({ + ...first, + name: "service", + field: "peer.service", + }); + + expect(second).toEqual({ + search: { + query: { + op: "bool", + conditions: [ + { + op: "term", + occur: "filter", + field: "service.name", + value: { param: "service" }, + optional: true, + }, + { + op: "term", + occur: "filter", + field: "peer.service", + value: { param: "service" }, + optional: true, + }, + ], + }, + }, + params: filterParams, + }); + expect(paramMappings(second.search, "service")).toEqual([ + { path: [0], field: "service.name", operand: "value" }, + { path: [1], field: "peer.service", operand: "value" }, + ]); + }); + + it("moves a list mapping and keeps its native field linked", () => { + const existing: EsSearch = { + query: { + op: "bool", + conditions: [ + { + op: "terms", + field: "old.scheme", + values: ["literal", { param: "schemes" }], + }, + ], + }, + }; + + const result = addParamMapping({ + search: existing, + params: filterParams, + name: "schemes", + field: "scheme.id", + }); + + expect(result.search.query?.conditions).toEqual([ + { op: "terms", field: "old.scheme", values: ["literal"] }, + { + op: "terms", + occur: "filter", + field: "scheme.id", + value: { param: "schemes" }, + optional: true, + }, + ]); + expect(result.params[1].field).toBe("scheme.id"); + expect(paramMappings(result.search, "schemes")).toEqual([ + { path: [1], field: "scheme.id", operand: "value" }, + ]); + }); + + it("removes only the selected reference and prunes an empty leaf", () => { + const search: EsSearch = { + query: { + op: "bool", + conditions: [ + { + op: "terms", + field: "scheme.id", + values: ["literal", { param: "schemes" }], + }, + { op: "term", field: "service.name", value: { param: "service" } }, + ], + }, + }; + + const list = removeParamMapping({ + search, + params: filterParams, + name: "schemes", + path: [0], + }); + expect(list.search.query?.conditions).toEqual([ + { op: "terms", field: "scheme.id", values: ["literal"] }, + { op: "term", field: "service.name", value: { param: "service" } }, + ]); + expect(list.params[1].field).toBeUndefined(); + + const scalar = removeParamMapping({ + ...list, + name: "service", + path: [1], + }); + expect(scalar.search.query?.conditions).toEqual([ + { op: "terms", field: "scheme.id", values: ["literal"] }, + ]); + }); + + it("binds a multiple operand canonically without a stale singular value", () => { + const result = bindParamOperand({ + search: { + query: { + op: "terms", + field: "scheme.id", + value: "stale", + values: ["literal"], + }, + }, + params: filterParams, + path: [], + operand: "values", + name: "schemes", + }); + + expect(result.search.query).toEqual({ + op: "terms", + field: "scheme.id", + value: undefined, + values: [{ param: "schemes" }], + gt: undefined, + gte: undefined, + lt: undefined, + lte: undefined, + conditions: undefined, + }); + expect(result.params[1].field).toBe("scheme.id"); + }); + + it("keeps a linked list field synchronized across query tree edits", () => { + const previousSearch: EsSearch = { + query: { + op: "bool", + conditions: [ + { + op: "terms", + field: "scheme.id", + value: { param: "schemes" }, + }, + ], + }, + }; + const params = [ + filterParams[0], + { ...filterParams[1], field: "scheme.id" }, + ]; + + const moved = reconcileSearchParamMappings({ + previousSearch, + nextSearch: { + query: { + op: "bool", + conditions: [ + { + op: "terms", + field: "scheme.code", + value: { param: "schemes" }, + }, + ], + }, + }, + params, + }); + expect(moved.params[1].field).toBe("scheme.code"); + + const removed = reconcileSearchParamMappings({ + previousSearch: moved.search, + nextSearch: { query: { op: "bool", conditions: [] } }, + params: moved.params, + }); + expect(removed.params[1].field).toBeUndefined(); + }); + + it("preserves a native-only list field across unrelated query edits", () => { + const params = [ + filterParams[0], + { ...filterParams[1], field: "legacy.scheme" }, + ]; + + const edit = reconcileSearchParamMappings({ + previousSearch: {}, + nextSearch: { + query: { op: "term", field: "service.name", value: "payments" }, + }, + params, + }); + + expect(edit.params[1].field).toBe("legacy.scheme"); + }); + + it("maps time roles and rejects automatic paging roles", () => { + const time = addParamMapping({ + search: {}, + params: [{ name: "from", type: "date", role: "time-from" }], + name: "from", + field: "startTimeMillis", + }); + expect(time.search.timeField).toBe("startTimeMillis"); + + expect(() => + addParamMapping({ + search: {}, + params: [{ name: "limit", type: "number", role: "limit" }], + name: "limit", + field: "size", + }), + ).toThrow("limit parameter limit cannot map to a query field"); + }); +}); + +describe("parameter definition reconciliation", () => { + it("renames every operand and gate atomically", () => { + const search: EsSearch = { + query: { + op: "bool", + conditions: [ + { op: "term", field: "service.name", value: { param: "service" } }, + { op: "exists", field: "error", when: "service" }, + ], + }, + }; + const next = [{ ...filterParams[0], name: "application" }, filterParams[1]]; + + expect( + reconcileParamMappings({ search, previous: filterParams, next }), + ).toEqual({ + search: { + query: { + op: "bool", + conditions: [ + { + op: "term", + field: "service.name", + value: { param: "application" }, + }, + { op: "exists", field: "error", when: "application" }, + ], + }, + }, + params: next, + }); + }); + + it("removes deleted references and preserves unrelated conditions", () => { + const search: EsSearch = { + query: { + op: "bool", + conditions: [ + { op: "term", field: "service.name", value: { param: "service" } }, + { op: "term", field: "level", value: "error" }, + ], + }, + }; + + expect( + reconcileParamMappings({ + search, + previous: filterParams, + next: [filterParams[1]], + }), + ).toEqual({ + search: { + query: { + op: "bool", + conditions: [{ op: "term", field: "level", value: "error" }], + }, + }, + params: [filterParams[1]], + }); + }); +}); diff --git a/packages/ui/src/profiles/esParamMappingModel.ts b/packages/ui/src/profiles/esParamMappingModel.ts new file mode 100644 index 00000000..74f7dffa --- /dev/null +++ b/packages/ui/src/profiles/esParamMappingModel.ts @@ -0,0 +1,410 @@ +import type { + ConditionPath, + EsCondition, + EsSearch, + EsValue, +} from "./esQueryBuilderModel"; +import { + conditionAt, + emptyGroup, + isParamValue, + updateAt, +} from "./esQueryBuilderModel"; +import { conditionOperandPatch } from "./esQueryOperandModel"; +import { applyPatch, type ParamDraft } from "./profileWizardModel"; + +export type ParamOperand = "value" | "values" | "gt" | "gte" | "lt" | "lte"; + +export type ParamMapping = { + path: ConditionPath; + field: string; + operand: ParamOperand; +}; + +export type ParamMappingEdit = { + search: EsSearch; + params: ParamDraft[]; +}; + +export function paramMappings( + search: EsSearch | undefined, + name: string, +): ParamMapping[] { + const found: ParamMapping[] = []; + const walk = (condition: EsCondition | undefined, path: ConditionPath) => { + if (!condition) return; + for (const operand of operands) { + const value = condition[operand]; + const values = Array.isArray(value) ? value : [value]; + if ( + condition.field && + values.some((entry) => isParamValue(entry) && entry.param === name) + ) { + found.push({ path, field: condition.field, operand }); + } + } + condition.conditions?.forEach((child, index) => + walk(child, [...path, index]), + ); + }; + walk(search?.query, []); + return found; +} + +export function addParamMapping({ + search, + params, + name, + field, +}: ParamMappingEdit & { name: string; field: string }): ParamMappingEdit { + const param = namedParam(params, name); + if (param.role === "limit" || param.role === "offset") { + throw new Error( + `${param.role} parameter ${name} cannot map to a query field`, + ); + } + if (param.role === "time-from" || param.role === "time-to") { + return { search: { ...search, timeField: field }, params }; + } + const withoutPrevious = + param.type === "list" ? stripParamReferences(search, name) : search; + const condition: EsCondition = { + op: param.type === "list" ? "terms" : "term", + occur: "filter", + field, + value: { param: name }, + ...(!param.required ? { optional: true } : {}), + }; + return { + search: appendCondition(withoutPrevious, condition), + params: syncNativeField( + params, + name, + param.type === "list" ? field : undefined, + ), + }; +} + +export function bindParamOperand({ + search, + params, + path, + operand, + name, +}: ParamMappingEdit & { + path: ConditionPath; + operand: ParamOperand; + name: string; +}): ParamMappingEdit { + const param = namedParam(params, name); + if (param.role && param.role !== "filter") { + throw new Error( + `${param.role} parameter ${name} cannot bind a query operand`, + ); + } + const condition = search.query && conditionAt(search.query, path); + if (!condition?.field) + throw new Error(`condition for parameter ${name} has no field`); + const value = { param: name }; + const patch = + operand === "values" + ? conditionOperandPatch({ arity: "multiple", values: [value] }) + : operand === "value" + ? conditionOperandPatch({ arity: "single", value }) + : conditionOperandPatch({ arity: "range", bound: operand, value }); + const query = updateAt(search.query as EsCondition, path, (current) => + applyPatch(current, patch), + ); + const bound = { ...search, query }; + return { + search: + param.type === "list" ? stripParamReferences(bound, name, path) : bound, + params: syncNativeField( + params, + name, + param.type === "list" ? condition.field : undefined, + ), + }; +} + +export function removeParamMapping({ + search, + params, + name, + path, +}: ParamMappingEdit & { + name: string; + path?: ConditionPath; +}): ParamMappingEdit { + const param = namedParam(params, name); + if (param.role === "time-from" || param.role === "time-to") { + const next = { ...search }; + delete next.timeField; + return { search: next, params }; + } + if (!path) { + if (param.type !== "list") + throw new Error(`parameter ${name} has no field mapping to remove`); + return { search, params: syncNativeField(params, name, undefined) }; + } + const query = search.query + ? editAt(search.query, path, (condition) => + removeReference(condition, name), + ) + : undefined; + return { + search: { ...search, query: query ?? emptyGroup() }, + params: syncNativeField(params, name, undefined), + }; +} + +export function reconcileParamMappings({ + search, + previous, + next, +}: { + search: EsSearch; + previous: ParamDraft[]; + next: ParamDraft[]; +}): ParamMappingEdit { + let reconciled = search; + const previousNames = new Set( + previous.map((param) => param.name).filter(Boolean), + ); + const nextNames = new Set(next.map((param) => param.name).filter(Boolean)); + const renamed = new Set(); + if (previous.length === next.length) { + previous.forEach((param, index) => { + const oldName = param.name; + const newName = next[index]?.name; + if ( + oldName && + newName && + oldName !== newName && + !nextNames.has(oldName) && + !previousNames.has(newName) + ) { + reconciled = renameParamReferences(reconciled, oldName, newName); + renamed.add(oldName); + } + }); + } + for (const param of previous) { + if (param.name && !renamed.has(param.name) && !nextNames.has(param.name)) { + reconciled = stripParamReferences(reconciled, param.name); + } + } + return { + search: reconciled, + params: syncAllNativeFields(reconciled, next, search), + }; +} + +export function reconcileSearchParamMappings({ + previousSearch, + nextSearch, + params, +}: { + previousSearch: EsSearch; + nextSearch: EsSearch; + params: ParamDraft[]; +}): ParamMappingEdit { + return { + search: nextSearch, + params: syncAllNativeFields(nextSearch, params, previousSearch), + }; +} + +const operands: ParamOperand[] = ["value", "values", "gt", "gte", "lt", "lte"]; + +function namedParam(params: ParamDraft[], name: string): ParamDraft { + const param = params.find((candidate) => candidate.name === name); + if (!param) throw new Error(`parameter ${name} does not exist`); + return param; +} + +function appendCondition(search: EsSearch, condition: EsCondition): EsSearch { + if (!search.query || search.query.op === "match_all") { + return { ...search, query: { ...emptyGroup(), conditions: [condition] } }; + } + if (search.query.op === "bool") { + return { + ...search, + query: { + ...search.query, + conditions: [...(search.query.conditions ?? []), condition], + }, + }; + } + return { + ...search, + query: { ...emptyGroup(), conditions: [search.query, condition] }, + }; +} + +function stripParamReferences( + search: EsSearch, + name: string, + keepPath?: ConditionPath, +): EsSearch { + const query = stripCondition(search.query, name, [], keepPath); + return { ...search, query: query ?? emptyGroup() }; +} + +function stripCondition( + condition: EsCondition | undefined, + name: string, + path: ConditionPath, + keepPath?: ConditionPath, +): EsCondition | undefined { + if (!condition) return undefined; + if (keepPath && samePath(path, keepPath)) return condition; + if (condition.when === name) return undefined; + let next = removeReference(condition, name); + if (!next) return undefined; + if (next.conditions) { + const conditions = next.conditions.flatMap((child, index) => { + const stripped = stripCondition(child, name, [...path, index], keepPath); + return stripped ? [stripped] : []; + }); + next = { ...next, conditions }; + if (conditions.length === 0 && (condition.conditions?.length ?? 0) > 0) + return undefined; + } + return next; +} + +function removeReference( + condition: EsCondition, + name: string, +): EsCondition | undefined { + let removed = false; + const next = { ...condition }; + for (const operand of operands) { + const value = condition[operand]; + if (operand === "values") { + const values = (condition.values ?? []).filter((entry) => { + const matches = isParamValue(entry) && entry.param === name; + removed ||= matches; + return !matches; + }); + // Deleted rather than set to undefined: the search is serialized, and a + // present `values: undefined` is a key the backend has to interpret. + if (values.length) next.values = values; + else delete next.values; + } else if (isParamValue(value) && value.param === name) { + delete next[operand]; + removed = true; + } + } + return removed && !hasOperand(next) ? undefined : next; +} + +function hasOperand(condition: EsCondition): boolean { + return ( + condition.value !== undefined || + Boolean(condition.values?.length) || + condition.gt !== undefined || + condition.gte !== undefined || + condition.lt !== undefined || + condition.lte !== undefined || + Boolean(condition.conditions?.length) + ); +} + +function editAt( + condition: EsCondition, + path: ConditionPath, + edit: (condition: EsCondition) => EsCondition | undefined, +): EsCondition | undefined { + if (path.length === 0) return edit(condition); + const [target, ...rest] = path; + const children = condition.conditions ?? []; + const child = target === undefined ? undefined : children[target]; + if (!child) throw new Error(`condition path ${path.join(".")} does not exist`); + const edited = editAt(child, rest, edit); + return { + ...condition, + conditions: children.flatMap((child, index) => + index !== target ? [child] : edited ? [edited] : [], + ), + }; +} + +function renameParamReferences( + search: EsSearch, + oldName: string, + newName: string, +): EsSearch { + const rename = (condition: EsCondition): EsCondition => { + const next = { ...condition }; + for (const operand of operands) { + const value = condition[operand]; + if (operand === "values") { + if (condition.values) { + next.values = condition.values.map((entry) => + renamedValue(entry, oldName, newName), + ); + } + } else { + next[operand] = renamedValue(value, oldName, newName) as never; + } + } + if (condition.when === oldName) next.when = newName; + if (condition.conditions) + next.conditions = condition.conditions.map(rename); + return next; + }; + if (!search.query) return { ...search }; + return { ...search, query: rename(search.query) }; +} + +function renamedValue( + value: EsValue, + oldName: string, + newName: string, +): EsValue { + return isParamValue(value) && value.param === oldName + ? { param: newName } + : value; +} + +function syncNativeField( + params: ParamDraft[], + name: string, + field: string | undefined, +): ParamDraft[] { + const param = namedParam(params, name); + if (param.type !== "list") return params; + return params.map((candidate) => { + if (candidate.name !== name) return candidate; + const next: ParamDraft = { ...candidate, ...(field ? { field } : {}) }; + if (!field) delete next.field; + return next; + }); +} + +function syncAllNativeFields( + search: EsSearch, + params: ParamDraft[], + previousSearch: EsSearch, +): ParamDraft[] { + return params.map((param) => { + if (param.type !== "list" || !param.name) return param; + const mapping = paramMappings(search, param.name)[0]; + const wasMapped = paramMappings(previousSearch, param.name).length > 0; + if (!mapping && !wasMapped) return param; + const field = mapping?.field; + if (field === param.field) return param; + const next: ParamDraft = { ...param, ...(field ? { field } : {}) }; + if (!field) delete next.field; + return next; + }); +} + +function samePath(left: ConditionPath, right: ConditionPath): boolean { + return ( + left.length === right.length && + left.every((value, index) => value === right[index]) + ); +} diff --git a/packages/ui/src/profiles/esParamMappingPill.tsx b/packages/ui/src/profiles/esParamMappingPill.tsx new file mode 100644 index 00000000..7300e824 --- /dev/null +++ b/packages/ui/src/profiles/esParamMappingPill.tsx @@ -0,0 +1,32 @@ +/** + * The chip showing which parameter an operand is bound to. + * + * Split from esParamOperandExtension.tsx: that module exports the operand + * extension, which is not a component, and a module may not export both + * (react/only-export-components). + */ + + +export function ParamMappingPill({ + name, + label, + onClear +}: { + name: string; + label: string; + onClear: () => void; +}) { + return ( + + {name} + + + ); +} diff --git a/packages/ui/src/profiles/esParamOperandExtension.tsx b/packages/ui/src/profiles/esParamOperandExtension.tsx new file mode 100644 index 00000000..6a5f6287 --- /dev/null +++ b/packages/ui/src/profiles/esParamOperandExtension.tsx @@ -0,0 +1,83 @@ +import { ParamMappingPill } from "./esParamMappingPill"; +import { applyPostExtensions } from "../components/json-schema-form-extensions"; +import type { FieldControl, PostExtension } from "../components/json-schema-form-types"; +import { Select } from "../components/select"; +import type { ReactNode } from "react"; +import { isParamValue, type EsValue } from "./esQueryBuilderModel"; +import type { ParamDraft } from "./profileWizardModel"; + +const esParamOperandPost: PostExtension = (field, nodes, ctx) => { + if (field.schema["x-clicky-component"] !== "es-query-operand") return nodes; + const label = String(field.schema["x-es-operand-label"] ?? field.label); + const params = operandParams((ctx?.rootValue?.params ?? []) as ParamDraft[]); + const bound = boundParam(field.value); + const selector = params.length ? ( + + set({ occur: event.target.value as never })} + /> + + + , so the clause and operator controls do + * not read each other's options. */ +const selectMarkup = (html: string, ariaLabel: string): string => { + const start = html.indexOf(`aria-label="${ariaLabel}"`); + expect(start, `no select labelled ${ariaLabel}`).toBeGreaterThan(-1); + return html.slice(start, html.indexOf("", start)); +}; + +/** The operator values the rendered set({ occur: event.target.value as never })} + /> + + {info?.needsField ? ( + set({ field: next })} + options={fieldOptions(context.fields)} + placeholder="Field…" + /> + ) : null} + {info?.acceptsFields ? ( + set({ fields: next })} + options={context.fields.map((entry) => ({ + value: entry.name, + label: entry.name, + }))} + placeholder="All fields" + /> + ) : null} + + + + actions.mapParam(path, operand, name)} + onUnbindParam={(name) => actions.unmapParam(path, name)} + /> + actions.remove(path)} + /> +
+ {warning ? ( +

+ {warning} +

+ ) : null} + {advanced.length ? ( +
+ + Advanced + +
+ {advanced.map((name) => ( + )[name]} + onChange={(next) => set({ [name]: next } as Patch)} + /> + ))} + +
+
+ ) : null} +
+ ); +} + +function fieldOptions(fields: EsFieldMapping[]) { + return fields.map((field) => ({ + value: field.name, + label: field.name, + title: (field.types ?? (field.dataType ? [field.dataType] : [])).join(", "), + })); +} + +type OperatorGroup = { + label?: string; + operators: { op: string; label: string }[]; +}; + +/** + * operatorGroups splits the offered operators into the ones that suit the field + * and the ones that fight its analysis, which render behind an Advanced + * divider. The condition's own operator is always offered, even where the field + * does not suit it, so changing the field never blanks the control. + */ +function operatorGroups( + catalog: EsBuilderVocabulary["catalog"], + field: EsFieldMapping | undefined, + current: string, +): OperatorGroup[] { + const offered = operatorsForField(catalog, field); + const groups: OperatorGroup[] = []; + const plain = offered.filter((entry) => !entry.advanced); + const advanced = offered.filter((entry) => entry.advanced); + if (!offered.some((entry) => entry.op === current)) { + const info = catalog.find((entry) => entry.op === current); + advanced.unshift({ ...(info ?? { op: current, label: current, arity: "single", fieldTypes: [] }) }); + } + if (plain.length) groups.push({ operators: plain }); + if (advanced.length) groups.push({ label: "Advanced", operators: advanced }); + return groups; +} diff --git a/packages/ui/src/profiles/esQueryGroupModel.ts b/packages/ui/src/profiles/esQueryGroupModel.ts new file mode 100644 index 00000000..95717f2f --- /dev/null +++ b/packages/ui/src/profiles/esQueryGroupModel.ts @@ -0,0 +1,24 @@ +/** + * A bool or nested group of conditions. A group renders itself for a child that + * is also a group, so the tree nests to whatever depth the author builds; the + * compiler places no bound on it either. + */ + +import { + type EsBuilderVocabulary + } from "./esQueryOperators"; + +/** Whether an operator holds other conditions rather than matching a value. */ +export function isGroupOperator( + catalog: EsBuilderVocabulary["catalog"], + op: string, +): boolean { + return catalog.find((entry) => entry.op === op)?.group === true; +} + +/** The group kinds the catalog offers — bool and nested, today. */ +export function groupOperatorOptions(catalog: EsBuilderVocabulary["catalog"]) { + return catalog + .filter((entry) => entry.group) + .map((entry) => ({ value: entry.op, label: entry.label })); +} diff --git a/packages/ui/src/profiles/esQueryOccur.ts b/packages/ui/src/profiles/esQueryOccur.ts new file mode 100644 index 00000000..b1a522e4 --- /dev/null +++ b/packages/ui/src/profiles/esQueryOccur.ts @@ -0,0 +1,23 @@ +/** + * One leaf condition: which field, which operator, and the operand that + * operator takes. Which operators a field may use and which advanced qualifiers + * an operator emits both come from the schema vocabulary, so this file owns the + * editing surface and never the vocabulary itself. + */ + + +export function occurOptions(occurs: string[]) { + return occurs.map((occur) => ({ + value: occur, + label: occurLabels[occur] ?? occur + })); +} + +// How each bool clause reads to an author. filter and must both narrow, but +// only must scores, which is the distinction the raw names hide. +const occurLabels: Record = { + filter: "AND", + must: "AND (scored)", + should: "OR", + must_not: "NOT" +}; diff --git a/packages/ui/src/profiles/esQueryOperandEditors.tsx b/packages/ui/src/profiles/esQueryOperandEditors.tsx new file mode 100644 index 00000000..08174c0c --- /dev/null +++ b/packages/ui/src/profiles/esQueryOperandEditors.tsx @@ -0,0 +1,338 @@ +/** + * The operand side of a condition row: the value an operator takes, and the + * advanced qualifiers it emits alongside it. Which operand shape applies comes + * from the operator's arity, and which qualifiers exist comes from the schema — + * neither is decided here. + */ + +import { InputField } from "../components/InputField"; +import { Select } from "../components/select"; +import { useState, type ReactNode } from "react"; +import { + isParamValue, + type EsCondition, + type EsValue, +} from "./esQueryBuilderModel"; +import type { FieldValuesQuery } from "./esFieldValues"; +import type { Patch } from "./profileWizardModel"; +import type { ParamOperand } from "./esParamMappingModel"; +import { extendEsParamOperand } from "./esParamOperandExtension"; +import { + conditionOperandPatch, + multipleConditionValues, +} from "./esQueryOperandModel"; +import type { EsQualifierSchema } from "./esQueryOperators"; +import { ValueCombobox, ValuesCombobox } from "./esValueCombobox"; +import type { ParamDraft } from "./profileWizardModel"; + +// Date math a range over a date field commonly starts from. They are ordinary +// operand values that the backend resolves — this only saves the typing. +const dateMathPresets = [ + "now-15m", + "now-1h", + "now-24h", + "now-7d", + "now/d", + "now", +]; + +export function ConditionOperand({ + condition, + arity, + rowId, + params, + dateMath, + values, + set, + onBindParam, + onUnbindParam, +}: { + condition: EsCondition; + arity: string; + rowId: string; + params: ParamDraft[]; + dateMath: boolean; + /** The field's own values, when the mapping allows aggregating them. */ + values?: FieldValuesQuery; + set: (patch: Patch) => void; + onBindParam: (operand: ParamOperand, name: string) => void; + onUnbindParam: (name: string) => void; +}) { + if (arity === "none" || arity === "group") return null; + if (arity === "multiple") { + return ( + { + if (isParamValue(next)) return onBindParam("values", next.param); + const bound = boundParam(multipleConditionValues(condition)); + if (next === undefined && bound) return onUnbindParam(bound); + if (!Array.isArray(next)) + throw new Error("multiple operand requires a value list"); + set(conditionOperandPatch({ arity: "multiple", values: next })); + }} + /> + ); + } + if (arity === "range") { + return ( + + {(["gte", "lte"] as const).map((bound) => ( + { + if (isParamValue(next)) return onBindParam(bound, next.param); + const mapped = boundParam(condition[bound]); + if (next === undefined && mapped) return onUnbindParam(mapped); + set( + conditionOperandPatch({ arity: "range", bound, value: next }), + ); + }} + /> + ))} + + ); + } + return ( + { + if (isParamValue(next)) return onBindParam("value", next.param); + const bound = boundParam(condition.value); + if (next === undefined && bound) return onUnbindParam(bound); + set(conditionOperandPatch({ arity: "single", value: next })); + }} + /> + ); +} + +function ValueInput({ + id, + label, + value, + params, + presets, + lookup, + onChange, +}: { + id: string; + label: string; + value: EsValue; + params: ParamDraft[]; + presets: string[]; + lookup?: FieldValuesQuery; + onChange: (next: EsValue) => void; +}) { + let node: ReactNode; + if (lookup) { + node = ( + onChange(next === "" ? undefined : next)} + /> + ); + } else { + const listId = presets.length ? `es-presets-${id}` : undefined; + node = ( + <> + onChange(next === "" ? undefined : next)} + /> + {listId ? ( + + {presets.map((preset) => ( + + ) : null} + + ); + } + return extendEsParamOperand({ + label, + value, + onChange, + node, + params, + }); +} + +function ValuesInput({ + values, + params, + lookup, + onChange, +}: { + values: EsValue[]; + params: ParamDraft[]; + lookup?: FieldValuesQuery; + onChange: (values: EsValue[] | EsValue | undefined) => void; +}) { + const [draft, setDraft] = useState(""); + const bound = values.filter(isParamValue); + const literals = values.filter((value) => !isParamValue(value)); + let node: ReactNode; + if (lookup) { + node = ( + onChange([...next, ...bound])} + /> + ); + } else { + const commit = () => { + const parsed = draft + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean); + if (!parsed.length) return; + onChange([...literals, ...parsed, ...bound]); + setDraft(""); + }; + const removeAt = (index: number) => + onChange([ + ...literals.filter((_value, position) => position !== index), + ...bound, + ]); + node = ( + + {literals.map((value, index) => ( + + {String(value)} + + + ))} + { + if (event.key !== "Enter" && event.key !== ",") return; + event.preventDefault(); + commit(); + }} + onBlur={commit} + /> + + ); + } + return extendEsParamOperand({ + label: "value", + value: values, + onChange, + node, + params, + }); +} + +function boundParam(value: EsValue | EsValue[]): string | undefined { + const values = Array.isArray(value) ? value : [value]; + return values.find(isParamValue)?.param; +} + +export function QualifierInput({ + name, + schema, + value, + onChange, +}: { + name: string; + schema: EsQualifierSchema; + value: unknown; + onChange: (next: unknown) => void; +}) { + const label = schema.title ?? name; + if (schema.type === "boolean") { + const checked = + value === undefined ? schema.default === true : value === true; + return ( + + ); + } + if (schema.enum?.length) { + // Select renders `placeholder` as a disabled option, which would trap the + // qualifier once set. An explicit empty entry is what clears it again. + return ( + + + setSource({ enabled: event.target.checked ? undefined : false }) + } + /> + Return _source + + + {total.enabled ? ( + setTotal({ threshold: parseCount(next) })} + /> + ) : null} + + {source.enabled === false ? null : ( +
+ + setSource({ includes: includes.length ? includes : undefined }) + } + /> + + setSource({ excludes: excludes.length ? excludes : undefined }) + } + /> +
+ )} + + ); +} + +/** + * A list of _source field patterns. They are patterns rather than field names — + * `user.*` is the point of them — so this stays free text rather than a picker. + */ +function PatternList({ + label, + values, + onChange, +}: { + label: string; + values: string[]; + onChange: (values: string[]) => void; +}) { + const [draft, setDraft] = useState(""); + const commit = () => { + const parsed = draft + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean); + if (!parsed.length) return; + onChange([...values, ...parsed]); + setDraft(""); + }; + return ( +
+ {label} + {values.map((value, index) => ( + + {value} + + + ))} + { + if (event.key !== "Enter" && event.key !== ",") return; + event.preventDefault(); + commit(); + }} + onBlur={commit} + /> +
+ ); +} diff --git a/packages/ui/src/profiles/esQueryOutputEditors.test.tsx b/packages/ui/src/profiles/esQueryOutputEditors.test.tsx new file mode 100644 index 00000000..b7b19365 --- /dev/null +++ b/packages/ui/src/profiles/esQueryOutputEditors.test.tsx @@ -0,0 +1,171 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import { EsQuerySortEditor } from "./esQuerySortEditor"; +import { moveSortEntry } from "./esQuerySortModel"; +import { EsQueryOutputEditor } from "./esQueryOutputEditor"; +import { parseCount, pruneEmpty } from "./esQueryOutputModel"; +import type { EsSearch, EsSortBy } from "./esQueryBuilderModel"; +import type { EsFieldMapping } from "./esQueryOperators"; + +const fields: EsFieldMapping[] = [ + { name: "@timestamp", dataType: "date", searchable: true, aggregatable: true }, + { name: "message", dataType: "text", searchable: true, aggregatable: false }, + { name: "level", dataType: "keyword", searchable: true, aggregatable: true }, +]; + +const renderSort = (sort: EsSortBy[]) => + renderToStaticMarkup( + undefined} + />, + ); + +/** The opening tag of the element carrying `ariaLabel`, attributes and all. */ +const openingTag = (html: string, ariaLabel: string): string => { + const at = html.indexOf(`aria-label="${ariaLabel}"`); + expect(at, `no element labelled ${ariaLabel}`).toBeGreaterThan(-1); + return html.slice(at, html.indexOf(">", at)); +}; + +const renderOutput = (search: EsSearch) => + renderToStaticMarkup( + undefined} />, + ); + +describe("reordering sort entries", () => { + const sort: EsSortBy[] = [ + { field: "@timestamp" }, + { field: "level" }, + { field: "_score" }, + ]; + + it("swaps an entry with the one before it", () => { + expect(moveSortEntry(sort, 1, -1).map((entry) => entry.field)).toEqual([ + "level", + "@timestamp", + "_score", + ]); + }); + + it("swaps an entry with the one after it", () => { + expect(moveSortEntry(sort, 0, 1).map((entry) => entry.field)).toEqual([ + "level", + "@timestamp", + "_score", + ]); + }); + + // Wrapping would silently make the first tie-break the last, which is the + // opposite of what a click on a disabled-looking arrow should do. + it("clamps at both ends rather than wrapping", () => { + expect(moveSortEntry(sort, 0, -1)).toBe(sort); + expect(moveSortEntry(sort, 2, 1)).toBe(sort); + }); + + it("leaves the entries it did not move alone", () => { + expect(moveSortEntry(sort, 1, -1)[2]).toBe(sort[2]); + }); +}); + +describe("the sort editor", () => { + it("renders one field and order control per sort entry", () => { + const html = renderSort([ + { field: "@timestamp", order: "desc" }, + { field: "level" }, + ]); + expect(html.match(/aria-label="Sort field"/g)).toHaveLength(2); + expect(html).toContain('value="@timestamp"'); + expect(html).toContain(''); + }); + + it("says plainly when nothing sorts the hits", () => { + expect(renderSort([])).toContain("Unsorted"); + expect(renderSort([{ field: "level" }])).not.toContain("Unsorted"); + }); + + it("names each move control after the field it moves", () => { + const html = renderSort([{ field: "@timestamp" }, { field: "level" }]); + expect(html).toContain('aria-label="Move @timestamp later"'); + expect(html).toContain('aria-label="Move level earlier"'); + expect(html).toContain('aria-label="Remove level"'); + }); + + it("disables the move that would run off either end", () => { + const html = renderSort([{ field: "@timestamp" }, { field: "level" }]); + // The bare word also appears in Tailwind's disabled: variants, so this has + // to look for the attribute itself. + expect(openingTag(html, "Move @timestamp earlier")).toContain('disabled=""'); + expect(openingTag(html, "Move level later")).toContain('disabled=""'); + expect(openingTag(html, "Move @timestamp later")).not.toContain('disabled=""'); + expect(openingTag(html, "Move level earlier")).not.toContain('disabled=""'); + }); +}); + +describe("pruning an empty sub-object", () => { + it("keeps a sub-object that still says something", () => { + expect(pruneEmpty({ enabled: false })).toEqual({ enabled: false }); + expect(pruneEmpty({ includes: ["user.*"] })).toEqual({ + includes: ["user.*"], + }); + }); + + // Storing `{}` would leave a key the compiler has to ignore, so clearing the + // last field has to clear the object with it. + it("drops one whose every field was cleared", () => { + expect(pruneEmpty({ enabled: undefined, includes: [] })).toBeUndefined(); + expect(pruneEmpty({})).toBeUndefined(); + }); +}); + +describe("reading a count", () => { + it.each([ + ["25", 25], + ["0", 0], + ])("accepts %s as %i", (raw, expected) => { + expect(parseCount(raw)).toBe(expected); + }); + + it.each(["", " ", "-1", "1.5", "many"])("treats %j as unset", (raw) => { + expect(parseCount(raw)).toBeUndefined(); + }); +}); + +describe("the output editor", () => { + it("shows where the hits start and the _source controls", () => { + const html = renderOutput({ from: 20 }); + expect(html).toContain('aria-label="From"'); + expect(html).toContain('value="20"'); + expect(html).toContain('aria-label="Add includes pattern"'); + expect(html).toContain('aria-label="Add excludes pattern"'); + }); + + // How many rows come back is the query's Limit, edited beside the filters. + // A second control here would be a row cap that can disagree with the one the + // raw query also honours. + it("leaves the row count to the query's own limit", () => { + expect(renderOutput({ size: 100 })).not.toContain('aria-label="Size"'); + }); + + it("renders each stored _source pattern as a removable chip", () => { + const html = renderOutput({ source: { includes: ["user.*", "@timestamp"] } }); + expect(html.match(/es-pattern-chip/g)).toHaveLength(2); + expect(html).toContain('aria-label="Remove user.*"'); + }); + + // With _source off there is nothing to include or exclude, so the pattern + // lists would only collect values the backend never reads. + it("hides the pattern lists once _source is turned off", () => { + const html = renderOutput({ source: { enabled: false } }); + expect(html).not.toContain('aria-label="Add includes pattern"'); + }); + + it("asks for a threshold only while total hits are tracked", () => { + expect(renderOutput({})).not.toContain('aria-label="Total hits threshold"'); + expect(renderOutput({ trackTotalHits: { enabled: true } })).toContain( + 'aria-label="Total hits threshold"', + ); + }); +}); diff --git a/packages/ui/src/profiles/esQueryOutputModel.ts b/packages/ui/src/profiles/esQueryOutputModel.ts new file mode 100644 index 00000000..32d4919e --- /dev/null +++ b/packages/ui/src/profiles/esQueryOutputModel.ts @@ -0,0 +1,25 @@ +/** + * What a matching document comes back as: where the hits start, which _source + * fields travel, and whether the backend counts past its default. How many hits + * come back is the query's Limit, edited next to the filters — one row cap, so + * that it cannot disagree with itself across the form and the raw DSL. + */ + + +/** + * pruneEmpty drops a sub-object that says nothing, so clearing the last field of + * `source` removes `source` rather than storing `{}` the compiler must ignore. + */ +export function pruneEmpty(value: T): T | undefined { + const said = Object.values(value).some((entry) => + Array.isArray(entry) ? entry.length > 0 : entry !== undefined && entry !== null, + ); + return said ? value : undefined; +} + +/** parseCount reads a non-negative integer, treating anything else as unset. */ +export function parseCount(raw: string): number | undefined { + if (raw.trim() === "") return undefined; + const parsed = Number(raw); + return Number.isInteger(parsed) && parsed >= 0 ? parsed : undefined; +} diff --git a/packages/ui/src/profiles/esQueryPreview.tsx b/packages/ui/src/profiles/esQueryPreview.tsx new file mode 100644 index 00000000..d5dc8974 --- /dev/null +++ b/packages/ui/src/profiles/esQueryPreview.tsx @@ -0,0 +1,45 @@ +/** + * The DSL a specification compiles to. The server compiles it — the same code + * path a query runs through — so the preview is the query, not a re-derivation + * of it that could drift. + */ + +import type { EsCompilation } from "./esQueryCompile"; + +export function EsQueryPreview({ + compilation, + className +}: { + compilation: EsCompilation; + className?: string; +}) { + const { query, size, from, error, loading } = compilation; + return ( +
+
+

Compiled DSL

+ {size === undefined ? null : ( + + size {size} + {from ? ` · from ${from}` : ""} + + )} + {loading ? ( + compiling… + ) : null} +
+ {error ? ( +

+ {error} +

+ ) : ( +
+          {query}
+        
+ )} +
+ ); +} diff --git a/packages/ui/src/profiles/esQuerySortEditor.tsx b/packages/ui/src/profiles/esQuerySortEditor.tsx new file mode 100644 index 00000000..aaed5550 --- /dev/null +++ b/packages/ui/src/profiles/esQuerySortEditor.tsx @@ -0,0 +1,136 @@ +/** + * Multi-field sort. The order of the entries is the tie-break order the backend + * applies, so moving an entry is an edit in its own right rather than cosmetic. + */ + +import { Combobox } from "../components/Combobox"; +import { IconButton } from "../components/IconButton"; +import { InputField } from "../components/InputField"; +import { Button } from "../components/button"; +import { Select } from "../components/select"; +import { UiAdd, UiArrowDown, UiArrowUp, UiTrash } from "../icons"; +import type { EsSortBy } from "./esQueryBuilderModel"; +import { applyPatch, type Patch } from "./profileWizardModel"; +import { sortableFields, type EsFieldMapping } from "./esQueryOperators"; +import { moveSortEntry } from "./esQuerySortModel"; + +export function EsQuerySortEditor({ + sort, + fields, + orders, + onChange, +}: { + sort: EsSortBy[]; + fields: EsFieldMapping[]; + orders: string[]; + onChange: (sort: EsSortBy[]) => void; +}) { + const options = sortableFields(fields).map((name) => ({ + value: name, + label: name, + })); + const set = (index: number, patch: Patch) => + onChange( + sort.map((entry, position) => + position === index ? applyPatch(entry, patch) : entry, + ), + ); + const text = (value: string | undefined) => value ?? ""; + + return ( +
+
+

Sort

+ +
+ {sort.length === 0 ? ( +

+ Unsorted — hits come back in the backend's own order. +

+ ) : null} + {sort.map((entry, index) => ( +
+ set(index, { field: next })} + options={options} + placeholder="Field…" + allowCustomValue + /> + + ; + }, +})); + +import { ValuesCombobox } from "./esValueCombobox"; + +describe("ValuesCombobox", () => { + beforeEach(() => { + comboboxCalls.length = 0; + }); + + it("keeps an OpenSearch terms operand creatable while selecting multiple values", () => { + const onChange = vi.fn(); + renderToStaticMarkup( + + ({ values: [], total: 0, scoped: true }), + }} + values={["payments"]} + onChange={onChange} + /> + , + ); + + expect(comboboxCalls).toHaveLength(1); + expect(comboboxCalls[0]).toMatchObject({ + multiple: true, + variant: "tags", + allowCustomValue: true, + value: ["payments"], + }); + + const change = comboboxCalls[0]?.onChange as + | ((next: string[]) => void) + | undefined; + expect(change).toBeDefined(); + change?.(["payments", "custom-service"]); + expect(onChange).toHaveBeenCalledWith(["payments", "custom-service"]); + }); +}); diff --git a/packages/ui/src/profiles/esValueCombobox.tsx b/packages/ui/src/profiles/esValueCombobox.tsx new file mode 100644 index 00000000..e963e0c3 --- /dev/null +++ b/packages/ui/src/profiles/esValueCombobox.tsx @@ -0,0 +1,110 @@ +/** + * The operand controls backed by a field's real values. Typing narrows the + * terms aggregation server-side rather than filtering a fetched page, so a + * field with thousands of values stays usable; a value outside the returned + * window is still typeable, which is what `allowCustomValue` stands for. + */ + +import { Combobox } from "../components/Combobox"; +import type { ComboboxOption } from "../components/Combobox"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { useState } from "react"; +import type { FieldValuesQuery, FieldValuesResult } from "./esFieldValues"; + +function useFieldValues(lookup: FieldValuesQuery) { + const [query, setQuery] = useState(""); + const result = useQuery({ + queryKey: ["es-field-values", lookup.key, query], + queryFn: () => lookup.fetch(query), + placeholderData: keepPreviousData, + staleTime: 30_000, + retry: 0, + }); + return { ...result, onSearch: setQuery }; +} + +function valueOptions(result: FieldValuesResult | undefined): ComboboxOption[] { + return (result?.values ?? []).map((entry) => ({ + value: entry.value, + label: entry.value, + title: `${entry.count.toLocaleString()} documents`, + })); +} + +/** + * The summary explains what the list is a window onto: how much of the field's + * cardinality is shown, and whether the rest of the query narrowed it. + */ +function valueSummary( + result: FieldValuesResult | undefined, + error: Error | null, +): string | undefined { + if (error) return error.message; + if (!result) return undefined; + const scope = result.scoped ? "matching current filters" : "across the index"; + if (!result.values.length) return `no values ${scope}`; + return `${result.values.length} of ${result.total.toLocaleString()} · ${scope}`; +} + +export function ValueCombobox({ + id, + label, + lookup, + value, + onChange, +}: { + id: string; + label: string; + lookup: FieldValuesQuery; + value: string; + onChange: (next: string) => void; +}) { + const { data, error, isFetching, onSearch } = useFieldValues(lookup); + const summary = valueSummary(data, error as Error | null); + return ( + + ); +} + +export function ValuesCombobox({ + label, + lookup, + values, + onChange, +}: { + label: string; + lookup: FieldValuesQuery; + values: string[]; + onChange: (next: string[]) => void; +}) { + const { data, error, isFetching, onSearch } = useFieldValues(lookup); + const summary = valueSummary(data, error as Error | null); + return ( + + ); +} diff --git a/packages/ui/src/profiles/jsonPathSample.ts b/packages/ui/src/profiles/jsonPathSample.ts new file mode 100644 index 00000000..a4765739 --- /dev/null +++ b/packages/ui/src/profiles/jsonPathSample.ts @@ -0,0 +1,114 @@ +import type { JSONPathEvalResult as JsonPathEvalResult } from "../components/JSONPathPlayground"; +import { useQuery } from "@tanstack/react-query"; +import { createContext, useContext, useMemo } from "react"; +import { fetchJSON } from "./connectionBrowserModel"; +import { profileApiPath } from "./profileApi"; + +/** Every sampled row in scope; empty where nothing has been sampled. */ +export function useJsonPathSampleRows(): unknown[] { + return useContext(JsonPathSampleRowsContext); +} + +export function sampleRequestProfile(profile: unknown): Record | null { + if (!isRecord(profile)) return null; + const provider = profile.provider; + if (!isRecord(provider)) return null; + const type = typeof provider.type === "string" ? provider.type.trim() : ""; + if (!type) return null; + const request: Record = {}; + for (const key of SAMPLE_PROFILE_KEYS) { + if (profile[key] !== undefined) request[key] = profile[key]; + } + const name = typeof profile.profile === "string" ? profile.profile.trim() : ""; + request.profile = name || "sample"; + return request; +} + +/** + * The rows the picker browses: a read-only sample of `profile`. + * + * A profile that cannot be sampled — no provider yet, or a query the backend + * rejects — yields nothing, which leaves JSONPathField's browse button disabled + * and the path typed by hand rather than picked. + */ +export function useJsonPathSample(profile: unknown): unknown[] { + const request = useMemo(() => sampleRequestProfile(profile), [profile]); + const { data } = useQuery({ + queryKey: ["jsonpath-sample", JSON.stringify(request)], + enabled: request !== null, + // The sample is a query against someone's backend, so it is fetched once per + // profile shape and reused by every column's picker rather than re-run as + // the form re-renders. + staleTime: Infinity, + gcTime: 5 * 60 * 1000, + retry: false, + refetchOnWindowFocus: false, + queryFn: async () => { + const result = await fetchJSON<{ rows?: Record[] }>( + profileApiPath("profile/sample"), + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ profile: request, params: {} }), + }, + ); + return result.rows ?? EMPTY_ROWS; + }, + }); + return data ?? EMPTY_ROWS; +} + +/** + * Evaluates a JSONPath against one sampled row, server-side. + * + * The path has to be read by the library the query engine reads it with, or the + * preview would confidently disagree with the column it previews. The row goes + * up with the request because the caller already has it — re-running someone's + * backend query on every keystroke would make the preview cost real money. + */ +export function evaluateJsonPath(request: { + jsonpath: string; + source?: string; + row: unknown; +}): Promise { + return fetchJSON(profileApiPath("profile/sample/jsonpath"), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(request), + }); +} + +// A stable identity so a component reading "nothing sampled" does not re-render +// every parent render on a fresh []. +const EMPTY_ROWS: unknown[] = []; + +// The rows the JSONPath picker browses, for the surfaces that cannot ask for +// them themselves. +// +// The entity form's extension reads the profile straight off the form root and +// samples it directly. The standalone profile editor renders its column +// inspector far from the draft it is editing, so that page samples once here and +// every column field reads the result — one request per editor rather than one +// per column, and no query client needed at the leaf. +export const JsonPathSampleRowsContext = createContext(EMPTY_ROWS); + +// The keys /profile/sample accepts. It decodes with DisallowUnknownFields, so +// this is a whitelist rather than a tidy-up: one stray key and the whole request +// is a 400. +// +// `columns`, `aliases` and `ignore` are left out deliberately. Those transforms +// are the thing the author is still writing — a source column gets renamed and +// consumed by them — and the picker has to offer the provider's own row shape, +// not the shape a half-written profile projects out of it. +const SAMPLE_PROFILE_KEYS = [ + "profile", + "provider", + "query", + "params", + "imports", + "namespace", +] as const; + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} diff --git a/packages/ui/src/profiles/jsonPathSampleRow.test.ts b/packages/ui/src/profiles/jsonPathSampleRow.test.ts new file mode 100644 index 00000000..b9ebe3bb --- /dev/null +++ b/packages/ui/src/profiles/jsonPathSampleRow.test.ts @@ -0,0 +1,69 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { evaluateJsonPath, sampleRequestProfile } from "./jsonPathSample"; + +const profile = { + profile: "orders", + namespace: "default", + provider: { type: "sql", options: { url: "postgres://localhost/orders" } }, + query: "SELECT payload FROM orders", + params: [{ name: "since", type: "string" }], + columns: [{ name: "email", source: "payload", jsonpath: "$.user.email" }], + aliases: [{ name: "user", cel: "row.payload.user" }], + ignore: ["payload"], + render: "logs", +}; + +describe("sampleRequestProfile", () => { + it("keeps only the keys the sample endpoint accepts", () => { + // /profile/sample decodes with DisallowUnknownFields, so anything extra — + // `render` here — turns the whole request into a 400. + expect(sampleRequestProfile(profile)).toEqual({ + profile: "orders", + namespace: "default", + provider: { type: "sql", options: { url: "postgres://localhost/orders" } }, + query: "SELECT payload FROM orders", + params: [{ name: "since", type: "string" }], + }); + }); + + it("drops the transforms so the raw provider row is sampled", () => { + const request = sampleRequestProfile(profile)!; + + expect(request).not.toHaveProperty("columns"); + expect(request).not.toHaveProperty("aliases"); + expect(request).not.toHaveProperty("ignore"); + }); + + it("names an unnamed draft so the handler accepts it", () => { + const request = sampleRequestProfile({ ...profile, profile: " " })!; + + expect(request.profile).toBe("sample"); + }); + + it("declines a profile with no provider to sample", () => { + expect(sampleRequestProfile({ query: "SELECT 1" })).toBeNull(); + expect(sampleRequestProfile({ provider: { type: "" }, query: "SELECT 1" })).toBeNull(); + expect(sampleRequestProfile(undefined)).toBeNull(); + }); +}); + +describe("evaluateJsonPath", () => { + afterEach(() => vi.unstubAllGlobals()); + + it("sends the expression, its root and the row the caller is already holding", async () => { + const response = { matches: ["OPEN"], count: 1, filterField: "payload.status" }; + const fetchMock = vi.fn(async () => new Response(JSON.stringify(response), { + status: 200, + headers: { "Content-Type": "application/json" }, + })); + vi.stubGlobal("fetch", fetchMock); + + const row = { payload: '{"status":"OPEN"}' }; + await expect(evaluateJsonPath({ jsonpath: "$.status", source: "payload", row })).resolves.toEqual(response); + + const [url, init] = fetchMock.mock.calls[0]!; + expect(url).toBe("/api/v1/profile/sample/jsonpath"); + expect(init?.method).toBe("POST"); + expect(JSON.parse(init?.body as string)).toEqual({ jsonpath: "$.status", source: "payload", row }); + }); +}); diff --git a/packages/ui/src/profiles/jsonPathSampleRow.tsx b/packages/ui/src/profiles/jsonPathSampleRow.tsx new file mode 100644 index 00000000..b3086d43 --- /dev/null +++ b/packages/ui/src/profiles/jsonPathSampleRow.tsx @@ -0,0 +1,18 @@ +import { type ReactNode } from "react"; +import { JsonPathSampleRowsContext, useJsonPathSample } from "./jsonPathSample"; + +export function JsonPathProfileProvider({ + profile, + children +}: { + profile: unknown; + children: ReactNode; +}) { + const rows = useJsonPathSample(profile); + return ( + + {children} + + ); +} + diff --git a/packages/ui/src/profiles/profileApi.ts b/packages/ui/src/profiles/profileApi.ts new file mode 100644 index 00000000..e466f01e --- /dev/null +++ b/packages/ui/src/profiles/profileApi.ts @@ -0,0 +1,80 @@ +/** + * Boot-time configuration: where the profile engine is mounted, and the schema + * that describes a profile. + * + * Both are the host's to supply. The components talk to commons-db's profile + * service — sampling a draft, evaluating a JSONPath against a sampled row, + * browsing a connection's catalog — and every host mounts that service + * somewhere; a library that hardcodes one host's prefix is broken for the next. + * The schema is generated from commons-db's Go types, so it ships with the + * server, not with this package: vendoring a copy here would silently drift + * from the source of truth the server validates against. + * + * This is module state rather than a React context on purpose: both values are + * one per application, fixed before the first render, and read from plain + * functions (browserBaseUrl, profileSchemaProjection) as well as components. + * Threading a context through the whole tree would buy nothing a single + * boot-time call does not. + */ + +import type { JsonSchemaObject } from "../components/json-schema-form-types"; +import { stripTrailingSlashes } from "../lib/string"; + +/** ProfileSchema is commons-db's profile.json, with its $defs preserved. */ +export type ProfileSchema = JsonSchemaObject & { + $defs?: Record; +}; + +const DEFAULT_BASE = "/api/v1"; + +let base = DEFAULT_BASE; +let schema: ProfileSchema | null = null; + +/** + * configureProfiles points the components at a mount and gives them the profile + * schema. Call it once at startup, before rendering. + * + * basePath defaults to /api/v1 and may be omitted by a host that mounts there. + * schema has no default: the editor cannot describe a profile it has no schema + * for, and inventing one would disagree with the server that validates it. + */ +export function configureProfiles(options: { basePath?: string; schema: ProfileSchema }): void { + if (options.basePath !== undefined) { + const trimmed = stripTrailingSlashes(options.basePath.trim()); + if (!trimmed.startsWith("/")) { + throw new Error( + `profile API basePath must start with "/", got ${JSON.stringify(options.basePath)}`, + ); + } + base = trimmed; + } + schema = options.schema; +} + +/** profileApiBase is the configured mount point, without a trailing slash. */ +export function profileApiBase(): string { + return base; +} + +/** + * profileApiPath joins a service-relative path onto the mount point, e.g. + * profileApiPath("profile/sample") -> "/api/v1/profile/sample". + */ +export function profileApiPath(suffix: string): string { + return `${base}/${suffix.replace(/^\/+/, "")}`; +} + +/** + * profileSchema is the configured schema. It throws rather than returning an + * empty document: a form rendered from a missing schema shows no fields at all, + * which reads as "this profile has nothing to configure" instead of as the + * setup error it is. + */ +export function profileSchema(): ProfileSchema { + if (!schema) { + throw new Error( + "profile schema is not configured — call configureProfiles({ schema }) before rendering the profile editor", + ); + } + return schema; +} diff --git a/packages/ui/src/profiles/profileBuilder.test.ts b/packages/ui/src/profiles/profileBuilder.test.ts new file mode 100644 index 00000000..3ef19aed --- /dev/null +++ b/packages/ui/src/profiles/profileBuilder.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { profileBuilderModalClassName } from "./profileBuilderWorkspace"; +import { mapTimestampColumn, profileColumnTypeLabel } from "./profileColumnModel"; + +describe("Build Profile workspace layout", () => { + it("bounds the modal body and delegates scrolling to its panes", () => { + expect(profileBuilderModalClassName).toContain("h-[calc(100dvh-2rem)]"); + // The body must shrink and stop owning scroll, and its panes must share the + // height. Asserted as utilities because a stylesheet shipped alongside the + // library silently does nothing in a consumer that never imports it. + expect(profileBuilderModalClassName).toContain( + "[&>[data-slot=modal-body]]:min-h-0", + ); + expect(profileBuilderModalClassName).toContain( + "[&>[data-slot=modal-body]]:overflow-hidden", + ); + expect(profileBuilderModalClassName).toContain( + "[&>[data-slot=modal-body]>*]:flex-1", + ); + }); +}); + +describe("Build Profile timestamp mapping", () => { + it("marks exactly one sampled column as the timestamp date-range column", () => { + expect( + mapTimestampColumn( + [ + { name: "created_at", type: "string" }, + { name: "updated_at", type: "datetime", kind: "timestamp" }, + ], + "created_at", + ), + ).toEqual([ + { name: "created_at", type: "datetime", kind: "timestamp" }, + { name: "updated_at", type: "datetime" }, + ]); + }); +}); + +describe("Build Profile structured type labels", () => { + it("uses readable labels without changing serialized values", () => { + expect(profileColumnTypeLabel("key_value")).toBe("KeyValue{}"); + expect(profileColumnTypeLabel("key_values")).toBe("[]KeyValue"); + expect(profileColumnTypeLabel("json")).toBe("JSON"); + expect(profileColumnTypeLabel("duration")).toBe("duration"); + }); +}); diff --git a/packages/ui/src/profiles/profileBuilder.tsx b/packages/ui/src/profiles/profileBuilder.tsx new file mode 100644 index 00000000..c28e4f41 --- /dev/null +++ b/packages/ui/src/profiles/profileBuilder.tsx @@ -0,0 +1,87 @@ +import { Button } from "../components/button"; +import { Icon } from "../data/Icon"; +import { UiDatabase } from "../icons"; +import { + createContext, + useContext, + useEffect, + useRef, + useState, + type ReactNode +} from "react"; +import { savedConnectionID } from "./connectionBrowserModel"; +import { + ProfileBuilderWorkspace, + type ProfileDraft +} from "./profileBuilderWorkspace"; + +const ProfileBuilderAutoOpenContext = createContext(false); + +export function ProfileBuilderAutoOpen({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +export function ProfileQueryBuilderField({ + input, + rootValue, + onRootChange +}: { + input: ReactNode; + rootValue: ProfileDraft; + onRootChange?: ((next: Record) => void) | undefined; +}) { + const [open, setOpen] = useState(false); + const autoOpen = useContext(ProfileBuilderAutoOpenContext); + const autoOpened = useRef(false); + const connection = rootValue.provider?.connection ?? ""; + const connectionID = savedConnectionID(connection); + + useEffect(() => { + if (!autoOpen || autoOpened.current || !connectionID || !onRootChange) { + return; + } + autoOpened.current = true; + setOpen(true); + }, [autoOpen, connectionID, onRootChange]); + + return ( +
+ {input} +
+ + {!connectionID ? ( + + Choose a saved connection to browse its catalog and sample rows. + Inline URLs can still be configured manually. + + ) : null} +
+ {open && connectionID && onRootChange ? ( + setOpen(false)} + /> + ) : null} +
+ ); +} diff --git a/packages/ui/src/profiles/profileBuilderExtension.tsx b/packages/ui/src/profiles/profileBuilderExtension.tsx new file mode 100644 index 00000000..dda69a53 --- /dev/null +++ b/packages/ui/src/profiles/profileBuilderExtension.tsx @@ -0,0 +1,32 @@ +/** + * The JSON-schema form extension that opens the profile builder for a query + * field. + * + * It lives apart from profileBuilder.tsx because it is not a component, and a + * module that exports components must export nothing else for Fast Refresh to + * work (react/only-export-components). + */ + +import type { PostExtension } from "../components/json-schema-form-types"; +import { ProfileQueryBuilderField } from "./profileBuilder"; +import type { ProfileDraft } from "./profileBuilderWorkspace"; + +const profileQueryBuilderPost: PostExtension = (field, nodes, ctx) => { + if (field.schema["x-clicky-component"] !== "profile-query-builder") { + return nodes; + } + return { + label: nodes.label, + value: ( + + ), + }; +}; + +export const profileBuilderFormExtensions = { + post: [profileQueryBuilderPost], +}; diff --git a/packages/ui/src/profiles/profileBuilderWorkspace.tsx b/packages/ui/src/profiles/profileBuilderWorkspace.tsx new file mode 100644 index 00000000..40e98cc2 --- /dev/null +++ b/packages/ui/src/profiles/profileBuilderWorkspace.tsx @@ -0,0 +1,428 @@ +import { JsonSchemaForm } from "../components/JsonSchemaForm"; +import { Button } from "../components/button"; +import type { JsonSchemaObject, JsonSchemaProperty } from "../components/json-schema-form-types"; +import { Icon } from "../data/Icon"; +import type { QueryBrowserResult } from "../data/query-browser/QueryBrowser.types"; +import { Modal } from "../overlay/Modal"; +import { UiCheck, UiColumns, UiSqlColumn } from "../icons"; +import { useQuery } from "@tanstack/react-query"; +import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; +import { + browserBaseUrl, + fetchJSON, + mergeProviderOptions, + useInspection, + type BrowserDescriptor, + type ProfileRowLimits, +} from "./connectionBrowserModel"; +import { ConnectionQueryWorkspace } from "./connectionQueryWorkspace"; +import type { EsSearch } from "./esQueryBuilderModel"; +import { + ColumnPicker, + type ProfileColumn, +} from "./profileColumnPicker"; +import { withProfileLimits, type ParamDraft, type ProfileProvider } from "./profileWizardModel"; +import { defaultParamValues, paramRoles } from "./esQueryBuilderForm"; +import { mapTimestampColumn } from "./profileColumnModel"; + +// Same story as ProfileColumn: one ProfileProvider, defined with the draft +// model. The copy here had drifted to carry `role`, which the canonical type's +// index signature already admits. +export type { ProfileProvider }; + +export type ProfileDraft = Record & { + profile?: string; + query?: string; + provider?: ProfileProvider; + params?: ParamDraft[]; + columns?: ProfileColumn[]; + /** The row caps this profile sets for itself; unset ones take their default. */ + limits?: ProfileRowLimits; +}; + +type SampleResult = QueryBrowserResult & { + columns: ProfileColumn[]; + renderedQuery: string; +}; + +// Modal's body is a flex child. It must be allowed to shrink and must not own +// scrolling, otherwise QueryBrowser's intrinsic minimum height expands the +// whole workspace and pushes the editor/results below the dialog viewport. +// +// These are utilities rather than a stylesheet on purpose: a library CSS asset +// only reaches a consumer that remembers to import it, whereas Tailwind scans +// this source and folds the rules into the dist/styles.css every consumer +// already loads. +export const profileBuilderModalClassName = + "h-[calc(100dvh-2rem)] [&>[data-slot=modal-body]]:flex [&>[data-slot=modal-body]]:min-h-0 [&>[data-slot=modal-body]]:overflow-hidden [&>[data-slot=modal-body]>*]:flex-1 [&>[data-slot=modal-body]>*]:h-auto [&>[data-slot=modal-body]>*]:min-h-0"; + +export function ProfileBuilderWorkspace({ + connectionID, + rootValue, + onApply, + onClose, +}: { + connectionID: string; + rootValue: ProfileDraft; + onApply: (next: Record) => void; + onClose: () => void; +}) { + const baseUrl = browserBaseUrl(connectionID); + const descriptor = useQuery({ + queryKey: ["profile-builder-descriptor", connectionID], + queryFn: () => fetchJSON(baseUrl), + retry: 0, + }); + const initialProviderOptions = useMemo( + () => ({ ...rootValue.provider?.options }), + [rootValue.provider?.options], + ); + const [query, setQuery] = useState(rootValue.query ?? ""); + const [search, setSearch] = useState( + () => initialProviderOptions.search as EsSearch | undefined, + ); + const [params, setParams] = useState( + () => rootValue.params ?? [], + ); + const [liveOptions, setLiveOptions] = useState>( + initialProviderOptions, + ); + const [catalogOptions, setCatalogOptions] = useState>( + {}, + ); + const [sampleParams, setSampleParams] = useState>( + () => defaultParamValues(params), + ); + const [sampleColumns, setSampleColumns] = useState([]); + const [selectedColumns, setSelectedColumns] = useState>( + () => new Set(), + ); + const [timestampColumn, setTimestampColumn] = useState( + () => + rootValue.columns?.find((column) => column.kind === "timestamp")?.name ?? + "", + ); + const [selectedDatabase, setSelectedDatabase] = useState(""); + const [limits, setLimits] = useState( + () => rootValue.limits, + ); + + useEffect(() => { + if (!query && descriptor.data?.defaultQuery) { + setQuery(descriptor.data.defaultQuery); + } + }, [descriptor.data?.defaultQuery, query]); + + const explicitTargetKind = + liveOptions.targetKind ?? initialProviderOptions.targetKind; + const inspection = useInspection({ + cacheKey: "profile-builder-inspection", + id: connectionID, + baseUrl, + enabled: descriptor.data?.catalog === true, + database: selectedDatabase, + fallbackDatabase: String(initialProviderOptions.database ?? ""), + target: String(liveOptions.index ?? initialProviderOptions.index ?? ""), + ...(typeof explicitTargetKind === "string" + ? { targetKind: explicitTargetKind } + : {}), + }); + const browserOptions = useMemo( + () => + mergeProviderOptions({ + layers: [ + descriptor.data?.initialOptions, + initialProviderOptions, + catalogOptions, + ], + database: inspection.sqlDatabase, + keepTargetKind: true, + }), + [ + catalogOptions, + descriptor.data?.initialOptions, + initialProviderOptions, + inspection.sqlDatabase, + ], + ); + // The specification is authored here, not merged from a layer, so it is + // stamped on last — including its absence, which a lower layer would + // otherwise reinstate after the author switched back to raw DSL. + const effectiveOptions = useCallback( + (options: Record) => { + const merged = mergeProviderOptions({ + layers: [initialProviderOptions, catalogOptions, options], + database: inspection.sqlDatabase, + }); + if (search) merged.search = search; + else delete merged.search; + return merged; + }, + [catalogOptions, initialProviderOptions, inspection.sqlDatabase, search], + ); + + const paramSchema = useMemo(() => sampleParamSchema(params), [params]); + const existingColumns = rootValue.columns ?? []; + const existingNames = useMemo( + () => new Set(existingColumns.map((column) => column.name)), + [existingColumns], + ); + + const applyDraft = (mode: "query" | "merge" | "replace") => { + const chosen = mapTimestampColumn( + sampleColumns.filter((column) => selectedColumns.has(column.name)), + timestampColumn, + ); + let columns = existingColumns; + if (mode === "merge") { + columns = mapTimestampColumn( + [ + ...existingColumns, + ...chosen.filter((column) => !existingNames.has(column.name)), + ], + timestampColumn, + ); + } else if (mode === "replace") { + if ( + existingColumns.length > 0 && + !window.confirm( + `Replace ${existingColumns.length} configured column${existingColumns.length === 1 ? "" : "s"}?`, + ) + ) { + return; + } + columns = chosen; + } + const next: ProfileDraft = withProfileLimits( + { + ...rootValue, + query, + params, + provider: { + ...rootValue.provider, + options: effectiveOptions(liveOptions), + }, + ...(mode === "query" ? {} : { columns }), + }, + limits, + ); + onApply(next); + onClose(); + }; + + const footer = ( +
+ + + + +
+ ); + + return ( + +
+ {Object.keys(paramSchema.properties ?? {}).length > 0 ? ( +
+
+ Temporary sample parameters (not saved) +
+ +
+ ) : null} + {descriptor.isLoading ? ( + Loading connection browser… + ) : descriptor.isError ? ( + + {errorMessage( + descriptor.error, + "Unable to load this connection browser", + )} + + ) : descriptor.data ? ( + { + setSearch(transition.search); + setQuery(transition.query); + }} + {...(limits ? { limits } : {})} + onLimitsChange={setLimits} + params={params} + onParamMappingChange={(edit) => { + setSearch(edit.search); + setParams(edit.params); + }} + paramValues={sampleParams} + paramRoles={paramRoles(params)} + compileBaseUrl={baseUrl} + className="h-full min-h-0" + onCatalogSelect={(node) => { + if (node.query) setQuery(node.query); + const nextOptions = node.options ?? {}; + setCatalogOptions(nextOptions); + setLiveOptions({ ...browserOptions, ...nextOptions }); + }} + execute={async (request) => { + const result = await fetchJSON( + "/api/v1/profile/sample", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + profile: { + ...rootValue, + params, + profile: rootValue.profile || "sample", + query: request.query, + provider: { + ...rootValue.provider, + options: effectiveOptions(request.options), + }, + }, + params: sampleParams, + ...(request.pagination + ? { pagination: request.pagination } + : {}), + ...(request.debug ? { debug: true } : {}), + }), + }, + ); + setSampleColumns(result.columns ?? []); + setSelectedColumns( + new Set((result.columns ?? []).map((column) => column.name)), + ); + return result; + }} + renderResults={({ defaultView }) => ( +
+
{defaultView}
+ {sampleColumns.length > 0 ? ( + + ) : null} +
+ )} + /> + ) : ( + + This saved connection does not expose a query browser. + + )} +
+
+ ); +} + +function WorkspaceMessage({ + children, + error = false, +}: { + children: ReactNode; + error?: boolean; +}) { + return ( +
+ {children} +
+ ); +} + +function sampleParamSchema(params: ParamDraft[]): JsonSchemaObject { + const properties: Record = {}; + const required: string[] = []; + for (const param of params) { + const name = param.name?.trim(); + if (!name) continue; + const property: JsonSchemaProperty = { + title: param.label || name, + ...(param.description ? { description: param.description } : {}), + ...(param.default !== undefined ? { default: param.default } : {}), + }; + switch (param.type) { + case "number": + property.type = "number"; + break; + case "boolean": + property.type = "boolean"; + break; + case "date": + property.type = "string"; + property.format = "date-time"; + break; + default: + property.type = "string"; + } + if (param.options?.length) property.enum = param.options; + properties[name] = property; + if (param.required) required.push(name); + } + return { + type: "object", + properties, + ...(required.length ? { required } : {}), + }; +} + +function errorMessage(error: unknown, fallback: string): string { + return error instanceof Error && error.message.trim() + ? error.message.trim() + : fallback; +} diff --git a/packages/ui/src/profiles/profileColumnFilter.test.ts b/packages/ui/src/profiles/profileColumnFilter.test.ts new file mode 100644 index 00000000..cf4badbb --- /dev/null +++ b/packages/ui/src/profiles/profileColumnFilter.test.ts @@ -0,0 +1,112 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { createElement } from "react"; +import { describe, expect, it } from "vitest"; +import { ProfileFieldEditorForm } from "./profileFieldEditor"; +import { + inferredFilterKind, + patchColumnFilter, + patchProfileField, + PROFILE_FILTER_DEFAULT_LIMIT, + type ProfileColumn, +} from "./profileWizardModel"; + +describe("patchColumnFilter", () => { + it("merges one knob without disturbing the others", () => { + expect(patchColumnFilter({ kind: "terms", limit: 10 }, { multi: false })).toEqual({ + kind: "terms", + limit: 10, + multi: false, + }); + }); + + // The distinction the server reads: an absent block means "infer this", an + // empty one would mean "override it with nothing". + it("drops the block once the last knob is cleared", () => { + expect(patchColumnFilter({ limit: 10 }, { limit: undefined })).toBeUndefined(); + }); + + it("drops a block that was never anything", () => { + expect(patchColumnFilter(undefined, { field: undefined })).toBeUndefined(); + }); + + it("creates the block on the first knob set", () => { + expect(patchColumnFilter(undefined, { limit: 25 })).toEqual({ limit: 25 }); + }); + + // false and 0 are values an author chose; only undefined means "unset". + it("keeps a knob deliberately turned off", () => { + expect(patchColumnFilter(undefined, { lookup: false })).toEqual({ lookup: false }); + }); +}); + +describe("inferredFilterKind", () => { + it.each([ + ["number", "range"], + ["duration", "range"], + ["bytes", "range"], + ["datetime", "time"], + ["boolean", "boolean"], + ["json", "none"], + ["key_values", "none"], + ["string", "terms"], + ["status", "terms"], + [undefined, "terms"], + ])("reads %s as %s, matching the server", (type, expected) => { + expect(inferredFilterKind({ name: "c", ...(type ? { type } : {}) })).toBe(expected); + }); +}); + +describe("the filter block survives editing the rest of the column", () => { + it("is untouched by a label edit", () => { + const column: ProfileColumn = { + name: "tenant", + type: "string", + filter: { limit: 10, lookup: true }, + }; + expect(patchProfileField(column, { label: "Tenant" }).filter).toEqual({ + limit: 10, + lookup: true, + }); + }); +}); + +describe("the column inspector", () => { + const render = (field: ProfileColumn) => + renderToStaticMarkup(createElement(ProfileFieldEditorForm, { field, onChange: () => {} })); + + it("offers the lookup limit for a value selection", () => { + const markup = render({ name: "tenant", type: "string" }); + + expect(markup).toContain("Values offered"); + // Blank means the server's default, so the placeholder has to name it. + expect(markup).toContain(`placeholder="${PROFILE_FILTER_DEFAULT_LIMIT}"`); + expect(markup).toContain(`top ${PROFILE_FILTER_DEFAULT_LIMIT}`); + }); + + // A range is typed rather than picked, so a cap on a list it does not have + // would be a control with nothing behind it. + it("offers no lookup limit for a range", () => { + expect(render({ name: "latency_ms", type: "number" })).not.toContain("Values offered"); + }); + + it("shows a declared limit in the collapsed summary", () => { + expect(render({ name: "tenant", type: "string", filter: { limit: 7 } })).toContain("top 7"); + }); + + it("reports a filter turned off without claiming a control", () => { + const markup = render({ name: "tenant", type: "string", filter: { disabled: true } }); + expect(markup).toContain(">off<"); + }); + + // Enumerated values are the answer a lookup would fetch, so the two cannot + // both be on — and a disabled checkbox says so better than a silent override. + it("disables the lookup toggle once values are listed", () => { + const markup = render({ + name: "tenant", + type: "string", + filter: { options: ["prod", "dev"] }, + }); + expect(markup).toContain("Values are listed above"); + expect(markup).toContain("prod, dev"); + }); +}); diff --git a/packages/ui/src/profiles/profileColumnModel.ts b/packages/ui/src/profiles/profileColumnModel.ts new file mode 100644 index 00000000..1f265b24 --- /dev/null +++ b/packages/ui/src/profiles/profileColumnModel.ts @@ -0,0 +1,34 @@ + +// One ProfileColumn, defined where the draft model is. The picker used to keep +// a narrower copy of the same shape; two definitions of one concept drifted +// apart unnoticed while they lived in separate modules and only collided once +// the package exported both. +import type { ProfileColumn } from "./profileWizardModel"; + +export function profileColumnTypeLabel(type?: string) { + return type ? (PROFILE_COLUMN_TYPE_LABELS[type] ?? type) : "string"; +} + +/** + * mapTimestampColumn marks the chosen column as the profile's time range and + * clears the mark from whichever column previously held it. + */ +export function mapTimestampColumn( + columns: ProfileColumn[], + timestampColumn: string, +): ProfileColumn[] { + return columns.map((column) => { + if (column.name === timestampColumn) { + return { ...column, type: "datetime", kind: "timestamp" }; + } + if (column.kind !== "timestamp") return column; + const { kind: _kind, ...rest } = column; + return rest; + }); +} + +const PROFILE_COLUMN_TYPE_LABELS: Record = { + key_value: "KeyValue{}", + key_values: "[]KeyValue", + json: "JSON" +}; diff --git a/packages/ui/src/profiles/profileColumnPicker.tsx b/packages/ui/src/profiles/profileColumnPicker.tsx new file mode 100644 index 00000000..462f13c0 --- /dev/null +++ b/packages/ui/src/profiles/profileColumnPicker.tsx @@ -0,0 +1,82 @@ +import { Icon } from "../data/Icon"; +import { UiSqlColumn } from "../icons"; + +// One ProfileColumn, defined where the draft model is. The picker used to keep +// a narrower copy of the same shape; two definitions of one concept drifted +// apart unnoticed while they lived in separate modules and only collided once +// the package exported both. +import type { ProfileColumn } from "./profileWizardModel"; +import { profileColumnTypeLabel } from "./profileColumnModel"; +export type { ProfileColumn }; + + +export function ColumnPicker({ + columns, + selected, + existing, + onChange, + timestampColumn, + onTimestampColumnChange, +}: { + columns: ProfileColumn[]; + selected: Set; + existing: Set; + onChange: (next: Set) => void; + timestampColumn: string; + onTimestampColumnChange: (next: string) => void; +}) { + return ( +
+
+ + Columns from sample +
+
+ {columns.map((column) => ( +
+ + + {existing.has(column.name) ? ( + + configured + + ) : null} +
+ ))} +
+
+ ); +} + diff --git a/packages/ui/src/profiles/profileEditor.tsx b/packages/ui/src/profiles/profileEditor.tsx new file mode 100644 index 00000000..e140ac9d --- /dev/null +++ b/packages/ui/src/profiles/profileEditor.tsx @@ -0,0 +1,472 @@ +import { Button } from "../components/button"; +import { Workspace } from "../layout/Workspace"; +import type { WorkspacePaneSpec } from "../layout/Workspace"; +import { Modal } from "../overlay/Modal"; +import { useOperationLookupFetcher } from "../rpc/operationLookupFetcher"; +import type { ResolvedOperation } from "../rpc/types"; +import type { OperationsApiClient } from "../rpc/useOperations"; +import { UiColumns, UiListTree, UiSliders, UiTable } from "../icons"; +import { lazy, Suspense, useEffect, useMemo, useState } from "react"; +import { + cloneProfileDraft, + mergeSampledProfileColumns, + profileAdvancedKeys, + profileColumnResetState, + profileEditorSectionStatus, + profileEditorSections, + profileSampleSignature, + profileUpdateConflictTarget, + resetProfileColumns, + validateProfileEditorDraft, + type ProfileEditorSection, +} from "./profileEditorModel"; +import { ProfileEditorPreview } from "./profileEditorPreview"; +import { ProfileEditorRail } from "./profileEditorRail"; +import { + ProfileGeneralSection, + ProfileSchemaSection, + ProfileSourceSection, +} from "./profileEditorSections"; +import { + ProfileFieldEditorActions, + ProfileFieldEditorForm, + profileFieldEditorEmptyMessage, +} from "./profileFieldEditor"; +import { ProfileFieldFilters, ProfileFieldGrid } from "./profileFieldGrid"; +import { useProfileFieldState } from "./profileFieldState"; +import type { ProfileSample } from "./profileWizardQueryStep"; +import { JsonPathProfileProvider } from "./jsonPathSampleRow"; +import type { ProfileColumn, ProfileWizardDraft } from "./profileWizardModel"; +import { resolveProfileUpdatePath } from "./profileEditorRoutes"; + +const ProfileEditorRaw = lazy(() => + import("./profileEditorRaw").then((module) => ({ + default: module.ProfileEditorRaw, + })), +); + +/** + * The profile editor as a route rather than a dialog. + * + * Six sections, ~130 discoverable fields and a CEL editor per column outgrew a + * modal: the layout is a clicky-ui Workspace, so the section rail, the column + * grid, the field inspector and the sampled preview are panes the user can + * resize, collapse and keep across visits. + */ +export function ProfileEditor({ + client, + action, + surfaceKey, + initialValue, + onClose, + onSuccess, +}: { + client: OperationsApiClient; + action: ResolvedOperation; + surfaceKey: string; + initialValue: Record; + onClose: () => void; + onSuccess: (name: string) => void | Promise; +}) { + const lookupFetcher = useOperationLookupFetcher(client); + const initialDraft = useMemo(() => cloneProfileDraft(initialValue), [initialValue]); + const initialSerialized = useMemo(() => JSON.stringify(initialDraft), [initialDraft]); + const [draft, setDraft] = useState(initialDraft); + const [section, setSection] = useState("general"); + const [discovered, setDiscovered] = useState(initialDraft.columns ?? []); + const [sampledColumns, setSampledColumns] = useState([]); + const [sampleRows, setSampleRows] = useState[]>([]); + const [activeField, setActiveField] = useState(initialDraft.columns?.[0]?.name ?? ""); + const [lastSampleSignature, setLastSampleSignature] = useState(() => profileSampleSignature(initialDraft)); + const [rawValid, setRawValid] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(""); + const [replaceTarget, setReplaceTarget] = useState(""); + const [confirmDiscard, setConfirmDiscard] = useState(false); + const [confirmResetColumns, setConfirmResetColumns] = useState(false); + const dirty = JSON.stringify(draft) !== initialSerialized; + const validationError = validateProfileEditorDraft(draft); + const sampleStale = profileSampleSignature(draft) !== lastSampleSignature; + const resetState = profileColumnResetState({ + providerType: draft.provider?.type ?? "", + sampledColumnCount: sampledColumns.length, + sampleStale, + }); + + const fields = useProfileFieldState({ + discovered, + configured: draft.columns ?? [], + activeName: activeField, + onConfiguredChange: (columns) => setDraft((current) => ({ ...current, columns })), + onActiveNameChange: setActiveField, + }); + + // A route can be refreshed or navigated away from; the dialog used to guard + // unsaved edits with confirmClose, so the route has to guard them too. + useEffect(() => { + if (!dirty) return; + const warn = (event: BeforeUnloadEvent) => event.preventDefault(); + window.addEventListener("beforeunload", warn); + return () => window.removeEventListener("beforeunload", warn); + }, [dirty]); + + const save = async (replaceExisting = false) => { + if (validationError) { + setError(validationError); + return; + } + if (!client.submitForm) { + setError("Profile updates are unavailable"); + return; + } + setSaving(true); + setError(""); + try { + const body = { + ...draft, + id: surfaceKey, + ...(replaceExisting ? { replaceExisting: true } : {}), + }; + const response = await client.submitForm( + resolveProfileUpdatePath(action.path, surfaceKey), + action.method, + body, + ); + if (!response.success) { + const message = response.error || response.message || "Profile update failed"; + const target = profileUpdateConflictTarget(message); + if (target && !replaceExisting) { + setReplaceTarget(target); + return; + } + throw new Error(message); + } + await onSuccess(draft.profile!.trim()); + } catch (saveError) { + setError(saveError instanceof Error ? saveError.message : "Profile update failed"); + } finally { + setSaving(false); + } + }; + + const acceptSample = ({ columns, rows, sourceDraft }: ProfileSample) => { + setSampledColumns(structuredClone(columns)); + setDiscovered(columns); + setSampleRows(rows); + setDraft((current) => ({ + ...current, + columns: mergeSampledProfileColumns(current.columns ?? [], columns), + })); + setActiveField((current) => current || columns[0]?.name || ""); + setLastSampleSignature(profileSampleSignature(sourceDraft)); + }; + + const resetColumns = () => { + const first = sampledColumns[0]; + if (!first) throw new Error("cannot reset columns without a sample"); + setDraft((current) => resetProfileColumns(current, sampledColumns)); + setActiveField(first.name); + setConfirmResetColumns(false); + }; + + const sectionContent = ( +
+ {section === "general" ? : null} + {section === "source" ? ( + + ) : null} + {section === "parameters" ? ( + + ) : null} + {section === "advanced" ? ( + + ) : null} + {section === "raw" ? ( + Loading YAML editor…

}> + +
+ ) : null} +
+ ); + + const panes = useMemo(() => { + const rail: WorkspacePaneSpec = { + id: "sections", + label: "Profile", + icon: , + location: "left", + width: 240, + minWidth: 180, + maxWidth: 340, + content: ( + + ), + }; + + if (section !== "columns") { + return [rail, { id: "section", label: sectionLabel(section), location: "center", collapsible: false, contentClassName: "p-0", content: sectionContent }]; + } + + return [ + rail, + { + id: "columns", + label: "Columns", + icon: , + location: "center", + collapsible: false, + contentClassName: "flex flex-col overflow-hidden", + slots: { + headerTrailing: ( + <> + + {fields.configuredCount} of {fields.available.length} included + + {resetState.visible ? ( + + ) : null} + + + ), + }, + content: ( + <> +
+ +
+ + + ), + }, + { + id: "field", + label: fields.activeField?.name ?? "Field", + icon: , + location: "right", + width: 380, + minWidth: 300, + maxWidth: 560, + contentClassName: "flex flex-col overflow-hidden", + // The pane header already names the field, so this composes the editor's + // parts rather than nesting its carded, self-titling variant. + content: fields.activeField ? ( + <> +
+ { + if (fields.activeField) fields.setFieldSelection(fields.activeField, selected); + }} + canMoveUp={fields.canMoveUp} + canMoveDown={fields.canMoveDown} + onMoveUp={() => fields.moveActive(-1)} + onMoveDown={() => fields.moveActive(1)} + onRemove={fields.removeActive} + /> +
+
+ +
+ + ) : ( +

+ {profileFieldEditorEmptyMessage} +

+ ), + }, + { + id: "preview", + label: "Preview", + icon: , + location: "bottom", + height: 220, + minHeight: 120, + maxHeight: 520, + slots: { + headerTrailing: ( + + {sampleRows.length + ? `${sampleRows.length} sampled rows · updates as you edit` + : "No sample yet"} + + ), + }, + content: , + }, + ]; + }, [draft, fields, resetState, sampleRows, sampleStale, section, sectionContent]); + + const leave = () => (dirty ? setConfirmDiscard(true) : onClose()); + + return ( + +
+
+ + {error ? {error} : null} + + {dirty ? "Unsaved changes" : "No changes"} + + + +
+ + +
+ + {confirmDiscard ? ( + setConfirmDiscard(false)} + title="Discard profile changes?" + size="sm" + footer={ +
+ + +
+ } + > +

+ Your unsaved profile changes will be lost. +

+
+ ) : null} + + {confirmResetColumns ? ( + setConfirmResetColumns(false)} + title="Reset columns from latest sample?" + size="sm" + footer={ +
+ + +
+ } + > +

+ Replace {fields.configuredCount} configured column{fields.configuredCount === 1 ? "" : "s"} with {sampledColumns.length} column{sampledColumns.length === 1 ? "" : "s"} from the latest sample. Custom labels, expressions, formatting, filtering, ordering, and manually added columns will be removed. The profile will remain unsaved until you save it. +

+
+ ) : null} + + {replaceTarget ? ( + setReplaceTarget("")} + title={`Replace ${replaceTarget}?`} + size="sm" + footer={ +
+ + +
+ } + > +

+ A profile named {replaceTarget} already exists. Its definition will be + overwritten, the current profile will be renamed, and dependent imports + will be updated atomically. +

+
+ ) : null} +
+ ); +} + +function sectionLabel(section: ProfileEditorSection): string { + return profileEditorSections.find((entry) => entry.id === section)!.label; +} + diff --git a/packages/ui/src/profiles/profileEditorModel.test.ts b/packages/ui/src/profiles/profileEditorModel.test.ts new file mode 100644 index 00000000..b0e2806d --- /dev/null +++ b/packages/ui/src/profiles/profileEditorModel.test.ts @@ -0,0 +1,208 @@ +import type { JsonSchemaObject } from "../components/json-schema-form-types"; +import { beforeAll, describe, expect, it } from "vitest"; +import { configureProfiles } from "./profileApi"; +import { testProfileSchema } from "./testSchema"; +import { + mergeProfileProjection, + mergeSampledProfileColumns, + profileAdvancedKeys, + profileEditRoute, + profileEditSurfaceKey, + profileColumnResetState, + profileEditorSections, + profileRoute, + profileSampleSignature, + profileSchemaProjection, + profileUpdateConflictTarget, + resetProfileColumns, + validateProfileEditorDraft, +} from "./profileEditorModel"; + +describe("profile editor model", () => { + // The schema is the host's to supply; these tests supply a small one so the + // projection assertions below have an expected result that is obvious by inspection. + beforeAll(() => configureProfiles({ schema: testProfileSchema })); + + it("defines a custom sectioned workspace independent of the schema layout", () => { + expect(profileEditorSections.map((section) => section.id)).toEqual([ + "general", + "source", + "columns", + "parameters", + "advanced", + "raw", + ]); + }); + + it("merges projected schema fields without dropping opaque profile fields", () => { + const draft = { + profile: "OS", + params: [{ name: "host" }], + trace: { interval: "5s" }, + context: { policy: { query: "select 1" } }, + }; + + expect( + mergeProfileProjection(draft, ["params"], { + params: [{ name: "namespace" }], + }), + ).toEqual({ + profile: "OS", + params: [{ name: "namespace" }], + trace: { interval: "5s" }, + context: { policy: { query: "select 1" } }, + }); + expect(profileSchemaProjection(["params"]).properties).toHaveProperty("params"); + }); + + // Which presets `use` offers is commons-db's schema to state, and its own + // tests assert that. What matters here is that a projection reaches nested + // properties intact rather than flattening or dropping them. + it("projects the advanced section down to the schema, nested properties included", () => { + expect(profileAdvancedKeys).toContain("processors"); + + const projection = profileSchemaProjection(profileAdvancedKeys); + const processors = projection.properties?.processors as JsonSchemaObject; + const use = (processors?.items as JsonSchemaObject)?.properties + ?.use as JsonSchemaObject; + + expect(use?.enum).toEqual(["example.processor"]); + // An advanced key the schema does not declare contributes nothing, rather + // than an empty property the form would render as a blank control. + expect(projection.properties).not.toHaveProperty("aliases"); + // profile and provider are required, but neither is in this projection. + expect(projection.required).toEqual([]); + }); + + it("merges samples by name while retaining configured and missing fields", () => { + expect( + mergeSampledProfileColumns( + [ + { name: "message", type: "string", label: "Message" }, + { name: "legacy", type: "string" }, + ], + [ + { name: "message", type: "json" }, + { name: "duration", type: "duration" }, + ], + ), + ).toEqual([ + { name: "message", type: "string", label: "Message" }, + { name: "legacy", type: "string" }, + { name: "duration", type: "duration" }, + ]); + }); + + it("resets configured columns to the latest sample order and metadata", () => { + const draft = { + profile: "OS", + provider: { type: "opensearch" }, + columns: [ + { name: "message", type: "string", label: "Message", cel: "message.trim()" }, + { name: "manual", type: "boolean" }, + ], + output: { unwrap: "hits" }, + }; + const sampled = [ + { name: "duration", type: "number" }, + { name: "message", type: "json" }, + ]; + + const reset = resetProfileColumns(draft, sampled); + + expect(reset).toEqual({ + ...draft, + columns: sampled, + }); + expect(reset.columns).not.toBe(sampled); + expect(reset.columns?.[0]).not.toBe(sampled[0]); + expect(() => resetProfileColumns(draft, [])).toThrow( + "Cannot reset profile columns without sampled columns", + ); + }); + + it("offers reset only for OpenSearch with a current non-empty sample", () => { + expect( + profileColumnResetState({ + providerType: "sql", + sampledColumnCount: 2, + sampleStale: false, + }), + ).toEqual({ visible: false, disabled: true, title: "" }); + expect( + profileColumnResetState({ + providerType: "opensearch", + sampledColumnCount: 0, + sampleStale: false, + }), + ).toEqual({ + visible: true, + disabled: true, + title: "Run a sample before resetting columns", + }); + expect( + profileColumnResetState({ + providerType: "opensearch", + sampledColumnCount: 2, + sampleStale: true, + }), + ).toEqual({ + visible: true, + disabled: true, + title: "Run another sample for the current source and query", + }); + expect( + profileColumnResetState({ + providerType: "opensearch", + sampledColumnCount: 2, + sampleStale: false, + }), + ).toEqual({ + visible: true, + disabled: false, + title: "Replace configured columns with the latest sample", + }); + }); + + it("tracks source changes and validates only editor-owned invariants", () => { + const draft = { + profile: "OS", + provider: { type: "sql", connection: "connection://db" }, + query: "select 1", + columns: [{ name: "id" }], + }; + expect(profileSampleSignature(draft)).not.toBe( + profileSampleSignature({ ...draft, query: "select 2" }), + ); + expect(validateProfileEditorDraft(draft)).toBeNull(); + expect( + validateProfileEditorDraft({ + ...draft, + columns: [{ name: "id" }, { name: "id" }], + }), + ).toBe('Column name "id" is duplicated'); + }); + + it("derives rename routes and structured conflict targets", () => { + expect(profileRoute("Service Logs.v2")).toBe("/profile-service-logs-v2"); + expect( + profileUpdateConflictTarget( + 'PROFILE_NAME_CONFLICT: profile "OS" conflicts with existing profile "Linux"', + ), + ).toBe("Linux"); + }); + + it("round-trips the editor route so a refresh reopens the same profile", () => { + const surfaceKey = "profile-service-logs-v2"; + expect(profileEditRoute(surfaceKey)).toBe("/profile-service-logs-v2/edit"); + expect(profileEditSurfaceKey(profileEditRoute(surfaceKey))).toBe(surfaceKey); + expect(profileEditSurfaceKey("/profile-service-logs-v2/edit/")).toBe(surfaceKey); + }); + + it("claims only profile edit routes, leaving detail and collection paths alone", () => { + expect(profileEditSurfaceKey("/profile-os2")).toBeNull(); + expect(profileEditSurfaceKey("/profiles/edit")).toBeNull(); + expect(profileEditSurfaceKey("/connection/edit")).toBeNull(); + expect(profileEditSurfaceKey("/profile-os2/edit/columns")).toBeNull(); + }); +}); diff --git a/packages/ui/src/profiles/profileEditorModel.ts b/packages/ui/src/profiles/profileEditorModel.ts new file mode 100644 index 00000000..3f7abada --- /dev/null +++ b/packages/ui/src/profiles/profileEditorModel.ts @@ -0,0 +1,224 @@ +import type { JsonSchemaObject } from "../components/json-schema-form-types"; +import { stripSurroundingDashes } from "../lib/string"; +import { profileSchema } from "./profileApi"; +import { validateProfileParams } from "./profileParamModel"; +import type { ProfileColumn, ProfileWizardDraft } from "./profileWizardModel"; + +/** + * The schema the raw-YAML editor validates against. It is read through the + * configured accessor rather than bundled: the document is generated from + * commons-db's Go types and served by the host, so a copy here would drift. + */ +export function profileEditorSchema(): JsonSchemaObject { + return profileSchema(); +} + +export const profileEditorSections = [ + { id: "general", label: "General", hint: "Name, namespace, render mode" }, + { id: "source", label: "Source & Query", hint: "Provider, connection, sample" }, + { id: "columns", label: "Columns", hint: "Fields, labels, expressions" }, + { id: "parameters", label: "Parameters", hint: "Named query inputs" }, + { id: "advanced", label: "Advanced", hint: "Imports, aliases, processors, output" }, + { id: "raw", label: "Raw YAML", hint: "Edit the document directly" }, +] as const; + +export type ProfileEditorSection = (typeof profileEditorSections)[number]["id"]; + +export const profileAdvancedKeys = ["imports", "aliases", "ignore", "processors", "output"]; + +export type ProfileSectionStatus = { + badge?: string | undefined; + attention?: boolean | undefined; +}; + +/** + * Rail annotations per section. The route replaced tabs with a vertical rail, + * which has room to say how much each section holds — so a stale sample or an + * empty column set is visible without opening the section. + */ +export function profileEditorSectionStatus({ + draft, + availableColumns, + sampleStale, +}: { + draft: ProfileWizardDraft; + availableColumns: number; + sampleStale: boolean; +}): Record { + const configured = draft.columns?.length ?? 0; + const params = Array.isArray(draft.params) ? draft.params.length : 0; + const advanced = profileAdvancedKeys.filter((key) => + Object.prototype.hasOwnProperty.call(draft, key), + ).length; + return { + general: { attention: !draft.profile?.trim() }, + source: { + badge: draft.provider?.type || undefined, + attention: sampleStale || !draft.provider?.type?.trim(), + }, + columns: { + badge: `${configured}/${Math.max(availableColumns, configured)}`, + attention: configured === 0, + }, + parameters: { badge: params ? String(params) : undefined }, + advanced: { badge: advanced ? String(advanced) : undefined }, + raw: {}, + }; +} + +export function cloneProfileDraft( + value: Record, +): ProfileWizardDraft { + return structuredClone(value) as ProfileWizardDraft; +} + +export function profileSchemaProjection(keys: string[]): JsonSchemaObject { + const properties = Object.fromEntries( + keys.flatMap((key) => { + const property = profileSchema().properties?.[key]; + return property ? [[key, property]] : []; + }), + ); + return { + type: "object", + properties, + required: (profileSchema().required ?? []).filter((key) => keys.includes(key)), + }; +} + +export function providerOptionsSchema(providerType: string): JsonSchemaObject { + const definition = profileSchema().$defs?.[providerType]; + const options = definition?.properties?.options; + if (!options || options.type !== "object") { + return { type: "object", properties: {}, additionalProperties: true }; + } + return options as JsonSchemaObject; +} + +export function providerTypes(): string[] { + const values = profileSchema().properties?.provider?.properties?.type?.enum; + return Array.isArray(values) + ? values.filter((value): value is string => typeof value === "string") + : []; +} + +export function mergeProfileProjection( + draft: ProfileWizardDraft, + keys: string[], + next: Record, +): ProfileWizardDraft { + const merged = { ...draft }; + for (const key of keys) delete merged[key]; + for (const key of keys) { + if (Object.prototype.hasOwnProperty.call(next, key)) merged[key] = next[key]; + } + return merged; +} + +export function mergeSampledProfileColumns( + configured: ProfileColumn[], + sampled: ProfileColumn[], +): ProfileColumn[] { + const configuredNames = new Set(configured.map((column) => column.name)); + return [ + ...configured.map((column) => ({ ...column })), + ...sampled + .filter((column) => !configuredNames.has(column.name)) + .map((column) => ({ ...column })), + ]; +} + +export function resetProfileColumns( + draft: ProfileWizardDraft, + sampled: ProfileColumn[], +): ProfileWizardDraft { + if (sampled.length === 0) { + throw new Error("Cannot reset profile columns without sampled columns"); + } + return { ...draft, columns: structuredClone(sampled) }; +} + +export function profileColumnResetState({ + providerType, + sampledColumnCount, + sampleStale, +}: { + providerType: string; + sampledColumnCount: number; + sampleStale: boolean; +}) { + if (providerType !== "opensearch") { + return { visible: false, disabled: true, title: "" }; + } + if (sampledColumnCount === 0) { + return { + visible: true, + disabled: true, + title: "Run a sample before resetting columns", + }; + } + if (sampleStale) { + return { + visible: true, + disabled: true, + title: "Run another sample for the current source and query", + }; + } + return { + visible: true, + disabled: false, + title: "Replace configured columns with the latest sample", + }; +} + +export function profileSampleSignature(draft: ProfileWizardDraft): string { + return JSON.stringify({ + provider: draft.provider ?? {}, + query: draft.query ?? "", + }); +} + +export function validateProfileEditorDraft( + draft: ProfileWizardDraft, +): string | null { + if (!draft.profile?.trim()) return "Profile name is required"; + if (!draft.provider?.type?.trim()) return "Provider type is required"; + const names = new Set(); + for (const column of draft.columns ?? []) { + const name = column.name.trim(); + if (!name) return "Every column needs a name"; + if (names.has(name)) return `Column name "${name}" is duplicated`; + names.add(name); + } + return validateProfileParams(draft.params, draft.provider?.type); +} + +export function profileUpdateConflictTarget(error: string): string | null { + if (!error.includes("PROFILE_NAME_CONFLICT")) return null; + return error.match(/existing profile "([^"]+)"/)?.[1] ?? null; +} + +export function profileRoute(name: string): string { + const slug = stripSurroundingDashes( + Array.from(name.trim().toLowerCase()) + .map((character) => + /[a-z0-9]/.test(character) + ? character + : /[ ._/-]/.test(character) + ? "-" + : "", + ) + .join(""), + ); + return `/profile-${slug}`; +} + +/** Deep-linkable editor route for a profile surface (`profile-os2`). */ +export function profileEditRoute(surfaceKey: string): string { + return `/${surfaceKey}/edit`; +} + +/** Surface key an edit route addresses, or null when the path is not one. */ +export function profileEditSurfaceKey(pathname: string): string | null { + return pathname.match(/^\/(profile-[^/]+)\/edit\/?$/)?.[1] ?? null; +} diff --git a/packages/ui/src/profiles/profileEditorPreview.tsx b/packages/ui/src/profiles/profileEditorPreview.tsx new file mode 100644 index 00000000..543b810f --- /dev/null +++ b/packages/ui/src/profiles/profileEditorPreview.tsx @@ -0,0 +1,71 @@ +import type { ProfileColumn } from "./profileWizardModel"; + +/** + * The configured columns applied to the rows returned by the last sample, so + * the effect of including a field, relabelling it or hiding it is visible + * without leaving the editor. Rows only exist after a sample runs — there is no + * placeholder data, because a fabricated preview is worse than none. + */ +export function ProfileEditorPreview({ + columns, + rows, +}: { + columns: ProfileColumn[]; + rows: Record[]; +}) { + const shown = columns.filter((column) => !column.hidden); + + if (rows.length === 0 || shown.length === 0) { + return ( +

+ {shown.length === 0 + ? "No columns included yet." + : "Run a sample in Source & Query to preview rows."} +

+ ); + } + + return ( +
+ + + + {shown.map((column) => ( + + ))} + + + + {rows.map((row, index) => ( + + {shown.map((column) => ( + + ))} + + ))} + +
+ {column.label ?? column.name} +
+ {formatPreviewCell( + Object.prototype.hasOwnProperty.call(row, column.name) + ? row[column.name] + : column.source + ? row[column.source] + : undefined, + )} +
+
+ ); +} + +function formatPreviewCell(value: unknown): string { + if (value === null || value === undefined) return "—"; + return typeof value === "object" ? JSON.stringify(value) : String(value); +} diff --git a/packages/ui/src/profiles/profileEditorRail.tsx b/packages/ui/src/profiles/profileEditorRail.tsx new file mode 100644 index 00000000..1436b5a4 --- /dev/null +++ b/packages/ui/src/profiles/profileEditorRail.tsx @@ -0,0 +1,58 @@ +import { + profileEditorSections, + type ProfileEditorSection, + type ProfileSectionStatus, +} from "./profileEditorModel"; + +/** Vertical section nav for the editor route, in place of the modal's tabs. */ +export function ProfileEditorRail({ + value, + status, + onChange, +}: { + value: ProfileEditorSection; + status: Record; + onChange: (section: ProfileEditorSection) => void; +}) { + return ( + + ); +} diff --git a/packages/ui/src/profiles/profileEditorRaw.test.tsx b/packages/ui/src/profiles/profileEditorRaw.test.tsx new file mode 100644 index 00000000..2c7a1a4a --- /dev/null +++ b/packages/ui/src/profiles/profileEditorRaw.test.tsx @@ -0,0 +1,64 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { beforeAll, describe, expect, it, vi } from "vitest"; +import { configureProfiles } from "./profileApi"; +import { testProfileSchema } from "./testSchema"; +import { ProfileEditorRaw } from "./profileEditorRaw"; +import { parseProfileYamlDocument, profileYamlFilename } from "./profileYaml"; + +vi.mock("../monaco", () => ({ + MonacoSchemaEditor: ({ height, value }: { height?: string | number; value: string }) => ( +
+ {value} +
+ ), +})); + +const draft = { + profile: "service-logs", + provider: { type: "opensearch" }, + query: "GET service-logs/_search", +}; + +describe("raw profile YAML", () => { + // The Monaco editor validates against the host-supplied schema. + beforeAll(() => configureProfiles({ schema: testProfileSchema })); + + it("fills the editor frame and exposes YAML import and export", () => { + const html = renderToStaticMarkup( + undefined} + onValidityChange={() => undefined} + />, + ); + + expect(html).toContain("Import YAML"); + expect(html).toContain("Export YAML"); + expect(html).toContain('accept=".yaml,.yml,application/yaml,text/yaml,text/x-yaml"'); + expect(html).toContain('data-slot="profile-yaml-editor-frame"'); + expect(html).toContain("[&>[data-slot=monaco-editor]]:h-full"); + expect(html).toContain('data-height="100%"'); + }); + + it("parses an imported YAML object", () => { + expect( + parseProfileYamlDocument(` +profile: service-logs +provider: + type: opensearch +query: GET service-logs/_search +`), + ).toEqual(draft); + }); + + it("rejects an imported YAML document that is not an object", () => { + expect(() => parseProfileYamlDocument("- service-logs\n")).toThrow( + "Profile YAML must contain an object", + ); + }); + + it("uses a filesystem-safe YAML export name", () => { + expect(profileYamlFilename(" Service Logs / Prod ")).toBe("Service-Logs-Prod.yaml"); + expect(profileYamlFilename(" ")).toBe("profile.yaml"); + }); +}); diff --git a/packages/ui/src/profiles/profileEditorRaw.tsx b/packages/ui/src/profiles/profileEditorRaw.tsx new file mode 100644 index 00000000..2c71e47c --- /dev/null +++ b/packages/ui/src/profiles/profileEditorRaw.tsx @@ -0,0 +1,115 @@ +import { Button } from "../components/button"; +import { UiDownload, UiUpload } from "../icons"; +import { MonacoSchemaEditor } from "../monaco"; +import { useRef, useState, type ChangeEvent } from "react"; +import { stringify } from "yaml"; +import { profileEditorSchema } from "./profileEditorModel"; +import type { ProfileWizardDraft } from "./profileWizardModel"; +import { parseProfileYamlDocument, profileYamlFilename } from "./profileYaml"; + +export function ProfileEditorRaw({ + draft, + onChange, + onValidityChange +}: { + draft: ProfileWizardDraft; + onChange: (draft: ProfileWizardDraft) => void; + onValidityChange: (valid: boolean) => void; +}) { + const [value, setValue] = useState(() => stringify(draft)); + const [parseError, setParseError] = useState(""); + const fileInput = useRef(null); + + const updateValue = (next: string) => { + setValue(next); + try { + const parsed = parseProfileYamlDocument(next); + setParseError(""); + onValidityChange(true); + onChange(parsed); + } catch (error) { + setParseError(error instanceof Error ? error.message : "Invalid YAML"); + onValidityChange(false); + } + }; + + const importYaml = async (event: ChangeEvent) => { + const input = event.currentTarget; + const file = input.files?.[0]; + input.value = ""; + if (!file) return; + try { + updateValue(await file.text()); + } catch (error) { + setParseError( + `Unable to read ${file.name}: ${error instanceof Error ? error.message : String(error)}`, + ); + onValidityChange(false); + } + }; + + return ( +
+
+

+ Raw YAML edits the complete profile. Invalid text is kept here while you + work; returning to another tab restores the last valid structured value. + YAML comments and key ordering are not preserved after structured edits. +

+
+ void importYaml(event)} + /> + + +
+
+ {parseError ? ( +

{parseError}

+ ) : null} +
+ { + if (!parseError) onValidityChange(state.status !== "invalid"); + }} + onChange={updateValue} + /> +
+
+ ); +} + +function downloadProfileYaml(value: string, filename: string) { + const url = URL.createObjectURL(new Blob([value], { type: "application/yaml;charset=utf-8" })); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + link.click(); + URL.revokeObjectURL(url); +} + diff --git a/packages/ui/src/profiles/profileEditorRoutes.ts b/packages/ui/src/profiles/profileEditorRoutes.ts new file mode 100644 index 00000000..4f916c0b --- /dev/null +++ b/packages/ui/src/profiles/profileEditorRoutes.ts @@ -0,0 +1,4 @@ + +export function resolveProfileUpdatePath(path: string, id: string): string { + return path.replace("{id}", encodeURIComponent(id)).replace(":id", encodeURIComponent(id)); +} diff --git a/packages/ui/src/profiles/profileEditorSections.tsx b/packages/ui/src/profiles/profileEditorSections.tsx new file mode 100644 index 00000000..f6834d87 --- /dev/null +++ b/packages/ui/src/profiles/profileEditorSections.tsx @@ -0,0 +1,252 @@ +import { JsonSchemaForm } from "../components/JsonSchemaForm"; +import type { FormLayout, LookupFetcher } from "../components/json-schema-form-types"; +import { Icon } from "../data/Icon"; +import type { ReactNode } from "react"; +import { + mergeProfileProjection, + profileSchemaProjection, + providerOptionsSchema, + providerTypes, +} from "./profileEditorModel"; +import { ProfileWizardQueryStep, type ProfileSample } from "./profileWizardQueryStep"; +import { + profileConnectionID, + type ProfileColumn, + type ProfileWizardDraft, +} from "./profileWizardModel"; + +const inputClassName = + "w-full rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus:border-primary focus:ring-2 focus:ring-primary/15"; + +export function ProfileGeneralSection({ + draft, + onChange, +}: EditorSectionProps) { + return ( + +
+ + onChange({ ...draft, profile: event.target.value })} + /> + + + onChange({ ...draft, namespace: event.target.value })} + /> + + + + + +
+ onChange({ ...draft, icon: event.target.value || undefined })} + /> + {typeof draft.icon === "string" && draft.icon !== "" ? ( + // Shows what the name actually resolves to — an unresolvable name + // renders nothing anywhere, which is invisible without a preview. + + ) : null} +
+
+
+
+ ); +} + +export function ProfileSourceSection({ + draft, + discovered, + sampleStale, + onChange, + onSample, +}: EditorSectionProps & { + discovered: ProfileColumn[]; + sampleStale: boolean; + onSample: (sample: ProfileSample) => void; +}) { + const connectionID = profileConnectionID(draft.provider?.connection ?? ""); + const providerType = draft.provider?.type ?? ""; + return ( +
+ +
+ + + + + + onChange({ + ...draft, + provider: { ...draft.provider, connection: event.target.value }, + }) + } + /> + +
+ {sampleStale ? ( +

+ Source or query settings changed after the latest sample. You can save, + but sampling again will verify the current field shape. +

+ ) : null} +
+ {connectionID ? ( + + ) : ( + + +