diff --git a/.gavel.yaml b/.gavel.yaml index 30a824ef2..9e04ea2c5 100644 --- a/.gavel.yaml +++ b/.gavel.yaml @@ -23,6 +23,8 @@ lint: pre: - name: build run: pnpm --filter @flanksource/clicky-ui run build +- name: build-expressions + run: pnpm --filter @flanksource/expressions run build - name: install-storybook-browser run: pnpm --filter storybook exec playwright install chromium procfile: {} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 61b04feb2..9dde0b828 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,6 +54,8 @@ jobs: - run: vp install - name: Build library run: pnpm --filter @flanksource/clicky-ui run build + - name: Build expressions + run: pnpm --filter @flanksource/expressions run build - name: Build Storybook run: pnpm --filter storybook exec storybook build --disable-telemetry - name: Build kitchen-sink diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 28a3fbb1d..5baa46a78 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,6 +13,19 @@ jobs: runs-on: ubuntu-latest # Skip the auto-bump commit so the workflow doesn't loop on itself. if: "!startsWith(github.event.head_commit.message, 'chore(release):')" + strategy: + # Serially: each package commits its own version bump to main, and two + # jobs pushing to the same protected branch would race. + max-parallel: 1 + fail-fast: false + matrix: + include: + - name: "@flanksource/clicky-ui" + directory: packages/ui + tag: clicky-ui + - name: "@flanksource/expressions" + directory: packages/expressions + tag: expressions steps: # Authenticate as a GitHub App so the bump commit/tag can be pushed to the # protected main branch. The App must be on the branch ruleset's bypass list @@ -50,51 +63,62 @@ jobs: - name: Bump patch version id: bump - working-directory: packages/ui + working-directory: ${{ matrix.directory }} # Derive the next version from the highest version already published on # npm, not from package.json/git — they can drift when a prior release # publishes but its bump commit never lands back on main. + env: + TAG_PREFIX: ${{ matrix.tag }} run: | name=$(node -p "require('./package.json').name") - published=$(npm view "$name" versions --json \ + published=$(npm view "$name" versions --json 2>/dev/null \ | jq -r 'if type == "array" then .[] else . end' \ | sort -V | tail -1) if [ -z "$published" ]; then - echo "Could not determine the highest published version for $name" >&2 - exit 1 + # Never published. The version in package.json is the intended + # first release, so take it as-is rather than bumping past it. + new_version="v$(node -p "require('./package.json').version")" + echo "First publish of $name at ${new_version#v}" + else + echo "Highest published version: $published" + npm version "$published" --no-git-tag-version --allow-same-version >/dev/null + new_version=$(npm version patch --no-git-tag-version) fi - echo "Highest published version: $published" - npm version "$published" --no-git-tag-version --allow-same-version >/dev/null - new_version=$(npm version patch --no-git-tag-version) echo "version=${new_version#v}" >> "$GITHUB_OUTPUT" - echo "tag=clicky-ui@${new_version#v}" >> "$GITHUB_OUTPUT" + echo "tag=${TAG_PREFIX}@${new_version#v}" >> "$GITHUB_OUTPUT" - name: Build library - run: pnpm --filter @flanksource/clicky-ui run build + run: pnpm --filter ${{ matrix.name }} run build - name: Publish to npm - working-directory: packages/ui + working-directory: ${{ matrix.directory }} env: NPM_CONFIG_PROVENANCE: "true" run: pnpm publish --access public --no-git-checks - name: Commit, tag, and push env: + NAME: ${{ matrix.name }} + DIRECTORY: ${{ matrix.directory }} VERSION: ${{ steps.bump.outputs.version }} TAG: ${{ steps.bump.outputs.tag }} run: | - git add packages/ui/package.json - git commit -m "chore(release): @flanksource/clicky-ui@${VERSION}" + git add "${DIRECTORY}/package.json" + git commit -m "chore(release): ${NAME}@${VERSION}" git tag "${TAG}" + # Rebase before pushing: an earlier matrix entry may have landed its + # own bump commit on main since this job checked out. + git pull --rebase origin "${GITHUB_REF_NAME}" git push origin "HEAD:${GITHUB_REF_NAME}" git push origin "${TAG}" - name: Create GitHub Release env: GH_TOKEN: ${{ steps.app-token.outputs.token }} + NAME: ${{ matrix.name }} VERSION: ${{ steps.bump.outputs.version }} TAG: ${{ steps.bump.outputs.tag }} run: | gh release create "${TAG}" \ - --title "@flanksource/clicky-ui@${VERSION}" \ + --title "${NAME}@${VERSION}" \ --generate-notes diff --git a/.github/workflows/vendor-lang.yml b/.github/workflows/vendor-lang.yml new file mode 100644 index 000000000..05b2e8f35 --- /dev/null +++ b/.github/workflows/vendor-lang.yml @@ -0,0 +1,102 @@ +name: Vendor gomplate languages + +# The tokenizers under packages/expressions/src/lang are generated from cel-go's +# ANTLR grammar, text/template's lexer and gomplate's live function registries. +# Only a Go toolchain can read any of that, so this job runs gomplate's own +# generator and opens a PR when the result changes. +on: + schedule: + # Weekly. gomplate's function surface moves with releases, not with commits, + # so anything more frequent is just noise in the PR list. + - cron: "0 6 * * 1" + workflow_dispatch: + inputs: + ref: + description: gomplate ref to vendor from + required: false + default: main + pull_request: + # Only when the vendored tree is touched. Regenerating needs Go and a clone + # of gomplate, which is not worth paying for on every unrelated PR. + paths: + - packages/expressions/src/lang/** + - packages/expressions/VENDOR + - packages/expressions/scripts/vendor-lang.ts + +permissions: + contents: read + +jobs: + verify: + # Catches a hand-edit under src/lang, which would be silently overwritten by + # the next scheduled vendoring. + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: stable + - run: corepack enable + - name: Install Vite+ + run: curl -fsSL https://vite.plus | bash + - name: Add Vite+ to PATH + run: echo "$HOME/.vite-plus/bin" >> "$GITHUB_PATH" + - run: vp install + - name: Check the vendored tree is generated, not hand-edited + working-directory: packages/expressions + run: pnpm vendor:check + + + vendor: + if: github.event_name != 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + # A GitHub App token, matching release.yml: the default GITHUB_TOKEN + # cannot open a PR that triggers other workflows. + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.FLANKSOURCE_APP_ID }} + private-key: ${{ secrets.FLANKSOURCE_APP_SECRET }} + + - uses: actions/checkout@v4 + with: + token: ${{ steps.app-token.outputs.token }} + + - uses: actions/setup-go@v5 + with: + go-version: stable + + - run: corepack enable + - name: Install Vite+ + run: curl -fsSL https://vite.plus | bash + - name: Add Vite+ to PATH + run: echo "$HOME/.vite-plus/bin" >> "$GITHUB_PATH" + - run: vp install + + - name: Vendor + working-directory: packages/expressions + run: pnpm vendor --ref "${{ inputs.ref || 'main' }}" + + # The vendored tree is source: it has to typecheck and its tests have to + # pass before a PR is worth anyone's attention. + - name: Verify + run: pnpm --filter @flanksource/expressions run check && pnpm --filter @flanksource/expressions run test + + - name: Open a pull request + uses: peter-evans/create-pull-request@v7 + with: + token: ${{ steps.app-token.outputs.token }} + branch: vendor/gomplate-lang + title: "chore(expressions): vendor gomplate language definitions" + commit-message: "chore(expressions): vendor gomplate language definitions" + body: | + Regenerated `packages/expressions/src/lang` from gomplate. + See `packages/expressions/VENDOR` for the commit it came from. + add-paths: | + packages/expressions/src/lang + packages/expressions/VENDOR diff --git a/apps/kitchen-sink/package.json b/apps/kitchen-sink/package.json index 91cfe317b..a3dd223c7 100644 --- a/apps/kitchen-sink/package.json +++ b/apps/kitchen-sink/package.json @@ -22,13 +22,12 @@ "shiki": "catalog:" }, "devDependencies": { + "@tailwindcss/vite": "catalog:", "@testing-library/react": "catalog:", "@types/react": "catalog:", "@types/react-dom": "catalog:", "@vitejs/plugin-react": "catalog:", - "autoprefixer": "catalog:", "jsdom": "catalog:", - "postcss": "catalog:", "tailwindcss": "catalog:", "typescript": "catalog:", "vite": "catalog:", diff --git a/apps/kitchen-sink/postcss.config.js b/apps/kitchen-sink/postcss.config.js deleted file mode 100644 index 93e5b537b..000000000 --- a/apps/kitchen-sink/postcss.config.js +++ /dev/null @@ -1,6 +0,0 @@ -export default { - plugins: { - tailwindcss: { config: "./tailwind.config.ts" }, - autoprefixer: {}, - }, -}; diff --git a/apps/kitchen-sink/src/demo-catalog.tsx b/apps/kitchen-sink/src/demo-catalog.tsx index 6d4f2105a..8d36ef506 100644 --- a/apps/kitchen-sink/src/demo-catalog.tsx +++ b/apps/kitchen-sink/src/demo-catalog.tsx @@ -1,4 +1,5 @@ import type { ComponentType } from "react"; +import { AccordionListDemo } from "./demos/AccordionListDemo"; import { SwitchersDemo } from "./demos/SwitchersDemo"; import { ButtonDemo } from "./demos/ButtonDemo"; import { IconDemo } from "./demos/IconDemo"; @@ -61,6 +62,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, @@ -257,6 +260,12 @@ export const DEMO_GROUPS: DemoGroup[] = [ component: FormFieldsDemo, icon: UiForm, }, + { + id: "accordion-list", + label: "AccordionList", + component: AccordionListDemo, + icon: UiRows, + }, { id: "hierarchical-lookup", label: "Hierarchical lookup", @@ -431,6 +440,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/AccordionListDemo.tsx b/apps/kitchen-sink/src/demos/AccordionListDemo.tsx new file mode 100644 index 000000000..ca1fbfccd --- /dev/null +++ b/apps/kitchen-sink/src/demos/AccordionListDemo.tsx @@ -0,0 +1,114 @@ +import { useState } from "react"; +import { AccordionList, Badge } from "@flanksource/clicky-ui"; +import { DemoSection } from "./Section"; + +type Route = { path: string; method: string; upstream: string }; + +const ROUTES: Route[] = [ + { path: "/api/v1/users", method: "GET", upstream: "users-svc:8080" }, + { path: "/api/v1/events", method: "POST", upstream: "events-svc:8080" }, + { path: "/api/v1/health", method: "GET", upstream: "gateway:8080" }, +]; + +type Middleware = { name: string; phase: string; config: string }; + +const MIDDLEWARES: Middleware[] = [ + { name: "rate-limit", phase: "pre-auth", config: "100 req/min per token" }, + { name: "jwt-verify", phase: "auth", config: "RS256, 5m clock skew" }, + { name: "access-log", phase: "post-response", config: "json, sampled 1:10" }, +]; + +function Field({ + label, + value, + onChange, +}: { + label: string; + value: string; + onChange: (next: string) => void; +}) { + return ( + + ); +} + +export function AccordionListDemo() { + const [routes, setRoutes] = useState(ROUTES); + const [middlewares, setMiddlewares] = useState(MIDDLEWARES); + + return ( + +

+ Full editor — allowReorder allowDuplicate allowRemove plus{" "} + onCreate. Arrow keys, Home and End rove across the rows and onto the add row. +

+ + items={routes} + onChange={setRoutes} + summary={routes.length === 1 ? "1 route" : `${routes.length} routes`} + itemLabel={({ item }) => item.path || "new route"} + allowReorder + allowDuplicate + allowRemove + onCreate={() => ({ path: "", method: "GET", upstream: "" })} + addLabel="Add route" + addDescription="A route forwards one path to one upstream service." + renderHeader={({ item, index }) => ( + <> + {/* The header renders inside the disclosure button, so nothing in it + may be interactive — a copyable Badge would be a nested button. */} + + {item.method} + + + {item.path || `Route ${index + 1}`} + + {item.upstream} + + )} + renderBody={({ item, onChange }) => ( +
+ onChange({ ...item, path })} /> + onChange({ ...item, upstream })} + /> +
+ )} + /> + +

+ Reorder only — no add row, no duplicate, no delete. The same component, different opt-ins. +

+ + items={middlewares} + onChange={setMiddlewares} + allowReorder + itemLabel={({ item }) => item.name} + renderHeader={({ item }) => ( + {item.name} + )} + renderBody={({ item }) => ( +
+
Phase
+
{item.phase}
+
Config
+
{item.config}
+
+ )} + /> +
+ ); +} diff --git a/apps/kitchen-sink/src/demos/ProfilesDemo.tsx b/apps/kitchen-sink/src/demos/ProfilesDemo.tsx new file mode 100644 index 000000000..ccc248310 --- /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 000000000..738117bea --- /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 e1c27fd59..371b4d9bd 100644 --- a/apps/kitchen-sink/src/demos/examples.test.tsx +++ b/apps/kitchen-sink/src/demos/examples.test.tsx @@ -3,7 +3,10 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { afterEach, describe, expect, it } from "vitest"; import { findDemoEntry } from "../demo-catalog"; +import { AccordionListDemo } from "./AccordionListDemo"; import { HierarchicalLookupDemo } from "./HierarchicalLookupDemo"; +import { ProfilesDemo } from "./ProfilesDemo"; +import { QueryBrowserDemo } from "./QueryBrowserDemo"; import { TourDemo } from "./TourDemo"; afterEach(cleanup); @@ -14,6 +17,20 @@ describe("kitchen sink examples", () => { expect(findDemoEntry("hierarchical-lookup")?.component).toBe( HierarchicalLookupDemo, ); + expect(findDemoEntry("profiles")?.component).toBe(ProfilesDemo); + expect(findDemoEntry("query-browser")?.component).toBe(QueryBrowserDemo); + expect(findDemoEntry("accordion-list")?.component).toBe(AccordionListDemo); + }); + + it("reorders the accordion list demo through the named row action", () => { + render(); + + fireEvent.click(screen.getByRole("button", { name: "Move /api/v1/events up" })); + + const headers = screen + .getAllByRole("button") + .filter((b) => b.hasAttribute("aria-expanded")); + expect(headers[0]?.textContent).toContain("/api/v1/events"); }); it("starts the provider-managed guided tour from the demo", async () => { @@ -26,6 +43,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/src/styles.css b/apps/kitchen-sink/src/styles.css index d8e3900f8..c9ab46258 100644 --- a/apps/kitchen-sink/src/styles.css +++ b/apps/kitchen-sink/src/styles.css @@ -1,6 +1,5 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; +@config "../tailwind.config.ts"; +@import "tailwindcss"; /* The AppShell-based kitchen-sink chrome is full-height; let it fill the viewport. */ html, diff --git a/apps/kitchen-sink/tsconfig.json b/apps/kitchen-sink/tsconfig.json index 36c24ec9a..041121d38 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 d112a928f..facc78ca4 100644 --- a/apps/kitchen-sink/vite.config.ts +++ b/apps/kitchen-sink/vite.config.ts @@ -1,5 +1,6 @@ import { fileURLToPath } from "node:url"; import { dirname, resolve } from "node:path"; +import tailwindcss from "@tailwindcss/vite"; import react from "@vitejs/plugin-react"; import { defineConfig } from "vite"; @@ -8,7 +9,7 @@ const workspaceRoot = resolve(root, "../.."); const uiSrc = resolve(workspaceRoot, "packages/ui/src"); export default defineConfig({ - plugins: [react()], + plugins: [react(), tailwindcss()], resolve: { dedupe: ["react", "react-dom"], alias: [ @@ -24,6 +25,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/playground/package.json b/apps/playground/package.json index e26da217e..8af60e40c 100644 --- a/apps/playground/package.json +++ b/apps/playground/package.json @@ -14,19 +14,19 @@ "dependencies": { "@flanksource/clicky-ui": "workspace:*", "@flanksource/icons": "catalog:", + "@iconify/react": "catalog:", "monaco-editor": "catalog:", "react": "catalog:", "react-dom": "catalog:" }, "devDependencies": { + "@tailwindcss/vite": "catalog:", "@testing-library/react": "catalog:", "@types/node": "catalog:", "@types/react": "catalog:", "@types/react-dom": "catalog:", "@vitejs/plugin-react": "catalog:", - "autoprefixer": "catalog:", "jsdom": "catalog:", - "postcss": "catalog:", "react-grab": "catalog:", "tailwindcss": "catalog:", "typescript": "catalog:", diff --git a/apps/playground/postcss.config.js b/apps/playground/postcss.config.js deleted file mode 100644 index 93e5b537b..000000000 --- a/apps/playground/postcss.config.js +++ /dev/null @@ -1,6 +0,0 @@ -export default { - plugins: { - tailwindcss: { config: "./tailwind.config.ts" }, - autoprefixer: {}, - }, -}; diff --git a/apps/playground/src/styles.css b/apps/playground/src/styles.css index c172ae66b..74f4f5d26 100644 --- a/apps/playground/src/styles.css +++ b/apps/playground/src/styles.css @@ -1,6 +1,5 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; +@config "../tailwind.config.ts"; +@import "tailwindcss"; /* The AppShell-based playground chrome is full-height; let it fill the viewport. */ html, diff --git a/apps/playground/vite.config.ts b/apps/playground/vite.config.ts index f8c2654fc..798d56a52 100644 --- a/apps/playground/vite.config.ts +++ b/apps/playground/vite.config.ts @@ -1,5 +1,6 @@ import { fileURLToPath } from "node:url"; import { dirname, resolve } from "node:path"; +import tailwindcss from "@tailwindcss/vite"; import react from "@vitejs/plugin-react"; import { defineConfig } from "vite"; @@ -13,6 +14,7 @@ const uiSrc = resolve(workspaceRoot, "packages/ui/src"); export default defineConfig({ plugins: [ react(), + tailwindcss(), playgroundComments({ dir: resolve(root, ".playground") }), playgroundSources({ pagesDir: resolve(root, "src/pages") }), ], diff --git a/apps/storybook/.storybook/main.ts b/apps/storybook/.storybook/main.ts index 91c4cbe61..5e1db393f 100644 --- a/apps/storybook/.storybook/main.ts +++ b/apps/storybook/.storybook/main.ts @@ -1,4 +1,5 @@ import type { StorybookConfig } from "@storybook/react-vite"; +import tailwindcss from "@tailwindcss/vite"; const config: StorybookConfig = { stories: ["../../../packages/ui/src/**/*.stories.@(ts|tsx|mdx)"], @@ -8,7 +9,7 @@ const config: StorybookConfig = { addons: [ "@storybook/addon-docs", "@chromatic-com/storybook", - "@storybook/addon-vitest" + "@storybook/addon-vitest", ], framework: { name: "@storybook/react-vite", @@ -29,6 +30,7 @@ const config: StorybookConfig = { if (process.env.STORYBOOK_BASE_PATH) { viteConfig.base = process.env.STORYBOOK_BASE_PATH; } + viteConfig.plugins = [...(viteConfig.plugins ?? []), tailwindcss()]; // Force a single React instance across the storybook app and the linked // packages/ui source it renders (their react resolves via different // node_modules paths in the pnpm workspace) — otherwise stories hit @@ -59,6 +61,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/apps/storybook/.storybook/preview.css b/apps/storybook/.storybook/preview.css index b5c61c956..5608442e5 100644 --- a/apps/storybook/.storybook/preview.css +++ b/apps/storybook/.storybook/preview.css @@ -1,3 +1,2 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; +@config "../tailwind.config.ts"; +@import "tailwindcss"; diff --git a/apps/storybook/package.json b/apps/storybook/package.json index dc2b19397..a4fb01659 100644 --- a/apps/storybook/package.json +++ b/apps/storybook/package.json @@ -23,13 +23,12 @@ "@storybook/react": "10.3.5", "@storybook/react-vite": "10.3.5", "@storybook/test": "catalog:", + "@tailwindcss/vite": "catalog:", "@types/react": "catalog:", "@types/react-dom": "catalog:", "@vitest/browser": "3.2.6", "@vitest/coverage-v8": "3.2.6", - "autoprefixer": "catalog:", "playwright": "^1.61.0", - "postcss": "catalog:", "storybook": "10.3.5", "tailwindcss": "catalog:", "typescript": "catalog:", diff --git a/apps/storybook/postcss.config.js b/apps/storybook/postcss.config.js deleted file mode 100644 index ffd0f3b53..000000000 --- a/apps/storybook/postcss.config.js +++ /dev/null @@ -1,27 +0,0 @@ -import autoprefixer from "autoprefixer"; -import tailwindcss from "tailwindcss"; - -const tailwind = tailwindcss({ config: "./tailwind.config.ts" }); - -function shouldSkipTailwind(root) { - return root.source?.input.file?.endsWith("/packages/ui/dist/styles.css") === true; -} - -const tailwindPlugins = tailwind.plugins.map((plugin) => { - if (typeof plugin !== "function") return plugin; - const wrapped = async (root, result) => { - if (shouldSkipTailwind(root)) return; - return plugin(root, result); - }; - return wrapped; -}); - -export default { - plugins: [ - { - postcssPlugin: "tailwindcss-skip-clicky-prebuilt", - plugins: tailwindPlugins, - }, - autoprefixer(), - ], -}; diff --git a/package.json b/package.json index 8b84413a4..3ed3e1ed3 100644 --- a/package.json +++ b/package.json @@ -29,5 +29,5 @@ "engines": { "node": ">=22.12.0" }, - "packageManager": "pnpm@10.33.0" + "packageManager": "pnpm@11.10.0" } diff --git a/packages/expressions/README.md b/packages/expressions/README.md new file mode 100644 index 000000000..c42720088 --- /dev/null +++ b/packages/expressions/README.md @@ -0,0 +1,74 @@ +# @flanksource/expressions + +Monaco language support and a playground for the expression languages gomplate evaluates: CEL, Go templates (bare and embedded in YAML/JSON/text), and JSONPath. + +Two entries, so a host that only wants highlighting in its own editor does not pull React and the Monaco editor in with it: + +```ts +import { registerGomplateLanguages } from "@flanksource/expressions"; +import { ExpressionPlayground } from "@flanksource/expressions/playground"; +``` + +## Language support + +```ts +import * as monaco from "monaco-editor"; +import { registerGomplateLanguages } from "@flanksource/expressions"; + +const languages = registerGomplateLanguages(monaco); +``` + +Registers `cel`, `gomplate`, `yaml-gomplate`, `json-gomplate`, `text-gomplate` and `jsonpath` with tokenizers, completion, hover documentation and colour themes. + +With `@monaco-editor/react`, register in `beforeMount` — a model created before registration resolves to plaintext and is never revisited. + +### Your own functions + +The catalogue baked in is gomplate's. A host binary registers more on top and serves the result from its `GET /api/spec`; fold it in with `setSpec`: + +```ts +fetch("/api/spec") + .then((r) => r.json()) + .then((served) => languages.setSpec(served)); +``` + +No new grammar is involved. The tokenizers match any dotted call and dispatch on word lists — `namespaces`, `globalFunctions`, `memberFunctions`, `macros` — so `catalog.query(…)` highlights the moment `catalog` joins `namespaces`. + +### Completing the document + +Pass `environment` to complete the keys of the document being evaluated against, not just the function catalogue: + +```ts +registerGomplateLanguages(monaco, { environment: () => currentDocument }); +``` + +A getter, not a value: registration happens once, before the first editor mounts, while the document keeps being edited. + +## The playground + +```tsx +import { ExpressionPlayground } from "@flanksource/expressions/playground"; + +; +``` + +Renders the expression editor, the input document, and the Result / Object graph / Tokens / Functions panels. Deliberately shell-less — a host frames it with its own navigation. + +It talks to the Go handler in [`gomplate/playground`](https://github.com/flanksource/gomplate/tree/main/playground), which a host mounts with its own CEL options, template functions and sample documents. **That handler carries no authorization**: mount it behind the same authorization as any other query endpoint. + +## `src/lang` is generated — do not edit it + +The tokenizers come from cel-go's ANTLR grammar, `text/template`'s lexer and gomplate's live function registries, none of which is readable outside a Go toolchain. `scripts/vendor-lang.ts` clones gomplate, runs its generator, and commits the result: + +```sh +pnpm vendor # from github.com/flanksource/gomplate@main +pnpm vendor --ref v3.2.0 # from a tag +pnpm vendor --from ../../../gomplate # from a local checkout, while iterating +pnpm vendor:check # fail if the tree is stale or hand-edited +``` + +The whole `src` tree is copied, not just the generated JSON: the completion, hover and path-expression runtime changes with the generated shape, and a second copy of it here would drift within a release. `VENDOR` records the gomplate commit it came from. + +A weekly workflow re-runs the vendoring and opens a PR when it changes; a PR touching `src/lang` runs `vendor:check` against a fresh clone. + +**Fix language behaviour in gomplate, not here.** An edit under `src/lang` is overwritten by the next vendoring, and `vendor:check` fails the PR that makes one. diff --git a/packages/expressions/VENDOR b/packages/expressions/VENDOR new file mode 100644 index 000000000..68b10aea5 --- /dev/null +++ b/packages/expressions/VENDOR @@ -0,0 +1,3 @@ +# Generated by scripts/vendor-lang.ts. Do not edit src/lang by hand. +source: /Users/moshe/go/src/github.com/flanksource/gomplate +commit: b14bd92796ee2dca6ba4f9324efa602fb96923de (dirty working tree) diff --git a/packages/expressions/package.json b/packages/expressions/package.json new file mode 100644 index 000000000..dac199b70 --- /dev/null +++ b/packages/expressions/package.json @@ -0,0 +1,65 @@ +{ + "name": "@flanksource/expressions", + "version": "0.1.0", + "description": "Monaco language support and a playground for the expression languages gomplate evaluates — CEL, Go templates and JSONPath.", + "homepage": "https://github.com/flanksource/clicky-ui#readme", + "bugs": "https://github.com/flanksource/clicky-ui/issues", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/flanksource/clicky-ui.git", + "directory": "packages/expressions" + }, + "files": ["dist", "README.md", "LICENSE"], + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + }, + "./playground": { + "types": "./dist/playground.d.ts", + "import": "./dist/playground.js", + "require": "./dist/playground.cjs" + } + }, + "scripts": { + "build": "vite build && vite build --mode cjs", + "vendor": "tsx scripts/vendor-lang.ts", + "vendor:check": "tsx scripts/vendor-lang.ts --check", + "test": "vitest run", + "lint": "oxlint src scripts --deny-warnings", + "typecheck": "tsc -b --noEmit", + "check": "pnpm run typecheck && pnpm run lint" + }, + "peerDependencies": { + "@flanksource/clicky-ui": ">=0.3.19", + "monaco-editor": ">=0.48 <1", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "dependencies": { + "yaml": "catalog:" + }, + "devDependencies": { + "@flanksource/clicky-ui": "workspace:*", + "@types/node": "catalog:", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "@vitejs/plugin-react": "catalog:", + "jsdom": "catalog:", + "monaco-editor": "catalog:", + "oxlint": "catalog:", + "react": "catalog:", + "react-dom": "catalog:", + "tsx": "^4.7.0", + "typescript": "catalog:", + "vite": "catalog:", + "vite-plugin-dts": "catalog:", + "vitest": "catalog:" + } +} diff --git a/packages/expressions/scripts/vendor-lang.ts b/packages/expressions/scripts/vendor-lang.ts new file mode 100644 index 000000000..96a3f3171 --- /dev/null +++ b/packages/expressions/scripts/vendor-lang.ts @@ -0,0 +1,167 @@ +/** + * Vendors the language definitions from gomplate. + * + * The tokenizers are generated from cel-go's ANTLR grammar, `text/template`'s + * lexer and gomplate's live function registries — none of which exist outside a + * Go toolchain. Rather than reimplement any of that here, this clones gomplate, + * runs its generator, and commits the result. gomplate stays the single source + * of truth; this package is how it reaches npm, because clicky-ui is where the + * publish credentials live. + * + * The whole `src` tree is copied, not just the generated files: the completion, + * hover and path-expression runtime changes with the generated shape, and + * maintaining a second copy of it here would drift within a release. + * + * pnpm vendor # from github.com/flanksource/gomplate@main + * pnpm vendor --ref v3.2.0 # from a tag + * pnpm vendor --from ../../../gomplate # from a local checkout, while iterating + * pnpm vendor:check # fail if the tree is out of date + */ +import { execFileSync } from "node:child_process"; +import { + cpSync, + existsSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const target = join(packageRoot, "src", "lang"); +const stamp = join(packageRoot, "VENDOR"); + +const DEFAULT_REMOTE = "https://github.com/flanksource/gomplate.git"; +/** Where the generator writes, and where the runtime lives, inside gomplate. */ +const SOURCE_SUBPATH = join("web", "packages", "lang", "src"); +const GENERATED_SUBPATH = join(SOURCE_SUBPATH, "generated"); + +interface Args { + from: string; + ref: string; + check: boolean; +} + +function parseArgs(argv: string[]): Args { + const args: Args = { from: DEFAULT_REMOTE, ref: "main", check: false }; + for (let i = 0; i < argv.length; i += 1) { + const flag = argv[i]; + if (flag === "--check") args.check = true; + else if (flag === "--from") args.from = argv[(i += 1)] ?? args.from; + else if (flag === "--ref") args.ref = argv[(i += 1)] ?? args.ref; + else throw new Error(`unknown argument ${flag}`); + } + return args; +} + +function run(command: string, commandArgs: string[], cwd: string) { + execFileSync(command, commandArgs, { cwd, stdio: "inherit" }); +} + +function capture(command: string, commandArgs: string[], cwd: string) { + return execFileSync(command, commandArgs, { cwd, encoding: "utf8" }).trim(); +} + +/** Returns the checkout to generate from, and a cleanup for it. */ +function checkout(args: Args): { dir: string; cleanup: () => void } { + const local = resolve(packageRoot, args.from); + if (existsSync(join(local, "go.mod"))) { + // A local checkout is used in place, not copied: the point of --from is to + // see uncommitted generator changes before they are pushed. + console.log(`[vendor] using local checkout ${local}`); + return { dir: local, cleanup: () => {} }; + } + + const dir = mkdtempSync(join(tmpdir(), "gomplate-vendor-")); + console.log(`[vendor] cloning ${args.from}@${args.ref}`); + run("git", ["clone", "--depth", "1", "--branch", args.ref, args.from, dir], packageRoot); + return { dir, cleanup: () => rmSync(dir, { recursive: true, force: true }) }; +} + +function describe(dir: string): string { + const sha = capture("git", ["rev-parse", "HEAD"], dir); + const dirty = capture("git", ["status", "--porcelain"], dir) !== ""; + return dirty ? `${sha} (dirty working tree)` : sha; +} + +function vendor(args: Args, destination: string) { + const { dir, cleanup } = checkout(args); + try { + console.log("[vendor] running genmonarch"); + run("go", ["run", "./cmd/genmonarch", "-out", join(dir, GENERATED_SUBPATH)], dir); + + rmSync(destination, { recursive: true, force: true }); + cpSync(join(dir, SOURCE_SUBPATH), destination, { recursive: true }); + return describe(dir); + } finally { + cleanup(); + } +} + +function stampFor(source: string, args: Args) { + return [ + "# Generated by scripts/vendor-lang.ts. Do not edit src/lang by hand.", + `source: ${args.from}`, + `commit: ${source}`, + "", + ].join("\n"); +} + +const args = parseArgs(process.argv.slice(2)); + +if (!args.check) { + const source = vendor(args, target); + writeFileSync(stamp, stampFor(source, args)); + console.log(`[vendor] vendored ${source}`); +} else { + // Regenerate into a scratch directory and compare, so a hand-edit under + // src/lang or a stale vendoring fails CI rather than shipping. + const scratch = mkdtempSync(join(tmpdir(), "gomplate-vendor-check-")); + try { + const fresh = join(scratch, "lang"); + vendor(args, fresh); + + const diff = diffTrees(target, fresh); + if (diff.length > 0) { + console.error("[vendor] src/lang is out of date or has been edited by hand:"); + for (const path of diff.slice(0, 20)) console.error(` ${path}`); + if (diff.length > 20) console.error(` … and ${diff.length - 20} more`); + console.error("[vendor] run `pnpm vendor` and commit the result."); + process.exit(1); + } + console.log("[vendor] src/lang is up to date"); + } finally { + rmSync(scratch, { recursive: true, force: true }); + } +} + +/** Paths that differ between two trees, in either direction. */ +function diffTrees(a: string, b: string): string[] { + const files = new Set([...listFiles(a), ...listFiles(b)]); + const differing: string[] = []; + for (const relative of [...files].sort()) { + const left = readIfPresent(join(a, relative)); + const right = readIfPresent(join(b, relative)); + if (left !== right) differing.push(relative); + } + return differing; +} + +function listFiles(root: string, prefix = ""): string[] { + if (!existsSync(root)) return []; + const out: string[] = []; + for (const entry of readdirSync(root, { withFileTypes: true })) { + const relative = prefix ? `${prefix}/${entry.name}` : entry.name; + if (entry.isDirectory()) out.push(...listFiles(join(root, entry.name), relative)); + else out.push(relative); + } + return out; +} + +function readIfPresent(path: string): string | null { + return existsSync(path) ? readFileSync(path, "utf8") : null; +} diff --git a/packages/expressions/src/index.ts b/packages/expressions/src/index.ts new file mode 100644 index 000000000..26265c6dc --- /dev/null +++ b/packages/expressions/src/index.ts @@ -0,0 +1,9 @@ +/** + * Monaco language support for the expression languages gomplate evaluates. + * + * Everything under `./lang` is vendored from gomplate by + * `scripts/vendor-lang.ts` — the tokenizers are generated from cel-go's ANTLR + * grammar and gomplate's live registries, which only a Go toolchain can read. + * This entry is the published surface over that. + */ +export * from "./lang/index.ts"; diff --git a/packages/expressions/src/lang/attributes.ts b/packages/expressions/src/lang/attributes.ts new file mode 100644 index 000000000..e496b5fe1 --- /dev/null +++ b/packages/expressions/src/lang/attributes.ts @@ -0,0 +1,81 @@ +import type { CelSpec, GoTemplateSpec, GomplateSpec } from "./types"; +import { pathFlavour } from "./environment"; + +/** + * The Monarch word lists a tokenizer rule refers to as `@name`. + * + * The tokenizers themselves are fixed: their rules match any dotted call and + * then dispatch on these lists (`$1@namespaces`, `$1@globalFunctions`, …). So a + * host's own `catalog.query` needs no new grammar — only `catalog` in + * `namespaces` and `query` in `globalFunctions`. + * + * This mirrors the derivation in `genmonarch/lang_cel.go` and + * `lang_gotemplate.go`. `attributes.test.ts` recomputes the generated bundle + * from the generated spec and asserts the two agree, so the mirror cannot drift + * silently. + */ +export type Attributes = Record; + +/** CEL's word lists, minus `operators`, which comes from the grammar. */ +export function celAttributes(spec: CelSpec): Attributes { + const global: string[] = []; + const member: string[] = []; + for (const fn of spec.functions) { + if (fn.memberOnly) member.push(fn.name); + else global.push(leafOf(fn.name)); + } + + return { + // `true`/`false`/`null` are constants, and colouring them as keywords too + // would let whichever rule ran first decide. + keywords: spec.keywords.filter((word) => !CONSTANTS.includes(word)), + constants: [...CONSTANTS], + typeKeywords: spec.types, + macros: spec.macros.map((macro) => macro.name), + namespaces: spec.namespaces, + globalFunctions: global, + memberFunctions: member, + }; +} + +/** The go-template word lists, shared by the bare and embedded languages. */ +export function goTemplateAttributes(spec: GoTemplateSpec): Attributes { + return { + keywords: spec.keywords, + builtins: spec.builtins, + namespaces: spec.namespaces, + functions: spec.functions.filter((fn) => !fn.namespace).map((fn) => fn.name), + }; +} + +/** The word lists for one language id, or null where a spec does not drive it. */ +export function attributesFor(languageId: string, spec: GomplateSpec): Attributes | null { + switch (pathFlavour(languageId)) { + case "cel": + return normalize(celAttributes(spec.cel)); + case "gotemplate": + return normalize(goTemplateAttributes(spec.gotemplate)); + default: + // JSONPath's vocabulary is the grammar's own, with nothing to extend. + return null; + } +} + +const CONSTANTS = ["true", "false", "null"]; + +/** + * Sorted and deduplicated, matching `Language.MarshalJSON` on the Go side, so a + * recomputed list compares equal to a generated one. + */ +function normalize(attributes: Attributes): Attributes { + const out: Attributes = {}; + for (const [name, words] of Object.entries(attributes)) { + out[name] = [...new Set(words)].sort(); + } + return out; +} + +function leafOf(name: string) { + const dot = name.lastIndexOf("."); + return dot < 0 ? name : name.slice(dot + 1); +} diff --git a/packages/expressions/src/lang/completion.ts b/packages/expressions/src/lang/completion.ts new file mode 100644 index 000000000..fc9962966 --- /dev/null +++ b/packages/expressions/src/lang/completion.ts @@ -0,0 +1,250 @@ +import type * as monaco from "monaco-editor"; +import type { GomplateSpec, Monaco, SpecFunction, SpecMacro } from "./types"; +import { functionDocumentation, macroDocumentation } from "./hover"; +import { childEntries, pathExpression, pathFlavour, resolvePath } from "./environment"; +import { environmentPrefixAt } from "./prefix"; + +/** Supplies the document expressions are evaluated against, on every request. */ +export type EnvironmentSource = () => unknown; + +export interface CompletionOptions { + /** The catalogue to complete from — gomplate's, or a host's merged over it. */ + spec: GomplateSpec; + environment?: EnvironmentSource | undefined; +} + +/** + * Registers completion for one language. + * + * The catalogue half is built once per registration, so a host that supplies + * its own spec re-registers rather than mutating in place. The document half is + * rebuilt per request, because the document is being edited alongside the + * expression. + */ +export function registerCompletion( + monaco: Monaco, + languageId: string, + options: CompletionOptions, +) { + return monaco.languages.registerCompletionItemProvider( + languageId, + completionProvider(monaco, languageId, options), + ); +} + +/** + * The provider `registerCompletion` installs, exposed so it can be driven + * directly by a test over a real model rather than through Monaco's registry. + */ +export function completionProvider( + monaco: Monaco, + languageId: string, + { spec, environment }: CompletionOptions, +) { + const items = catalogueItems(monaco, languageId, spec); + return { + // A dot must retrigger, or `k8s.` offers nothing until another key is hit. + triggerCharacters: ["."], + provideCompletionItems(model: monaco.editor.ITextModel, position: monaco.Position) { + const suggestions: monaco.languages.CompletionItem[] = environmentItems( + monaco, + model, + position, + languageId, + environment, + ); + const range = wordRange(model, position); + for (const item of items) suggestions.push({ ...item, range }); + return { suggestions }; + }, + }; +} + +/** + * A completion item without its range. The range depends on the cursor, so it + * is attached per request while the rest of the item is built once. + */ +type Item = Omit; + +function catalogueItems(monaco: Monaco, languageId: string, spec: GomplateSpec): Item[] { + switch (pathFlavour(languageId)) { + case "cel": + return celCompletionItems(monaco, spec); + case "gotemplate": + return goTemplateCompletionItems(monaco, spec); + default: + // JSONPath's dialect has no catalogue to generate from; its completions + // come entirely from the document. + return []; + } +} + +/** + * The keys reachable from the cursor's position in the document. + * + * Each item replaces the whole path typed so far with a freshly rendered one, + * rather than appending to it, so what lands in the editor is always valid in + * that language — quoting an awkward key, or dropping the suggestion entirely + * where the language cannot express the path. + */ +function environmentItems( + monaco: Monaco, + model: monaco.editor.ITextModel, + position: monaco.Position, + languageId: string, + environment: EnvironmentSource | undefined, +): monaco.languages.CompletionItem[] { + if (!environment) return []; + const document = environment(); + if (document === undefined || document === null) return []; + + const prefix = environmentPrefixAt(model, position, languageId); + if (!prefix) return []; + + const parent = resolvePath(document, prefix.segments); + if (parent === undefined) return []; + + const kinds = monaco.languages.CompletionItemKind; + const range = { + startLineNumber: position.lineNumber, + endLineNumber: position.lineNumber, + startColumn: prefix.startColumn, + endColumn: prefix.endColumn, + }; + + // Monaco filters a candidate against the model text from the range start to + // the cursor, so `filterText` has to continue what was typed rather than + // repeat the rendered path: `pod.items[0]` does not match `pod.items.`, and + // the subscripted keys would be filtered straight back out. + const typedHead = prefix.typed.slice(0, prefix.typed.length - prefix.leaf.length); + + const items: monaco.languages.CompletionItem[] = []; + for (const entry of childEntries(parent)) { + const insertText = pathExpression(languageId, [...prefix.segments, entry.segment]); + if (insertText === null) continue; + items.push({ + label: entry.key, + kind: entry.container ? kinds.Folder : kinds.Field, + insertText, + filterText: `${typedHead}${entry.key}`, + detail: `${entry.kind} · ${entry.summary}`, + // A key of the document being evaluated beats any catalogue entry: it is + // what the author came to the editor to write. + sortText: `0${entry.key}`, + range, + }); + } + return items; +} + +function celCompletionItems(monaco: Monaco, spec: GomplateSpec) { + const kinds = monaco.languages.CompletionItemKind; + const items: Item[] = []; + + for (const fn of spec.cel.functions) { + items.push(buildFunctionItem(monaco, fn, celInsertText(fn), kinds.Function)); + } + for (const macro of spec.cel.macros) { + items.push(buildMacroItem(monaco, macro)); + } + for (const keyword of spec.cel.keywords) { + items.push({ + label: keyword, + kind: kinds.Keyword, + insertText: keyword, + detail: "CEL keyword", + }); + } + for (const type of spec.cel.types) { + items.push({ label: type, kind: kinds.TypeParameter, insertText: type, detail: "CEL type" }); + } + return items; +} + +function goTemplateCompletionItems(monaco: Monaco, spec: GomplateSpec) { + const kinds = monaco.languages.CompletionItemKind; + const items: Item[] = []; + + for (const fn of spec.gotemplate.functions) { + items.push(buildFunctionItem(monaco, fn, fn.name, kinds.Function)); + } + for (const builtin of spec.gotemplate.builtins) { + items.push({ + label: builtin, + kind: kinds.Function, + insertText: builtin, + detail: "text/template builtin", + }); + } + for (const keyword of spec.gotemplate.keywords) { + items.push({ + label: keyword, + kind: kinds.Keyword, + insertText: keyword, + detail: "template keyword", + }); + } + return items; +} + +function buildFunctionItem( + monaco: Monaco, + fn: SpecFunction, + insertText: string, + kind: monaco.languages.CompletionItemKind, +): Item { + // A function with neither a Go signature nor an overload has no detail to + // show; the key has to be absent rather than explicitly undefined. + const detail = fn.signature ?? fn.overloads?.[0]?.result; + return { + label: fn.name, + kind: fn.memberOnly ? monaco.languages.CompletionItemKind.Method : kind, + insertText, + ...(detail === undefined ? {} : { detail }), + documentation: { value: functionDocumentation(fn) }, + filterText: fn.name, + // Un-namespaced names first: they are the short, common ones, and a + // namespace is easy to reach by typing its prefix. + sortText: fn.namespace ? `2${fn.name}` : `1${fn.name}`, + }; +} + +function buildMacroItem(monaco: Monaco, macro: SpecMacro): Item { + return { + label: macro.name, + kind: monaco.languages.CompletionItemKind.Keyword, + insertText: macro.name, + detail: `macro (${macro.argCount === 0 ? "variadic" : `${macro.argCount} args`})`, + documentation: { value: macroDocumentation(macro) }, + filterText: macro.name, + sortText: `1${macro.name}`, + }; +} + +/** + * CEL functions take parentheses; a member-only function is offered without a + * leading dot because the dot is already typed when completion fires. + */ +function celInsertText(fn: SpecFunction) { + const arity = fn.overloads?.[0]?.args.length ?? 0; + const receiver = fn.overloads?.[0]?.member ? 1 : 0; + return arity - receiver === 0 ? `${fn.name}()` : `${fn.name}(`; +} + +function wordRange( + model: { + getWordUntilPosition(p: { lineNumber: number; column: number }): { + startColumn: number; + endColumn: number; + }; + }, + position: { lineNumber: number; column: number }, +) { + const word = model.getWordUntilPosition(position); + return { + startLineNumber: position.lineNumber, + endLineNumber: position.lineNumber, + startColumn: word.startColumn, + endColumn: word.endColumn, + }; +} diff --git a/packages/expressions/src/lang/environment.ts b/packages/expressions/src/lang/environment.ts new file mode 100644 index 000000000..2f480d53d --- /dev/null +++ b/packages/expressions/src/lang/environment.ts @@ -0,0 +1,167 @@ +/** + * Introspection of the document an expression is evaluated against. + * + * Deliberately free of Monaco and of any UI dependency: the same functions back + * editor completion and any caller that needs to render a document's shape. + */ + +/** One step of a path. A number indexes a list, a string keys a map. */ +export type PathSegment = string | number; + +/** The JSON shape of a value, as a name a reader recognises. */ +export type ValueKind = "string" | "number" | "boolean" | "null" | "object" | "array"; + +/** One child of a container, as completion and shape views need it. */ +export interface EnvironmentEntry { + /** The key or index, as typed. */ + key: string; + segment: PathSegment; + kind: ValueKind; + /** A short rendering of the value, for a detail column. */ + summary: string; + /** Whether the value has children of its own. */ + container: boolean; +} + +const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/; + +/** Whether `key` can be written as a bare `.key` rather than a subscript. */ +export function isIdentifier(key: string): boolean { + return IDENTIFIER.test(key); +} + +export function kindOf(value: unknown): ValueKind { + if (value === null || value === undefined) return "null"; + if (Array.isArray(value)) return "array"; + switch (typeof value) { + case "string": + return "string"; + case "number": + return "number"; + case "boolean": + return "boolean"; + default: + return "object"; + } +} + +const SUMMARY_LIMIT = 32; + +/** A one-line rendering of a value: the sample a completion detail shows. */ +export function summarize(value: unknown): string { + switch (kindOf(value)) { + case "null": + return "null"; + case "array": + return plural((value as unknown[]).length, "item"); + case "object": + return plural(Object.keys(value as Record).length, "key"); + case "string": { + const text = value as string; + return text.length > SUMMARY_LIMIT ? `${JSON.stringify(text.slice(0, SUMMARY_LIMIT))}…` : JSON.stringify(text); + } + default: + return String(value); + } +} + +function plural(count: number, noun: string): string { + return `${count} ${noun}${count === 1 ? "" : "s"}`; +} + +/** + * Walks `segments` from the root. + * + * A numeric segment indexes a list and nothing else; a string segment keys a + * map. Returns undefined as soon as a step does not apply, so a half-typed path + * simply yields no completions rather than throwing. + */ +export function resolvePath(environment: unknown, segments: readonly PathSegment[]): unknown { + let current = environment; + for (const segment of segments) { + if (typeof segment === "number") { + if (!Array.isArray(current)) return undefined; + current = current[segment]; + continue; + } + if (!isRecord(current)) return undefined; + current = current[segment]; + } + return current; +} + +/** + * The children of a container, in document order. + * + * A list yields its indices rather than the keys of its first element: no + * language here lets a field follow a list without an index, so offering + * `containers.name` would complete an expression that cannot evaluate. + */ +export function childEntries(value: unknown): EnvironmentEntry[] { + if (Array.isArray(value)) { + return value.map((element, index) => ({ + key: String(index), + segment: index, + kind: kindOf(element), + summary: summarize(element), + container: isContainer(element), + })); + } + if (!isRecord(value)) return []; + return Object.entries(value).map(([key, child]) => ({ + key, + segment: key, + kind: kindOf(child), + summary: summarize(child), + container: isContainer(child), + })); +} + +function isContainer(value: unknown): boolean { + const kind = kindOf(value); + return kind === "object" || kind === "array"; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * Renders a path in one language's syntax. + * + * The single place path syntax is spelled out, so completion and click-to-insert + * cannot drift apart. Returns null when the path has no expression in that + * language — a go-template reaches a list element or an awkward key through + * `index`, which is a call rather than a path. + */ +export function pathExpression(languageId: string, segments: readonly PathSegment[]): string | null { + const flavour = pathFlavour(languageId); + if (!flavour) return null; + if (flavour === "gotemplate") { + if (segments.some((segment) => typeof segment === "number" || !isIdentifier(segment))) { + return null; + } + return segments.length === 0 ? "." : segments.map((segment) => `.${String(segment)}`).join(""); + } + + const root = flavour === "jsonpath" ? "$" : ""; + let out = root; + for (const segment of segments) { + if (typeof segment === "number") { + out += `[${segment}]`; + } else if (isIdentifier(segment)) { + out += out === "" ? segment : `.${segment}`; + } else { + out += `[${JSON.stringify(segment)}]`; + } + } + return out; +} + +/** Which path syntax a language id uses. */ +export function pathFlavour(languageId: string): "cel" | "gotemplate" | "jsonpath" | null { + if (languageId === "cel") return "cel"; + if (languageId === "jsonpath") return "jsonpath"; + if (languageId === "gomplate" || languageId.endsWith("-gomplate")) return "gotemplate"; + return null; +} diff --git a/packages/expressions/src/lang/generated/cel.config.json b/packages/expressions/src/lang/generated/cel.config.json new file mode 100644 index 000000000..a7a104b89 --- /dev/null +++ b/packages/expressions/src/lang/generated/cel.config.json @@ -0,0 +1,68 @@ +{ + "comments": { + "lineComment": "//", + "blockComment": [ + "", + "" + ] + }, + "brackets": [ + [ + "{", + "}" + ], + [ + "[", + "]" + ], + [ + "(", + ")" + ] + ], + "autoClosingPairs": [ + { + "open": "{", + "close": "}" + }, + { + "open": "[", + "close": "]" + }, + { + "open": "(", + "close": ")" + }, + { + "open": "\"", + "close": "\"" + }, + { + "open": "'", + "close": "'" + } + ], + "surroundingPairs": [ + { + "open": "{", + "close": "}" + }, + { + "open": "[", + "close": "]" + }, + { + "open": "(", + "close": ")" + }, + { + "open": "\"", + "close": "\"" + }, + { + "open": "'", + "close": "'" + } + ], + "wordPattern": "[A-Za-z_][A-Za-z0-9_]*(?:\\.[A-Za-z_][A-Za-z0-9_]*)*" +} diff --git a/packages/expressions/src/lang/generated/cel.monarch.json b/packages/expressions/src/lang/generated/cel.monarch.json new file mode 100644 index 000000000..b3d9986fb --- /dev/null +++ b/packages/expressions/src/lang/generated/cel.monarch.json @@ -0,0 +1,566 @@ +{ + "defaultToken": "", + "tokenPostfix": ".cel", + "brackets": [ + { + "open": "{", + "close": "}", + "token": "delimiter.curly" + }, + { + "open": "[", + "close": "]", + "token": "delimiter.square" + }, + { + "open": "(", + "close": ")", + "token": "delimiter.parenthesis" + } + ], + "constants": [ + "false", + "null", + "true" + ], + "globalFunctions": [ + "ASCII", + "Abs", + "Add", + "Age", + "Alpha", + "AlphaNum", + "Append", + "Assert", + "Base", + "CSV", + "CSVByColumn", + "CSVByRow", + "Ceil", + "Clean", + "Contains", + "ContainsCIDR", + "Dict", + "Dir", + "Div", + "Duration", + "Ext", + "Fail", + "Find", + "FindAll", + "Float", + "Floor", + "FromSlash", + "GetHealth", + "GetStatus", + "Has", + "HashUUID", + "Hour", + "HumanDuration", + "HumanSize", + "InTimeRange", + "IsAbs", + "IsFloat", + "IsHealthy", + "IsInt", + "IsKind", + "IsNum", + "IsReady", + "IsValid", + "IsValidIP", + "Item", + "Join", + "Kind", + "Match", + "Microsecond", + "Millisecond", + "Minute", + "Mul", + "Nanosecond", + "Nil", + "Now", + "Number", + "Parse", + "ParseDateTime", + "ParseDuration", + "ParseInLocation", + "ParseLocal", + "Pow", + "Prepend", + "QuoteMeta", + "Rel", + "Rem", + "Replace", + "ReplaceLiteral", + "Required", + "Round", + "SHA1", + "SHA1Bytes", + "SHA224", + "SHA224Bytes", + "SHA256", + "SHA256Bytes", + "SHA384", + "SHA384Bytes", + "SHA512", + "SHA512Bytes", + "SHA512_224", + "SHA512_224Bytes", + "SHA512_256", + "SHA512_256Bytes", + "Second", + "Semver", + "SemverCompare", + "Seq", + "Since", + "Sort", + "Split", + "SplitN", + "String", + "Sub", + "TOML", + "Ternary", + "ToSlash", + "Unix", + "Until", + "V1", + "V4", + "VolumeName", + "YAML", + "YAMLArray", + "ZoneName", + "ZoneOffset", + "abs", + "age", + "arnToMap", + "bitAnd", + "bitNot", + "bitOr", + "bitShiftLeft", + "bitShiftRight", + "bitXor", + "bool", + "bytes", + "ceil", + "coalesce", + "contains", + "containsFloat", + "cpuAsMillicores", + "debug", + "decode", + "double", + "duration", + "dyn", + "encode", + "equivalent", + "f", + "first", + "floor", + "fromAWSMap", + "getHealth", + "getResourcesLimit", + "getResourcesRequests", + "getStatus", + "in", + "in_business_hours", + "int", + "intersects", + "isFinite", + "isHealthy", + "isInf", + "isNaN", + "isReady", + "isURL", + "is_healthy", + "jmespath", + "jq", + "jsonpath", + "keyValToMap", + "labels", + "last", + "mapToKeyVal", + "matchLabel", + "matches", + "memoryAsBytes", + "merge", + "neat", + "nodeProperties", + "none", + "of", + "ofNonZeroValue", + "podProperties", + "quote", + "range", + "round", + "sign", + "size", + "sqrt", + "string", + "timestamp", + "toCSV", + "toTOML", + "toYAML", + "trunc", + "type", + "uint", + "url", + "urldecode", + "urlencode", + "xpath" + ], + "keywords": [ + "as", + "break", + "const", + "continue", + "else", + "for", + "function", + "if", + "import", + "in", + "let", + "loop", + "namespace", + "package", + "return", + "type", + "var", + "void", + "while" + ], + "macros": [ + "all", + "exists", + "exists_one", + "filter", + "fold", + "greatest", + "has", + "least", + "map", + "optFlatMap", + "optMap", + "sortBy" + ], + "memberFunctions": [ + "JSON", + "JSONArray", + "abbrev", + "camelCase", + "charAt", + "contains", + "distinct", + "endsWith", + "find", + "findAll", + "flatten", + "format", + "getDate", + "getDayOfMonth", + "getDayOfWeek", + "getDayOfYear", + "getEscapedPath", + "getFullYear", + "getHost", + "getHostname", + "getHours", + "getMilliseconds", + "getMinutes", + "getMonth", + "getPort", + "getQuery", + "getScheme", + "getSeconds", + "hasValue", + "indent", + "indexOf", + "isSorted", + "join", + "kebabCase", + "keys", + "lastIndexOf", + "lowerAscii", + "match", + "max", + "min", + "omit", + "or", + "orValue", + "pick", + "quote", + "repeat", + "replace", + "replaceAll", + "replaceAllRegex", + "reverse", + "runeCount", + "shellQuote", + "slice", + "slug", + "snakeCase", + "sort", + "sortBy", + "split", + "splitRegex", + "squote", + "startsWith", + "substring", + "sum", + "title", + "toJSON", + "toJSONPretty", + "toLower", + "toUpper", + "trim", + "trimPrefix", + "trimSpace", + "trimSuffix", + "trunc", + "uniq", + "upperAscii", + "value", + "values", + "wordWrap" + ], + "namespaces": [ + "aws", + "base64", + "crypto", + "data", + "filepath", + "json", + "k8s", + "lists", + "math", + "net", + "optional", + "random", + "regexp", + "sets", + "strings", + "test", + "time", + "uuid" + ], + "operators": [ + "!", + "!=", + "%", + "\u0026\u0026", + "(", + ")", + "*", + "+", + ",", + "-", + ".", + "/", + ":", + "\u003c", + "\u003c=", + "==", + "\u003e", + "\u003e=", + "?", + "[", + "]", + "{", + "||", + "}" + ], + "typeKeywords": [ + "bool", + "bytes", + "double", + "duration", + "dyn", + "int", + "list", + "map", + "null_type", + "string", + "timestamp", + "type", + "uint" + ], + "tokenizer": { + "root": [ + { + "include": "@whitespace" + }, + [ + "[bB](?:[rR]\"\"\"[\\s\\S]*?\"\"\"|[rR]'''[\\s\\S]*?'''|\"\"\"(?:[^\\\\]|(?:\\\\[xX][0-9a-fA-F][0-9a-fA-F]|\\\\[0-3][0-7][0-7]|\\\\[abfnrtv\"'\\\\?`]|(?:\\\\U[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F]|\\\\u[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F])))*?\"\"\"|'''(?:[^\\\\]|(?:\\\\[xX][0-9a-fA-F][0-9a-fA-F]|\\\\[0-3][0-7][0-7]|\\\\[abfnrtv\"'\\\\?`]|(?:\\\\U[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F]|\\\\u[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F])))*?'''|[rR]\"[^\"\\n\\r]*\"|[rR]'[^'\\n\\r]*'|\"(?:[^\\\\\"\\n\\r]|(?:\\\\[xX][0-9a-fA-F][0-9a-fA-F]|\\\\[0-3][0-7][0-7]|\\\\[abfnrtv\"'\\\\?`]|(?:\\\\U[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F]|\\\\u[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F])))*\"|'(?:[^\\\\'\\n\\r]|(?:\\\\[xX][0-9a-fA-F][0-9a-fA-F]|\\\\[0-3][0-7][0-7]|\\\\[abfnrtv\"'\\\\?`]|(?:\\\\U[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F]|\\\\u[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F])))*')", + "string.bytes" + ], + [ + "(?:[rR]\"\"\"[\\s\\S]*?\"\"\"|[rR]'''[\\s\\S]*?'''|\"\"\"(?:[^\\\\]|(?:\\\\[xX][0-9a-fA-F][0-9a-fA-F]|\\\\[0-3][0-7][0-7]|\\\\[abfnrtv\"'\\\\?`]|(?:\\\\U[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F]|\\\\u[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F])))*?\"\"\"|'''(?:[^\\\\]|(?:\\\\[xX][0-9a-fA-F][0-9a-fA-F]|\\\\[0-3][0-7][0-7]|\\\\[abfnrtv\"'\\\\?`]|(?:\\\\U[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F]|\\\\u[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F])))*?'''|[rR]\"[^\"\\n\\r]*\"|[rR]'[^'\\n\\r]*'|\"(?:[^\\\\\"\\n\\r]|(?:\\\\[xX][0-9a-fA-F][0-9a-fA-F]|\\\\[0-3][0-7][0-7]|\\\\[abfnrtv\"'\\\\?`]|(?:\\\\U[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F]|\\\\u[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F])))*\"|'(?:[^\\\\'\\n\\r]|(?:\\\\[xX][0-9a-fA-F][0-9a-fA-F]|\\\\[0-3][0-7][0-7]|\\\\[abfnrtv\"'\\\\?`]|(?:\\\\U[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F]|\\\\u[0-9a-fA-F][0-9a-fA-F][0-9a-fA-F][0-9a-fA-F])))*')", + "string" + ], + [ + "`[A-Za-z0-9_.\\-/ ]+`", + "identifier.escaped" + ], + [ + "(?:\\.[0-9]+(?:[eE][+\\-]?[0-9]+)?|[0-9]+\\.[0-9]+(?:[eE][+\\-]?[0-9]+)?|[0-9]+[eE][+\\-]?[0-9]+)", + "number.float" + ], + [ + "(?:0x[0-9a-fA-F]+[uU]|[0-9]+[uU])", + "number.uint" + ], + [ + "(?:0x[0-9a-fA-F]+|[0-9]+)", + "number" + ], + [ + "(\\.\\?)([A-Za-z_][A-Za-z0-9_]*)(?=\\s*\\()", + { + "cases": { + "$2@macros": [ + "operator.optional", + "keyword.macro" + ], + "$2@memberFunctions": [ + "operator.optional", + "function.member" + ], + "$2@globalFunctions": [ + "operator.optional", + "function" + ], + "@default": [ + "operator.optional", + "variable.field" + ] + } + } + ], + [ + "(\\.\\?)([A-Za-z_][A-Za-z0-9_]*)", + [ + "operator.optional", + "variable.field" + ] + ], + [ + "\\.\\?", + "operator.optional" + ], + [ + "\\[\\?", + "operator.optional" + ], + [ + "\\?\\.", + "operator.optional" + ], + [ + "([A-Za-z_][A-Za-z0-9_]*)(\\.)([A-Za-z_][A-Za-z0-9_]*)(?=\\s*\\()", + { + "cases": { + "$1@namespaces": [ + "namespace", + "delimiter", + "function" + ], + "$3@macros": [ + "identifier", + "delimiter", + "keyword.macro" + ], + "$3@memberFunctions": [ + "identifier", + "delimiter", + "function.member" + ], + "$3@globalFunctions": [ + "identifier", + "delimiter", + "function" + ], + "@default": [ + "identifier", + "delimiter", + "identifier" + ] + } + } + ], + [ + "(\\.)([A-Za-z_][A-Za-z0-9_]*)(?=\\s*\\()", + { + "cases": { + "$2@macros": [ + "delimiter", + "keyword.macro" + ], + "$2@memberFunctions": [ + "delimiter", + "function.member" + ], + "$2@globalFunctions": [ + "delimiter", + "function" + ], + "@default": [ + "delimiter", + "variable.field" + ] + } + } + ], + [ + "(\\.)([A-Za-z_][A-Za-z0-9_]*)", + [ + "delimiter", + "variable.field" + ] + ], + [ + "([A-Za-z_][A-Za-z0-9_]*)(?=\\s*\\()", + { + "cases": { + "$1@macros": "keyword.macro", + "$1@globalFunctions": "function", + "$1@keywords": "keyword", + "@default": "identifier" + } + } + ], + [ + "[A-Za-z_][A-Za-z0-9_]*", + { + "cases": { + "@constants": "keyword.constant", + "@keywords": "keyword", + "@typeKeywords": "type", + "@default": "identifier" + } + } + ], + [ + "[{}()\\[\\]]", + "@brackets" + ], + [ + "(?:!=|\u0026\u0026|\u003c=|==|\u003e=|\\|\\||!|%|\\*|\\+|,|-|\\.|\\/|:|\u003c|\u003e|\\?)", + "operator" + ], + [ + "[,;]", + "delimiter" + ] + ], + "whitespace": [ + [ + "[\\t \\r\\n\\f]+", + "white" + ], + [ + "//[^\\n]*", + "comment" + ] + ] + } +} diff --git a/packages/expressions/src/lang/generated/conformance.json b/packages/expressions/src/lang/generated/conformance.json new file mode 100644 index 000000000..87a0d238c --- /dev/null +++ b/packages/expressions/src/lang/generated/conformance.json @@ -0,0 +1,4959 @@ +[ + { + "language": "cel", + "source": "\" \\ttrim\\n \".trim()", + "boundaries": [ + 0, + 16, + 17, + 21, + 22 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"\"\"triple \"quoted\" string\"\"\"", + "boundaries": [ + 0 + ], + "origin": "edge-case" + }, + { + "language": "cel", + "source": "\"12345\".matches(\"^\\\\d+$\")", + "boundaries": [ + 0, + 7, + 8, + 15, + 16, + 24 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"2023-01-01T12:34:56Z\".getDate()", + "boundaries": [ + 0, + 22, + 23, + 30, + 31 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"Hello Beautiful World!\".kebabCase()", + "boundaries": [ + 0, + 24, + 25, + 34, + 35 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"Hello Beautiful World!\".snakeCase()", + "boundaries": [ + 0, + 24, + 25, + 34, + 35 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"Hello Beautiful World\".slug()", + "boundaries": [ + 0, + 23, + 24, + 28, + 29 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"Hello World!\".slug()", + "boundaries": [ + 0, + 14, + 15, + 19, + 20 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"Hello World\".kebabCase()", + "boundaries": [ + 0, + 13, + 14, + 23, + 24 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"Hello World\".runeCount()", + "boundaries": [ + 0, + 13, + 14, + 23, + 24 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"Hello World\".shellQuote()", + "boundaries": [ + 0, + 13, + 14, + 24, + 25 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"Hello World\".snakeCase()", + "boundaries": [ + 0, + 13, + 14, + 23, + 24 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"Hello World\".squote()", + "boundaries": [ + 0, + 13, + 14, + 20, + 21 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"Hello$World\".shellQuote()", + "boundaries": [ + 0, + 13, + 14, + 24, + 25 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"Hello, World!\".slug()", + "boundaries": [ + 0, + 15, + 16, + 20, + 21 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"HelloWorld\".kebabCase()", + "boundaries": [ + 0, + 12, + 13, + 22, + 23 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"HelloWorld\".snakeCase()", + "boundaries": [ + 0, + 12, + 13, + 22, + 23 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"I have an apple\".replaceAll(\"apple\", \"orange\")", + "boundaries": [ + 0, + 17, + 18, + 28, + 29, + 36, + 38, + 46 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"John Smith\".reverse()", + "boundaries": [ + 0, + 12, + 13, + 20, + 21 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"KubernetesPod\".abbrev(1, 5)", + "boundaries": [ + 0, + 15, + 16, + 22, + 23, + 24, + 26, + 27 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"KubernetesPod\".abbrev(6)", + "boundaries": [ + 0, + 15, + 16, + 22, + 23, + 24 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"Now is the time for all good men\".abbrev(5, 20)", + "boundaries": [ + 0, + 34, + 35, + 41, + 42, + 43, + 45, + 47 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"TacoCat\".lowerAscii()", + "boundaries": [ + 0, + 9, + 10, + 20, + 21 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"TacoCat\".upperAscii()", + "boundaries": [ + 0, + 9, + 10, + 20, + 21 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"TacoCÆt Xii\".lowerAscii()", + "boundaries": [ + 0, + 13, + 14, + 24, + 25 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"\\x41A\\U0001F600\\101\"", + "boundaries": [ + 0 + ], + "origin": "edge-case" + }, + { + "language": "cel", + "source": "\"a\" + // trailing comment", + "boundaries": [ + 0, + 4, + 6 + ], + "origin": "edge-case" + }, + { + "language": "cel", + "source": "\"apple\" in [\"apple\", \"banana\"]", + "boundaries": [ + 0, + 8, + 11, + 12, + 19, + 21, + 29 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"apple\".contains(\"app\")", + "boundaries": [ + 0, + 7, + 8, + 16, + 17, + 22 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"apple\".matches(\"^a.*e$\")", + "boundaries": [ + 0, + 7, + 8, + 15, + 16, + 24 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"apple\".repeat(3)", + "boundaries": [ + 0, + 7, + 8, + 14, + 15, + 16 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"apple\".size()", + "boundaries": [ + 0, + 7, + 8, + 12, + 13 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"example@email.com\".matches(\"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\\\.[a-zA-Z]{2,}$\")", + "boundaries": [ + 0, + 19, + 20, + 27, + 28, + 79 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"gums\".reverse()", + "boundaries": [ + 0, + 6, + 7, + 14, + 15 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"hello beautiful world!\".camelCase()", + "boundaries": [ + 0, + 24, + 25, + 34, + 35 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"hello hello hello\".split(\" \")", + "boundaries": [ + 0, + 19, + 20, + 25, + 26, + 29 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"hello hello hello\".split(\" \", -1)", + "boundaries": [ + 0, + 19, + 20, + 25, + 26, + 29, + 31, + 32, + 33 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"hello hello hello\".split(\" \", 2)", + "boundaries": [ + 0, + 19, + 20, + 25, + 26, + 29, + 31, + 32 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"hello hello\".replace(\"he\", \"we\")", + "boundaries": [ + 0, + 13, + 14, + 21, + 22, + 26, + 28, + 32 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"hello hello\".replace(\"he\", \"we\", 0)", + "boundaries": [ + 0, + 13, + 14, + 21, + 22, + 26, + 28, + 32, + 34, + 35 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"hello hello\".replace(\"he\", \"we\", 1)", + "boundaries": [ + 0, + 13, + 14, + 21, + 22, + 26, + 28, + 32, + 34, + 35 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"hello mellow\".indexOf(\"\")", + "boundaries": [ + 0, + 14, + 15, + 22, + 23, + 25 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"hello mellow\".indexOf(\"\", 2)", + "boundaries": [ + 0, + 14, + 15, + 22, + 23, + 25, + 27, + 28 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"hello mellow\".indexOf(\"ello\")", + "boundaries": [ + 0, + 14, + 15, + 22, + 23, + 29 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"hello mellow\".indexOf(\"jello\")", + "boundaries": [ + 0, + 14, + 15, + 22, + 23, + 30 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"hello mellow\".lastIndexOf(\"\")", + "boundaries": [ + 0, + 14, + 15, + 26, + 27, + 29 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"hello mellow\".lastIndexOf(\"ello\")", + "boundaries": [ + 0, + 14, + 15, + 26, + 27, + 33 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"hello mellow\".lastIndexOf(\"ello\", 6)", + "boundaries": [ + 0, + 14, + 15, + 26, + 27, + 33, + 35, + 36 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"hello mellow\".lastIndexOf(\"jello\")", + "boundaries": [ + 0, + 14, + 15, + 26, + 27, + 34 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"hello world\".camelCase()", + "boundaries": [ + 0, + 13, + 14, + 23, + 24 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"hello world\".contains(\"world\")", + "boundaries": [ + 0, + 13, + 14, + 22, + 23, + 30 + ], + "origin": "README.md" + }, + { + "language": "cel", + "source": "\"hello world\".indent(4, \"-\")", + "boundaries": [ + 0, + 13, + 14, + 20, + 21, + 22, + 24, + 27 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"hello world\".split(\" \")", + "boundaries": [ + 0, + 13, + 14, + 19, + 20, + 23 + ], + "origin": "README.md" + }, + { + "language": "cel", + "source": "\"hello world\".title()", + "boundaries": [ + 0, + 13, + 14, + 19, + 20 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"hello world\".trimPrefix(\"hello \")", + "boundaries": [ + 0, + 13, + 14, + 24, + 25, + 33 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"hello world\".trimSuffix(\" world\")", + "boundaries": [ + 0, + 13, + 14, + 24, + 25, + 33 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"hello world\".upperAscii()", + "boundaries": [ + 0, + 13, + 14, + 24, + 25 + ], + "origin": "README.md" + }, + { + "language": "cel", + "source": "\"hello\" + \" world\"", + "boundaries": [ + 0, + 8, + 10 + ], + "origin": "README.md" + }, + { + "language": "cel", + "source": "\"hello\".charAt(4)", + "boundaries": [ + 0, + 7, + 8, + 14, + 15, + 16 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"hello\".endsWith(\"lo\")", + "boundaries": [ + 0, + 7, + 8, + 16, + 17, + 21 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"hello\".size()", + "boundaries": [ + 0, + 7, + 8, + 12, + 13 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"hello\".sort()", + "boundaries": [ + 0, + 7, + 8, + 12, + 13 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"hello\".startsWith(\"he\")", + "boundaries": [ + 0, + 7, + 8, + 18, + 19, + 23 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"hello_world\".camelCase()", + "boundaries": [ + 0, + 13, + 14, + 23, + 24 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"name\": \"world\",", + "boundaries": [ + 0, + 6, + 8, + 15 + ], + "origin": "README.md" + }, + { + "language": "cel", + "source": "\"tacocat\".substring(0, 4)", + "boundaries": [ + 0, + 9, + 10, + 19, + 20, + 21, + 23, + 24 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"tacocat\".substring(4)", + "boundaries": [ + 0, + 9, + 10, + 19, + 20, + 21 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "\"this is a string: %s\\nand an integer: %d\".format([\"str\", 42])", + "boundaries": [ + 0, + 42, + 43, + 49, + 50, + 51, + 56, + 58, + 60, + 61 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "'''triple 'quoted' string'''", + "boundaries": [ + 0 + ], + "origin": "edge-case" + }, + { + "language": "cel", + "source": "'[{\"name\": \"Alice\"}, {\"name\": \"Bob\"}]'.JSONArray()", + "boundaries": [ + 0, + 38, + 39, + 48, + 49 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "'{\"name\": \"Alice\", \"age\": 30}'.JSON()", + "boundaries": [ + 0, + 30, + 31, + 35, + 36 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "1.toJSON()", + "boundaries": [ + 0, + 1, + 2, + 8, + 9 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "123u + 0x1fU + 0x1f + 1.5e-3 + .5", + "boundaries": [ + 0, + 5, + 7, + 13, + 15, + 20, + 22, + 29, + 31 + ], + "origin": "edge-case" + }, + { + "language": "cel", + "source": "2 + 3", + "boundaries": [ + 0, + 2, + 4 + ], + "origin": "README.md" + }, + { + "language": "cel", + "source": "3 in [1, 2, 4]", + "boundaries": [ + 0, + 2, + 5, + 6, + 7, + 9, + 10, + 12, + 13 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "A small helper command is available at `cmd/ceval` to evaluate CEL expressions against an input YAML or JSON file.", + "boundaries": [ + 0, + 2, + 8, + 15, + 23, + 26, + 36, + 39, + 51, + 54, + 63, + 67, + 79, + 87, + 90, + 96, + 101, + 104, + 109, + 113 + ], + "origin": "README.md" + }, + { + "language": "cel", + "source": "B'bytes'", + "boundaries": [ + 0 + ], + "origin": "edge-case" + }, + { + "language": "cel", + "source": "C,32", + "boundaries": [ + 0, + 1, + 2 + ], + "origin": "GO_TEMPLATE.md" + }, + { + "language": "cel", + "source": "CSV([\"Alice,30\", \"Bob,31\"])[0][0]", + "boundaries": [ + 0, + 3, + 4, + 5, + 15, + 17, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "Count int", + "boundaries": [ + 0, + 8 + ], + "origin": "README.md" + }, + { + "language": "cel", + "source": "Example input file:", + "boundaries": [ + 0, + 8, + 14, + 18 + ], + "origin": "README.md" + }, + { + "language": "cel", + "source": "Go,25", + "boundaries": [ + 0, + 2, + 3 + ], + "origin": "GO_TEMPLATE.md" + }, + { + "language": "cel", + "source": "Message string", + "boundaries": [ + 0, + 8 + ], + "origin": "README.md" + }, + { + "language": "cel", + "source": "Output:", + "boundaries": [ + 0, + 6 + ], + "origin": "README.md" + }, + { + "language": "cel", + "source": "R'raw\\dstring'", + "boundaries": [ + 0 + ], + "origin": "edge-case" + }, + { + "language": "cel", + "source": "Run it with:", + "boundaries": [ + 0, + 4, + 7, + 11 + ], + "origin": "README.md" + }, + { + "language": "cel", + "source": "[\"a\", \"b\", \"a\", \"c\"].distinct()", + "boundaries": [ + 0, + 1, + 4, + 6, + 9, + 11, + 14, + 16, + 19, + 20, + 21, + 29, + 30 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "[\"a\", \"b\", \"b\"].uniq()", + "boundaries": [ + 0, + 1, + 4, + 6, + 9, + 11, + 14, + 15, + 16, + 20, + 21 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "[\"a\", \"b\", \"c\"].reverse()", + "boundaries": [ + 0, + 1, + 4, + 6, + 9, + 11, + 14, + 15, + 16, + 23, + 24 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "[\"apple\", \"banana\", \"cherry\"].size()", + "boundaries": [ + 0, + 1, + 8, + 10, + 18, + 20, + 28, + 29, + 30, + 34, + 35 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "[\"hello\", \"mellow\"].join(\" \")", + "boundaries": [ + 0, + 1, + 8, + 10, + 18, + 19, + 20, + 24, + 25, + 28 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "[\"hello\", \"mellow\"].join()", + "boundaries": [ + 0, + 1, + 8, + 10, + 18, + 19, + 20, + 24, + 25 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "['c', 'b', 'a'].sort()", + "boundaries": [ + 0, + 1, + 4, + 6, + 9, + 11, + 14, + 15, + 16, + 20, + 21 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "[1, 2, 2, 3, 3, 3].distinct()", + "boundaries": [ + 0, + 1, + 2, + 4, + 5, + 7, + 8, + 10, + 11, + 13, + 14, + 16, + 17, + 18, + 19, + 27, + 28 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "[1, 2, 3, 4].filter(e, e \u003e 2)", + "boundaries": [ + 0, + 1, + 2, + 4, + 5, + 7, + 8, + 10, + 11, + 12, + 13, + 19, + 20, + 21, + 23, + 25, + 27, + 28 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "[1, 2, 3, 4].reverse()", + "boundaries": [ + 0, + 1, + 2, + 4, + 5, + 7, + 8, + 10, + 11, + 12, + 13, + 20, + 21 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "[1, 2, 3, 4].slice(1, 3)", + "boundaries": [ + 0, + 1, + 2, + 4, + 5, + 7, + 8, + 10, + 11, + 12, + 13, + 18, + 19, + 20, + 22, + 23 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "[1, 2, 3, 4].slice(2, 4)", + "boundaries": [ + 0, + 1, + 2, + 4, + 5, + 7, + 8, + 10, + 11, + 12, + 13, + 18, + 19, + 20, + 22, + 23 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "[1, 2, 3].all(e, e \u003e 0)", + "boundaries": [ + 0, + 1, + 2, + 4, + 5, + 7, + 8, + 9, + 10, + 13, + 14, + 15, + 17, + 19, + 21, + 22 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "[1, 2, 3].exists(e, e == 2)", + "boundaries": [ + 0, + 1, + 2, + 4, + 5, + 7, + 8, + 9, + 10, + 16, + 17, + 18, + 20, + 22, + 25, + 26 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "[1, 2, 3].exists_one(e, e == 2)", + "boundaries": [ + 0, + 1, + 2, + 4, + 5, + 7, + 8, + 9, + 10, + 20, + 21, + 22, + 24, + 26, + 29, + 30 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "[1, 2, 3].exists_one(e, e \u003e 1)", + "boundaries": [ + 0, + 1, + 2, + 4, + 5, + 7, + 8, + 9, + 10, + 20, + 21, + 22, + 24, + 26, + 28, + 29 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "[1, 2, 3].fold(e, acc, acc + e)", + "boundaries": [ + 0, + 1, + 2, + 4, + 5, + 7, + 8, + 9, + 10, + 14, + 15, + 16, + 18, + 21, + 23, + 27, + 29, + 30 + ], + "origin": "edge-case" + }, + { + "language": "cel", + "source": "[1, 2, 3].map(e, e * 2)", + "boundaries": [ + 0, + 1, + 2, + 4, + 5, + 7, + 8, + 9, + 10, + 13, + 14, + 15, + 17, + 19, + 21, + 22 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "[1, 2, 3].map(x, x \u003e 1, x + 1)", + "boundaries": [ + 0, + 1, + 2, + 4, + 5, + 7, + 8, + 9, + 10, + 13, + 14, + 15, + 17, + 19, + 21, + 22, + 24, + 26, + 28, + 29 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "[1, 2, 3][?2].orValue(5)", + "boundaries": [ + 0, + 1, + 2, + 4, + 5, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 21, + 22, + 23 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "[1, 2][?2].orValue(5)", + "boundaries": [ + 0, + 1, + 2, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 18, + 19, + 20 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "[1, [2, [3, 4]]].flatten()", + "boundaries": [ + 0, + 1, + 2, + 4, + 5, + 6, + 8, + 9, + 10, + 12, + 13, + 14, + 15, + 16, + 17, + 24, + 25 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "[1,2,3,3,3].uniq().sum()", + "boundaries": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 16, + 17, + 18, + 19, + 22, + 23 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "[1,2,3].all(e, e \u003e 0)", + "boundaries": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 11, + 12, + 13, + 15, + 17, + 19, + 20 + ], + "origin": "README.md" + }, + { + "language": "cel", + "source": "[1,2,3].filter(e, e \u003e 1)", + "boundaries": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 14, + 15, + 16, + 18, + 20, + 22, + 23 + ], + "origin": "README.md" + }, + { + "language": "cel", + "source": "[1,2,3].map(e, e * 2)", + "boundaries": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 11, + 12, + 13, + 15, + 17, + 19, + 20 + ], + "origin": "README.md" + }, + { + "language": "cel", + "source": "[1,2] + [3,4]", + "boundaries": [ + 0, + 1, + 2, + 3, + 4, + 6, + 8, + 9, + 10, + 11, + 12 + ], + "origin": "README.md" + }, + { + "language": "cel", + "source": "[3, 2, 1].sort()", + "boundaries": [ + 0, + 1, + 2, + 4, + 5, + 7, + 8, + 9, + 10, + 14, + 15 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "[[1, 2], [3, 4]].flatten()", + "boundaries": [ + 0, + 1, + 2, + 3, + 5, + 6, + 7, + 9, + 10, + 11, + 13, + 14, + 15, + 16, + 17, + 24, + 25 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "[].join(\"/\")", + "boundaries": [ + 0, + 1, + 2, + 3, + 7, + 8, + 11 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "[{ name: \"John\" }].toJSON()", + "boundaries": [ + 0, + 1, + 3, + 7, + 9, + 16, + 17, + 18, + 19, + 25, + 26 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "`escaped.identifier-1`", + "boundaries": [ + 0 + ], + "origin": "edge-case" + }, + { + "language": "cel", + "source": "a.?b.orValue(\"x\")", + "boundaries": [ + 0, + 1, + 2, + 3, + 4, + 5, + 12, + 13, + 16 + ], + "origin": "edge-case" + }, + { + "language": "cel", + "source": "aws.arnToMap(\"arn:aws:sns:eu-west-1:123:MMS-Topic\")", + "boundaries": [ + 0, + 3, + 4, + 12, + 13, + 50 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "aws.fromAWSMap(x).hello == \"world\"", + "boundaries": [ + 0, + 3, + 4, + 14, + 15, + 16, + 17, + 18, + 24, + 27 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "b\"abc\".size()", + "boundaries": [ + 0, + 6, + 7, + 11, + 12 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "b\"bytes\"", + "boundaries": [ + 0 + ], + "origin": "edge-case" + }, + { + "language": "cel", + "source": "base64.decode(\"aGVsbG8=\")", + "boundaries": [ + 0, + 6, + 7, + 13, + 14, + 24 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "base64.encode(\"hello\")", + "boundaries": [ + 0, + 6, + 7, + 13, + 14, + 21 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "cond ? \"yes\" : \"no\"", + "boundaries": [ + 0, + 5, + 7, + 13, + 15 + ], + "origin": "edge-case" + }, + { + "language": "cel", + "source": "crypto.SHA1(\"hello\")", + "boundaries": [ + 0, + 6, + 7, + 11, + 12, + 19 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "crypto.SHA256(\"hello\")", + "boundaries": [ + 0, + 6, + 7, + 13, + 14, + 21 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "duration(\"30m\")", + "boundaries": [ + 0, + 8, + 9, + 14 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "duration(\"5h\")", + "boundaries": [ + 0, + 8, + 9, + 13 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "duration(\"7d\")", + "boundaries": [ + 0, + 8, + 9, + 13 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "dyn(tags).fold(tag, acc, merge(acc, {tag.key: tag.value}))", + "boundaries": [ + 0, + 3, + 4, + 8, + 9, + 10, + 14, + 15, + 18, + 20, + 23, + 25, + 30, + 31, + 34, + 36, + 37, + 40, + 41, + 44, + 46, + 49, + 50, + 55, + 56, + 57 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "exec:", + "boundaries": [ + 0, + 4 + ], + "origin": "GO_TEMPLATE.md" + }, + { + "language": "cel", + "source": "filepath.Base(\"/home/user/projects/gencel\")", + "boundaries": [ + 0, + 8, + 9, + 13, + 14, + 42 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "filepath.Clean(\"/foo/bar/../baz\")", + "boundaries": [ + 0, + 8, + 9, + 14, + 15, + 32 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "filepath.Dir(\"/home/user/projects/gencel\")", + "boundaries": [ + 0, + 8, + 9, + 12, + 13, + 41 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "filepath.Ext(\"/opt/image.jpg\")", + "boundaries": [ + 0, + 8, + 9, + 12, + 13, + 29 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "filepath.IsAbs(\"/home/user/projects/gencel\")", + "boundaries": [ + 0, + 8, + 9, + 14, + 15, + 43 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "filepath.IsAbs(\"projects/gencel\")", + "boundaries": [ + 0, + 8, + 9, + 14, + 15, + 32 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "filepath.Join([\"/home/user\", \"projects\", \"gencel\"])", + "boundaries": [ + 0, + 8, + 9, + 13, + 14, + 15, + 27, + 29, + 39, + 41, + 49, + 50 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "filepath.Match(\"*.txt\", \"foo.json\")", + "boundaries": [ + 0, + 8, + 9, + 14, + 15, + 22, + 24, + 34 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "filepath.Match(\"*.txt\", \"foo.txt\")", + "boundaries": [ + 0, + 8, + 9, + 14, + 15, + 22, + 24, + 33 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "filepath.Rel(\"/foo/bar\", \"/foo/bar/baz\")", + "boundaries": [ + 0, + 8, + 9, + 12, + 13, + 23, + 25, + 39 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "filepath.Split(\"/foo/bar/baz\")", + "boundaries": [ + 0, + 8, + 9, + 14, + 15, + 29 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "gomplate.Template{Expression: \"person.display_name\"},", + "boundaries": [ + 0, + 8, + 9, + 17, + 18, + 28, + 30, + 51, + 52 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "has(person.name)", + "boundaries": [ + 0, + 3, + 4, + 10, + 11, + 15 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "import \"github.com/flanksource/gomplate/v3\"", + "boundaries": [ + 0, + 7 + ], + "origin": "README.md" + }, + { + "language": "cel", + "source": "jmespath(\"city\", { name: \"John\", age: 30, city: \"NY\" })", + "boundaries": [ + 0, + 8, + 9, + 15, + 17, + 19, + 23, + 25, + 31, + 33, + 36, + 38, + 40, + 42, + 46, + 48, + 53, + 54 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "jq(\".[] | select(.age \u003e 25)\", [{ name: \"John\", age: 30 }, { name: \"Jane\", age: 25 }])", + "boundaries": [ + 0, + 2, + 3, + 28, + 30, + 31, + 33, + 37, + 39, + 45, + 47, + 50, + 52, + 55, + 56, + 58, + 60, + 64, + 66, + 72, + 74, + 77, + 79, + 82, + 83, + 84 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "jq(\".name\", { name: \"John\", age: 30 })", + "boundaries": [ + 0, + 2, + 3, + 10, + 12, + 14, + 18, + 20, + 26, + 28, + 31, + 33, + 36, + 37 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "jq(\"{name, age}\", { name: \"John\", age: 30, city: \"NY\" })", + "boundaries": [ + 0, + 2, + 3, + 16, + 18, + 20, + 24, + 26, + 32, + 34, + 37, + 39, + 41, + 43, + 47, + 49, + 54, + 55 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "jsonpath(\"$.addresses[-1:].city\", { addresses: [{city:\"NYC\"},{city:\"SF\"}] })", + "boundaries": [ + 0, + 8, + 9, + 32, + 34, + 36, + 45, + 47, + 48, + 49, + 53, + 54, + 59, + 60, + 61, + 62, + 66, + 67, + 71, + 72, + 74, + 75 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "jsonpath(\"$.items[0]\", { items: [\"apple\", \"banana\"] })", + "boundaries": [ + 0, + 8, + 9, + 21, + 23, + 25, + 30, + 32, + 33, + 40, + 42, + 50, + 52, + 53 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "jsonpath(\"$.name\", { name: \"John\", age: 30 })", + "boundaries": [ + 0, + 8, + 9, + 17, + 19, + 21, + 25, + 27, + 33, + 35, + 38, + 40, + 43, + 44 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "jsonpath(\"$.user.email\", '{\"user\": {\"email\": \"john@example.com\"}}')", + "boundaries": [ + 0, + 8, + 9, + 23, + 25, + 66 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "k8s.cpuAsMillicores(\"0.5\")", + "boundaries": [ + 0, + 3, + 4, + 19, + 20, + 25 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "k8s.cpuAsMillicores(\"1.234\")", + "boundaries": [ + 0, + 3, + 4, + 19, + 20, + 27 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "k8s.cpuAsMillicores(\"10m\")", + "boundaries": [ + 0, + 3, + 4, + 19, + 20, + 25 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "k8s.cpuAsMillicores(\"500m\")", + "boundaries": [ + 0, + 3, + 4, + 19, + 20, + 26 + ], + "origin": "README.md" + }, + { + "language": "cel", + "source": "k8s.getHealth(deployment)", + "boundaries": [ + 0, + 3, + 4, + 13, + 14, + 24 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "k8s.getHealth(pod)", + "boundaries": [ + 0, + 3, + 4, + 13, + 14, + 17 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "k8s.getHealth(service)", + "boundaries": [ + 0, + 3, + 4, + 13, + 14, + 21 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "k8s.getResourcesLimit(pod, \"cpu\")", + "boundaries": [ + 0, + 3, + 4, + 21, + 22, + 25, + 27, + 32 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "k8s.getResourcesLimit(pod, \"memory\")", + "boundaries": [ + 0, + 3, + 4, + 21, + 22, + 25, + 27, + 35 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "k8s.getResourcesRequests(pod, \"cpu\")", + "boundaries": [ + 0, + 3, + 4, + 24, + 25, + 28, + 30, + 35 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "k8s.getResourcesRequests(pod, \"memory\")", + "boundaries": [ + 0, + 3, + 4, + 24, + 25, + 28, + 30, + 38 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "k8s.getStatus(deployment)", + "boundaries": [ + 0, + 3, + 4, + 13, + 14, + 24 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "k8s.getStatus(pod)", + "boundaries": [ + 0, + 3, + 4, + 13, + 14, + 17 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "k8s.getStatus(service)", + "boundaries": [ + 0, + 3, + 4, + 13, + 14, + 21 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "k8s.isHealthy(deployment)", + "boundaries": [ + 0, + 3, + 4, + 13, + 14, + 24 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "k8s.isHealthy(pod)", + "boundaries": [ + 0, + 3, + 4, + 13, + 14, + 17 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "k8s.isHealthy(pod) \u0026\u0026 pod.status.?phase.orValue(\"\") == \"Running\"", + "boundaries": [ + 0, + 3, + 4, + 13, + 14, + 17, + 19, + 22, + 25, + 26, + 32, + 33, + 34, + 39, + 40, + 47, + 48, + 50, + 52, + 55 + ], + "origin": "edge-case" + }, + { + "language": "cel", + "source": "k8s.isHealthy(service)", + "boundaries": [ + 0, + 3, + 4, + 13, + 14, + 21 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "k8s.labels(pod)", + "boundaries": [ + 0, + 3, + 4, + 10, + 11, + 14 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "k8s.memoryAsBytes(\"1.234Gi\")", + "boundaries": [ + 0, + 3, + 4, + 17, + 18, + 27 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "k8s.memoryAsBytes(\"10Ki\")", + "boundaries": [ + 0, + 3, + 4, + 17, + 18, + 24 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "k8s.memoryAsBytes(\"1Gi\")", + "boundaries": [ + 0, + 3, + 4, + 17, + 18, + 23 + ], + "origin": "README.md" + }, + { + "language": "cel", + "source": "k8s.nodeProperties(node)", + "boundaries": [ + 0, + 3, + 4, + 18, + 19, + 23 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "k8s.podProperties(pod)", + "boundaries": [ + 0, + 3, + 4, + 17, + 18, + 21 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "keyValToMap(\"a=b,c=d\")", + "boundaries": [ + 0, + 11, + 12, + 21 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "keyValToMap(\"env=prod,region=us-east-1\")", + "boundaries": [ + 0, + 11, + 12, + 39 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "l[?index].or(obj.?field.subfield).or(obj.?other)", + "boundaries": [ + 0, + 1, + 2, + 3, + 8, + 9, + 10, + 12, + 13, + 16, + 17, + 18, + 23, + 24, + 32, + 33, + 34, + 36, + 37, + 40, + 41, + 42, + 47 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "m[?\"k\"]", + "boundaries": [ + 0, + 1, + 2, + 3, + 6 + ], + "origin": "edge-case" + }, + { + "language": "cel", + "source": "map[string]any{\"person\": Person{DisplayName: \"Ada\"}},", + "boundaries": [ + 0, + 3, + 4, + 10, + 11, + 14, + 15, + 23, + 25, + 31, + 32, + 43, + 45, + 50, + 51, + 52 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "matchLabel(config.labels, \"env\", \"!production\")", + "boundaries": [ + 0, + 10, + 11, + 17, + 18, + 24, + 26, + 31, + 33, + 46 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "matchLabel(config.labels, \"env\", \"prod,staging\")", + "boundaries": [ + 0, + 10, + 11, + 17, + 18, + 24, + 26, + 31, + 33, + 47 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "matchLabel(config.labels, \"optional\", \"!*\")", + "boundaries": [ + 0, + 10, + 11, + 17, + 18, + 24, + 26, + 36, + 38, + 42 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "matchLabel(config.labels, \"region\", \"us-*\")", + "boundaries": [ + 0, + 10, + 11, + 17, + 18, + 24, + 26, + 34, + 36, + 42 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "matchLabel(config.tags, \"cluster\", \"*-prod,*-staging\")", + "boundaries": [ + 0, + 10, + 11, + 17, + 18, + 22, + 24, + 33, + 35, + 53 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "matchLabel(labels, key, patterns)", + "boundaries": [ + 0, + 10, + 11, + 17, + 19, + 22, + 24, + 32 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "matchQuery(.config, \"type=Kubernetes::Pod tags.cluster=homelab\")", + "boundaries": [ + 0, + 10, + 11, + 12, + 18, + 20, + 63 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "matchQuery(.config, \"type=Kubernetes::Pod\")", + "boundaries": [ + 0, + 10, + 11, + 12, + 18, + 20, + 42 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "matchQuery(r, s)", + "boundaries": [ + 0, + 10, + 11, + 12, + 14, + 15 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "math.Abs(-1)", + "boundaries": [ + 0, + 4, + 5, + 8, + 9, + 10, + 11 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "math.Add([1, 2, 3, 4, 5])", + "boundaries": [ + 0, + 4, + 5, + 8, + 9, + 10, + 11, + 13, + 14, + 16, + 17, + 19, + 20, + 22, + 23, + 24 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "math.Add([1,2,3])", + "boundaries": [ + 0, + 4, + 5, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16 + ], + "origin": "README.md" + }, + { + "language": "cel", + "source": "math.Ceil(2.3)", + "boundaries": [ + 0, + 4, + 5, + 9, + 10, + 13 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "math.Div(4, 2)", + "boundaries": [ + 0, + 4, + 5, + 8, + 9, + 10, + 12, + 13 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "math.Floor(2.3)", + "boundaries": [ + 0, + 4, + 5, + 10, + 11, + 14 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "math.IsFinite(1.0 / 0.0)", + "boundaries": [ + 0, + 4, + 5, + 13, + 14, + 18, + 20, + 23 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "math.IsFinite(5.0)", + "boundaries": [ + 0, + 4, + 5, + 13, + 14, + 17 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "math.IsInf(1.0 / 0.0)", + "boundaries": [ + 0, + 4, + 5, + 10, + 11, + 15, + 17, + 20 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "math.IsInf(5.0)", + "boundaries": [ + 0, + 4, + 5, + 10, + 11, + 14 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "math.IsNaN(0.0 / 0.0)", + "boundaries": [ + 0, + 4, + 5, + 10, + 11, + 15, + 17, + 20 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "math.IsNaN(5.0)", + "boundaries": [ + 0, + 4, + 5, + 10, + 11, + 14 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "math.Mul([1, 2, 3, 4, 5])", + "boundaries": [ + 0, + 4, + 5, + 8, + 9, + 10, + 11, + 13, + 14, + 16, + 17, + 19, + 20, + 22, + 23, + 24 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "math.Pow(4, 2)", + "boundaries": [ + 0, + 4, + 5, + 8, + 9, + 10, + 12, + 13 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "math.Rem(4, 3)", + "boundaries": [ + 0, + 4, + 5, + 8, + 9, + 10, + 12, + 13 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "math.Round(2.3)", + "boundaries": [ + 0, + 4, + 5, + 10, + 11, + 14 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "math.Round(2.5)", + "boundaries": [ + 0, + 4, + 5, + 10, + 11, + 14 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "math.Round(2.7)", + "boundaries": [ + 0, + 4, + 5, + 10, + 11, + 14 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "math.Seq([1, 5])", + "boundaries": [ + 0, + 4, + 5, + 8, + 9, + 10, + 11, + 13, + 14, + 15 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "math.Seq([1, 6, 2])", + "boundaries": [ + 0, + 4, + 5, + 8, + 9, + 10, + 11, + 13, + 14, + 16, + 17, + 18 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "math.Sign(-5)", + "boundaries": [ + 0, + 4, + 5, + 9, + 10, + 11, + 12 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "math.Sign(0)", + "boundaries": [ + 0, + 4, + 5, + 9, + 10, + 11 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "math.Sign(5)", + "boundaries": [ + 0, + 4, + 5, + 9, + 10, + 11 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "math.Sqrt(16)", + "boundaries": [ + 0, + 4, + 5, + 9, + 10, + 12 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "math.Sqrt(2)", + "boundaries": [ + 0, + 4, + 5, + 9, + 10, + 11 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "math.Sub(5, 4)", + "boundaries": [ + 0, + 4, + 5, + 8, + 9, + 10, + 12, + 13 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "math.Trunc(-2.7)", + "boundaries": [ + 0, + 4, + 5, + 10, + 11, + 12, + 15 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "math.Trunc(2.7)", + "boundaries": [ + 0, + 4, + 5, + 10, + 11, + 14 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "math.greatest([1, 2, 3, 4, 5])", + "boundaries": [ + 0, + 4, + 5, + 13, + 14, + 15, + 16, + 18, + 19, + 21, + 22, + 24, + 25, + 27, + 28, + 29 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "math.greatest([1,2,3])", + "boundaries": [ + 0, + 4, + 5, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21 + ], + "origin": "README.md" + }, + { + "language": "cel", + "source": "math.least([1, 2, 3, 4, 5])", + "boundaries": [ + 0, + 4, + 5, + 10, + 11, + 12, + 13, + 15, + 16, + 18, + 19, + 21, + 22, + 24, + 25, + 26 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "obj.?a.?b.orValue(\"fallback\")", + "boundaries": [ + 0, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 17, + 18, + 28 + ], + "origin": "README.md" + }, + { + "language": "cel", + "source": "obj.?field.or(m[?key])", + "boundaries": [ + 0, + 3, + 4, + 5, + 10, + 11, + 13, + 14, + 15, + 16, + 17, + 20, + 21 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "obj.?field.orValue(\"default\")", + "boundaries": [ + 0, + 3, + 4, + 5, + 10, + 11, + 18, + 19, + 28 + ], + "origin": "README.md" + }, + { + "language": "cel", + "source": "obj.a.?d.orValue(\"fallback\")", + "boundaries": [ + 0, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 16, + 17, + 27 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "obj.a.b", + "boundaries": [ + 0, + 3, + 4, + 5, + 6 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "obj.a.d", + "boundaries": [ + 0, + 3, + 4, + 5, + 6 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "r\"\"\"raw triple \\d\"\"\"", + "boundaries": [ + 0 + ], + "origin": "edge-case" + }, + { + "language": "cel", + "source": "r\"raw\\dstring\"", + "boundaries": [ + 0 + ], + "origin": "edge-case" + }, + { + "language": "cel", + "source": "random.ASCII(5)", + "boundaries": [ + 0, + 6, + 7, + 12, + 13, + 14 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "random.Alpha(5)", + "boundaries": [ + 0, + 6, + 7, + 12, + 13, + 14 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "random.AlphaNum(5)", + "boundaries": [ + 0, + 6, + 7, + 15, + 16, + 17 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "random.Float(1, 10)", + "boundaries": [ + 0, + 6, + 7, + 12, + 13, + 14, + 16, + 18 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "random.Item([\"a\", \"b\", \"c\"])", + "boundaries": [ + 0, + 6, + 7, + 11, + 12, + 13, + 16, + 18, + 21, + 23, + 26, + 27 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "random.Number(1, 10)", + "boundaries": [ + 0, + 6, + 7, + 13, + 14, + 15, + 17, + 19 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "random.String(5)", + "boundaries": [ + 0, + 6, + 7, + 13, + 14, + 15 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "random.String(5, [\"a\", \"d\"])", + "boundaries": [ + 0, + 6, + 7, + 13, + 14, + 15, + 17, + 18, + 21, + 23, + 26, + 27 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "range(0, 10, 2)", + "boundaries": [ + 0, + 5, + 6, + 7, + 9, + 11, + 13, + 14 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "range(2, 5)", + "boundaries": [ + 0, + 5, + 6, + 7, + 9, + 10 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "range(5)", + "boundaries": [ + 0, + 5, + 6, + 7 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "regexp.Find(\"\\\\d+\", \"abc123def\")", + "boundaries": [ + 0, + 6, + 7, + 11, + 12, + 18, + 20, + 31 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "regexp.Find(\"llo\", \"hello\")", + "boundaries": [ + 0, + 6, + 7, + 11, + 12, + 17, + 19, + 26 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "regexp.Find(\"xyz\", \"hello\")", + "boundaries": [ + 0, + 6, + 7, + 11, + 12, + 17, + 19, + 26 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "regexp.FindAll(\"\\\\d\", 2, \"12345\")", + "boundaries": [ + 0, + 6, + 7, + 14, + 15, + 20, + 22, + 23, + 25, + 32 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "regexp.FindAll(\"a.\", -1, \"banana\")", + "boundaries": [ + 0, + 6, + 7, + 14, + 15, + 19, + 21, + 22, + 23, + 25, + 33 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "regexp.FindAll(\"z\", -1, \"hello\")", + "boundaries": [ + 0, + 6, + 7, + 14, + 15, + 18, + 20, + 21, + 22, + 24, + 31 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "regexp.Match(\"\\\\d+\", \"abc123\")", + "boundaries": [ + 0, + 6, + 7, + 12, + 13, + 19, + 21, + 29 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "regexp.Match(\"^b\", \"apple\")", + "boundaries": [ + 0, + 6, + 7, + 12, + 13, + 17, + 19, + 26 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "regexp.Match(\"^h.llo\", \"hello\")", + "boundaries": [ + 0, + 6, + 7, + 12, + 13, + 21, + 23, + 30 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "regexp.QuoteMeta(\"a.b\")", + "boundaries": [ + 0, + 6, + 7, + 16, + 17, + 22 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "regexp.QuoteMeta(\"abc\")", + "boundaries": [ + 0, + 6, + 7, + 16, + 17, + 22 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "regexp.Replace(\"\\\\d+\", \"num\", \"abc123\")", + "boundaries": [ + 0, + 6, + 7, + 14, + 15, + 21, + 23, + 28, + 30, + 38 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "regexp.Replace(\"a.\", \"x\", \"banana\")", + "boundaries": [ + 0, + 6, + 7, + 14, + 15, + 19, + 21, + 24, + 26, + 34 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "regexp.Replace(\"z\", \"x\", \"apple\")", + "boundaries": [ + 0, + 6, + 7, + 14, + 15, + 18, + 20, + 23, + 25, + 32 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "regexp.ReplaceLiteral(\"a.\", \"x\", \"a.b c.d\")", + "boundaries": [ + 0, + 6, + 7, + 21, + 22, + 26, + 28, + 31, + 33, + 42 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "regexp.ReplaceLiteral(\"apple\", \"orange\", \"apple pie\")", + "boundaries": [ + 0, + 6, + 7, + 21, + 22, + 29, + 31, + 39, + 41, + 52 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "regexp.Split(\"\\\\s\", 2, \"apple pie is delicious\")", + "boundaries": [ + 0, + 6, + 7, + 12, + 13, + 18, + 20, + 21, + 23, + 47 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "regexp.Split(\"a.\", -1, \"banana\")", + "boundaries": [ + 0, + 6, + 7, + 12, + 13, + 17, + 19, + 20, + 21, + 23, + 31 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "regexp.Split(\"z\", -1, \"hello\")", + "boundaries": [ + 0, + 6, + 7, + 12, + 13, + 16, + 18, + 19, + 20, + 22, + 29 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "return err", + "boundaries": [ + 0, + 7 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "script: \u003e", + "boundaries": [ + 0, + 6, + 8 + ], + "origin": "GO_TEMPLATE.md" + }, + { + "language": "cel", + "source": "sets.contains([1, 2, 3, 4], [2, 3])", + "boundaries": [ + 0, + 4, + 5, + 13, + 14, + 15, + 16, + 18, + 19, + 21, + 22, + 24, + 25, + 26, + 28, + 29, + 30, + 32, + 33, + 34 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "sets.contains([], [1])", + "boundaries": [ + 0, + 4, + 5, + 13, + 14, + 15, + 16, + 18, + 19, + 20, + 21 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "sets.contains([], [])", + "boundaries": [ + 0, + 4, + 5, + 13, + 14, + 15, + 16, + 18, + 19, + 20 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "sets.equivalent([1, 2, 3], [3u, 2.0, 1])", + "boundaries": [ + 0, + 4, + 5, + 15, + 16, + 17, + 18, + 20, + 21, + 23, + 24, + 25, + 27, + 28, + 30, + 32, + 35, + 37, + 38, + 39 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "sets.equivalent([1], [1, 1])", + "boundaries": [ + 0, + 4, + 5, + 15, + 16, + 17, + 18, + 19, + 21, + 22, + 23, + 25, + 26, + 27 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "sets.equivalent([], [])", + "boundaries": [ + 0, + 4, + 5, + 15, + 16, + 17, + 18, + 20, + 21, + 22 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "sets.intersects([1], [1, 2])", + "boundaries": [ + 0, + 4, + 5, + 15, + 16, + 17, + 18, + 19, + 21, + 22, + 23, + 25, + 26, + 27 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "sets.intersects([1], [])", + "boundaries": [ + 0, + 4, + 5, + 15, + 16, + 17, + 18, + 19, + 21, + 22, + 23 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "strings.quote('single-quote with \"double quote\"')", + "boundaries": [ + 0, + 7, + 8, + 13, + 14, + 48 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "time.Now()", + "boundaries": [ + 0, + 4, + 5, + 8, + 9 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "time.Parse(\"02-01-2006\", \"26-09-2023\")", + "boundaries": [ + 0, + 4, + 5, + 10, + 11, + 23, + 25, + 37 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "time.Parse(\"15:04 02-01-2006\", \"14:30 26-09-2023\")", + "boundaries": [ + 0, + 4, + 5, + 10, + 11, + 29, + 31, + 49 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "time.Parse(\"2006-01-02\", \"2023-09-26\")", + "boundaries": [ + 0, + 4, + 5, + 10, + 11, + 23, + 25, + 37 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "time.ParseDuration(\"-2h45m\")", + "boundaries": [ + 0, + 4, + 5, + 18, + 19, + 27 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "time.ParseDuration(\"1h30m\")", + "boundaries": [ + 0, + 4, + 5, + 18, + 19, + 26 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "time.ParseDuration(\"30d12h\")", + "boundaries": [ + 0, + 4, + 5, + 18, + 19, + 27 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "time.ParseDuration(\"7d\")", + "boundaries": [ + 0, + 4, + 5, + 18, + 19, + 23 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "time.ParseInLocation(\"02-01-2006\", \"Europe/London\", \"26-09-2023\")", + "boundaries": [ + 0, + 4, + 5, + 20, + 21, + 33, + 35, + 50, + 52, + 64 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "time.ParseInLocation(\"15:04 02-01-2006\", \"Asia/Tokyo\", \"14:30 26-09-2023\")", + "boundaries": [ + 0, + 4, + 5, + 20, + 21, + 39, + 41, + 53, + 55, + 73 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "time.ParseInLocation(\"2006-01-02\", \"America/New_York\", \"2023-09-26\")", + "boundaries": [ + 0, + 4, + 5, + 20, + 21, + 33, + 35, + 53, + 55, + 67 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "time.ParseLocal(\"2006-01-02 15:04\", \"2023-09-26 14:30\")", + "boundaries": [ + 0, + 4, + 5, + 15, + 16, + 34, + 36, + 54 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "time.Since(time.Now())", + "boundaries": [ + 0, + 4, + 5, + 10, + 11, + 15, + 16, + 19, + 20, + 21 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "time.Since(time.Parse(\"2006-01-02\", \"2023-09-26\"))", + "boundaries": [ + 0, + 4, + 5, + 10, + 11, + 15, + 16, + 21, + 22, + 34, + 36, + 48, + 49 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "time.Since(timestamp(\"2023-01-01T00:00:00Z\"))", + "boundaries": [ + 0, + 4, + 5, + 10, + 11, + 20, + 21, + 43, + 44 + ], + "origin": "README.md" + }, + { + "language": "cel", + "source": "time.Until(time.Parse(\"2006-01-02\", \"2023-10-01\"))", + "boundaries": [ + 0, + 4, + 5, + 10, + 11, + 15, + 16, + 21, + 22, + 34, + 36, + 48, + 49 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "time.ZoneName()", + "boundaries": [ + 0, + 4, + 5, + 13, + 14 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "time.ZoneOffset()", + "boundaries": [ + 0, + 4, + 5, + 15, + 16 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "timestamp(\"2023-01-01T00:00:00Z\")", + "boundaries": [ + 0, + 9, + 10, + 32 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "timestamp(\"2023-07-04T12:00:00Z\")", + "boundaries": [ + 0, + 9, + 10, + 32 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "true ? \"yes\" : \"no\"", + "boundaries": [ + 0, + 5, + 7, + 13, + 15 + ], + "origin": "README.md" + }, + { + "language": "cel", + "source": "type Config struct {", + "boundaries": [ + 0, + 5, + 12, + 19 + ], + "origin": "README.md" + }, + { + "language": "cel", + "source": "type Person struct {", + "boundaries": [ + 0, + 5, + 12, + 19 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "type(\"hello\")", + "boundaries": [ + 0, + 4, + 5, + 12 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "type(5)", + "boundaries": [ + 0, + 4, + 5, + 6 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "type([1, 2, 3])", + "boundaries": [ + 0, + 4, + 5, + 6, + 7, + 9, + 10, + 12, + 13, + 14 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "type({\"key\": \"value\"})", + "boundaries": [ + 0, + 4, + 5, + 6, + 11, + 13, + 20, + 21 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "urldecode(\"hello+world+%3F\")", + "boundaries": [ + 0, + 9, + 10, + 27 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "urlencode(\"hello world ?\")", + "boundaries": [ + 0, + 9, + 10, + 25 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "x \u003e 5 \u0026\u0026 y != null", + "boundaries": [ + 0, + 2, + 4, + 6, + 9, + 11, + 14 + ], + "origin": "README.md" + }, + { + "language": "cel", + "source": "{\"a\": \"apple\", \"b\": \"banana\"}.all(k, k.startsWith(\"a\"))", + "boundaries": [ + 0, + 1, + 4, + 6, + 13, + 15, + 18, + 20, + 28, + 29, + 30, + 33, + 34, + 35, + 37, + 38, + 39, + 49, + 50, + 53, + 54 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "{\"a\": \"apple\", \"b\": \"banana\"}.fold(k, v, acc, acc + v)", + "boundaries": [ + 0, + 1, + 4, + 6, + 13, + 15, + 18, + 20, + 28, + 29, + 30, + 34, + 35, + 36, + 38, + 39, + 41, + 44, + 46, + 50, + 52, + 53 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "{\"a\": \"b\", \"c\": \"d\"}.mapToKeyVal()", + "boundaries": [ + 0, + 1, + 4, + 6, + 9, + 11, + 14, + 16, + 19, + 20, + 21, + 32, + 33 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "{\"a\": 1, \"b\": 2}.size()", + "boundaries": [ + 0, + 1, + 4, + 6, + 7, + 9, + 12, + 14, + 15, + 16, + 17, + 21, + 22 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "{\"a\":1,\"b\":2}.keys()", + "boundaries": [ + 0, + 1, + 4, + 5, + 6, + 7, + 10, + 11, + 12, + 13, + 14, + 18, + 19 + ], + "origin": "README.md" + }, + { + "language": "cel", + "source": "{\"first\": \"John\", \"last\": \"Doe\"}.keys()", + "boundaries": [ + 0, + 1, + 8, + 10, + 16, + 18, + 24, + 26, + 31, + 32, + 33, + 37, + 38 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "{\"first\": \"John\", \"last\": \"Doe\"}.omit([\"first\"])", + "boundaries": [ + 0, + 1, + 8, + 10, + 16, + 18, + 24, + 26, + 31, + 32, + 33, + 37, + 38, + 39, + 46, + 47 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "{\"first\": \"John\"}.merge({\"last\": \"Doe\"})", + "boundaries": [ + 0, + 1, + 8, + 10, + 16, + 17, + 18, + 23, + 24, + 25, + 31, + 33, + 38, + 39 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "{'a': 'x', 'b': 'y', 'c': 'z'}.?c.orValue('empty')", + "boundaries": [ + 0, + 1, + 4, + 6, + 9, + 11, + 14, + 16, + 19, + 21, + 24, + 26, + 29, + 30, + 31, + 32, + 33, + 34, + 41, + 42, + 49 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "{'a': 'x', 'b': 'y'}.?c.orValue('empty')", + "boundaries": [ + 0, + 1, + 4, + 6, + 9, + 11, + 14, + 16, + 19, + 20, + 21, + 22, + 23, + 24, + 31, + 32, + 39 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "{'a': 1, 'b': 2}.values()", + "boundaries": [ + 0, + 1, + 4, + 6, + 7, + 9, + 12, + 14, + 15, + 16, + 17, + 23, + 24 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "{'name': 'John'}.toJSON()", + "boundaries": [ + 0, + 1, + 7, + 9, + 15, + 16, + 17, + 23, + 24 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "{'name': 'aditya'}.toJSONPretty('\\t')", + "boundaries": [ + 0, + 1, + 7, + 9, + 17, + 18, + 19, + 31, + 32, + 36 + ], + "origin": "CEL.md" + }, + { + "language": "cel", + "source": "}, gomplate.Template{", + "boundaries": [ + 0, + 1, + 3, + 11, + 12, + 20 + ], + "origin": "README.md" + }, + { + "language": "gomplate", + "source": "{{ $x := coll.Dict \"a\" 1 }}{{ $x }}", + "boundaries": null, + "origin": "edge-case" + }, + { + "language": "gomplate", + "source": "{{ .name | strings.ToUpper }}", + "boundaries": null, + "origin": "edge-case" + }, + { + "language": "gomplate", + "source": "{{ `raw string` }}", + "boundaries": null, + "origin": "edge-case" + }, + { + "language": "gomplate", + "source": "{{ printf \"%s-%d\" .name 3 }}", + "boundaries": null, + "origin": "edge-case" + }, + { + "language": "gomplate", + "source": "{{- if .enabled -}}on{{- else -}}off{{- end -}}", + "boundaries": null, + "origin": "edge-case" + }, + { + "language": "gomplate", + "source": "{{/* a comment with {{ braces }} in it */}}", + "boundaries": null, + "origin": "edge-case" + }, + { + "language": "jsonpath", + "source": "$..author", + "boundaries": null, + "origin": "edge-case" + }, + { + "language": "jsonpath", + "source": "$.items[*]", + "boundaries": null, + "origin": "edge-case" + }, + { + "language": "jsonpath", + "source": "$.items[0:2]", + "boundaries": null, + "origin": "edge-case" + }, + { + "language": "jsonpath", + "source": "$.store.book[0].title", + "boundaries": null, + "origin": "edge-case" + }, + { + "language": "jsonpath", + "source": "$.store.book[?(@.price \u003c 10)]", + "boundaries": null, + "origin": "edge-case" + }, + { + "language": "jsonpath", + "source": "$['quoted key']", + "boundaries": null, + "origin": "edge-case" + } +] diff --git a/packages/expressions/src/lang/generated/gomplate.config.json b/packages/expressions/src/lang/generated/gomplate.config.json new file mode 100644 index 000000000..decec0d59 --- /dev/null +++ b/packages/expressions/src/lang/generated/gomplate.config.json @@ -0,0 +1,55 @@ +{ + "comments": { + "blockComment": [ + "{{/*", + "*/}}" + ] + }, + "brackets": [ + [ + "{{", + "}}" + ], + [ + "(", + ")" + ], + [ + "[", + "]" + ] + ], + "autoClosingPairs": [ + { + "open": "{{", + "close": " }}" + }, + { + "open": "(", + "close": ")" + }, + { + "open": "\"", + "close": "\"" + }, + { + "open": "`", + "close": "`" + } + ], + "surroundingPairs": [ + { + "open": "(", + "close": ")" + }, + { + "open": "\"", + "close": "\"" + }, + { + "open": "`", + "close": "`" + } + ], + "wordPattern": "[A-Za-z_][A-Za-z0-9_]*(?:\\.[A-Za-z_][A-Za-z0-9_]*)*" +} diff --git a/packages/expressions/src/lang/generated/gomplate.monarch.json b/packages/expressions/src/lang/generated/gomplate.monarch.json new file mode 100644 index 000000000..6c33e4fb4 --- /dev/null +++ b/packages/expressions/src/lang/generated/gomplate.monarch.json @@ -0,0 +1,282 @@ +{ + "defaultToken": "source", + "tokenPostfix": ".gomplate", + "brackets": [ + { + "open": "(", + "close": ")", + "token": "delimiter.parenthesis" + }, + { + "open": "[", + "close": "]", + "token": "delimiter.square" + } + ], + "builtins": [ + "and", + "call", + "eq", + "ge", + "gt", + "html", + "index", + "js", + "le", + "len", + "lt", + "ne", + "not", + "or", + "print", + "printf", + "println", + "slice", + "urlquery" + ], + "functions": [ + "add", + "append", + "assert", + "bool", + "coalesce", + "contains", + "csv", + "csvByColumn", + "csvByRow", + "default", + "dict", + "div", + "endsWith", + "fail", + "first", + "flatten", + "getHealth", + "getStatus", + "has", + "hasPrefix", + "hasSuffix", + "humanDuration", + "humanSize", + "in_business_hours", + "indent", + "isHealthy", + "isKind", + "isReady", + "jmespath", + "join", + "jq", + "json", + "jsonArray", + "jsonpath", + "keyValToMap", + "keys", + "kind", + "last", + "mapToKeyVal", + "matchLabel", + "merge", + "mul", + "neat", + "parseDateTime", + "pow", + "prepend", + "quote", + "rem", + "replaceAll", + "required", + "reverse", + "semver", + "semverCompare", + "seq", + "shellQuote", + "slice", + "sort", + "split", + "splitN", + "squote", + "startsWith", + "sub", + "ternary", + "title", + "toCSV", + "toJSON", + "toJSONPretty", + "toLower", + "toTOML", + "toUpper", + "toYAML", + "toml", + "trim", + "uniq", + "urlParse", + "urldecode", + "urlencode", + "values", + "xpath", + "yaml", + "yamlArray" + ], + "keywords": [ + "block", + "break", + "continue", + "define", + "else", + "end", + "if", + "nil", + "range", + "template", + "with" + ], + "namespaces": [ + "base64", + "coll", + "conv", + "crypto", + "data", + "filepath", + "k8s", + "math", + "net", + "path", + "random", + "regexp", + "strings", + "test", + "time", + "uuid" + ], + "tokenizer": { + "root": [ + [ + "^#\\s*gotemplate:.*$", + "comment.directive" + ], + [ + "\\{\\{-?\\/\\*", + { + "next": "@tmplComment", + "token": "comment" + } + ], + [ + "\\{\\{-?", + { + "next": "@tmplAction", + "token": "delimiter.template" + } + ], + [ + "[\\s\\S]", + "source" + ] + ], + "tmplComment": [ + [ + "\\*\\/-?\\}\\}", + { + "next": "@pop", + "token": "comment" + } + ], + [ + "[\\s\\S]", + "comment" + ] + ], + "tmplAction": [ + [ + "-?\\}\\}", + { + "next": "@pop", + "token": "delimiter.template" + } + ], + [ + "[ \\t\\r\\n]+", + "white" + ], + [ + "\"(?:[^\"\\\\]|\\\\.)*\"", + "string" + ], + [ + "`[^`]*`", + "string" + ], + [ + "'(?:[^'\\\\]|\\\\.)*'", + "string" + ], + [ + "[+-]?(?:0[xX][0-9a-fA-F]+|(?:\\d+\\.\\d*|\\.\\d+|\\d+)(?:[eE][+-]?\\d+)?)", + "number" + ], + [ + "\\$[A-Za-z_][A-Za-z0-9_]*", + "variable" + ], + [ + "\\$", + "variable" + ], + [ + "(\\.)([A-Za-z_][A-Za-z0-9_]*)", + [ + "delimiter", + "variable.field" + ] + ], + [ + "\\.", + "variable.field" + ], + [ + "([A-Za-z_][A-Za-z0-9_]*)(\\.)([A-Za-z_][A-Za-z0-9_]*)", + { + "cases": { + "$1@namespaces": [ + "namespace", + "delimiter", + "function" + ], + "@default": [ + "identifier", + "delimiter", + "identifier" + ] + } + } + ], + [ + "[A-Za-z_][A-Za-z0-9_]*", + { + "cases": { + "@keywords": "keyword", + "@builtins": "function.builtin", + "@functions": "function", + "@default": "identifier" + } + } + ], + [ + "[()\\[\\]]", + "@brackets" + ], + [ + "\\|", + "operator.pipe" + ], + [ + ":=|=", + "operator" + ], + [ + ",", + "delimiter" + ] + ] + } +} diff --git a/packages/expressions/src/lang/generated/index.ts b/packages/expressions/src/lang/generated/index.ts new file mode 100644 index 000000000..e3447267d --- /dev/null +++ b/packages/expressions/src/lang/generated/index.ts @@ -0,0 +1,72 @@ +// Code generated by cmd/genmonarch. DO NOT EDIT. +// +// Regenerate with: make monarch +// The tokenizers come from cel-go's CEL.g4 and text/template's lexer; the +// function catalogue is read from a live cel.Env and gomplate's FuncMap. + +import type { ConformanceCase, LanguageDefinition, GomplateSpec } from "../types"; + +import celMonarch from "./cel.monarch.json"; +import celConfig from "./cel.config.json"; +import gomplateMonarch from "./gomplate.monarch.json"; +import gomplateConfig from "./gomplate.config.json"; +import jsonGomplateMonarch from "./json-gomplate.monarch.json"; +import jsonGomplateConfig from "./json-gomplate.config.json"; +import jsonpathMonarch from "./jsonpath.monarch.json"; +import jsonpathConfig from "./jsonpath.config.json"; +import textGomplateMonarch from "./text-gomplate.monarch.json"; +import textGomplateConfig from "./text-gomplate.config.json"; +import yamlGomplateMonarch from "./yaml-gomplate.monarch.json"; +import yamlGomplateConfig from "./yaml-gomplate.config.json"; +import specJson from "./spec.json"; +import conformanceJson from "./conformance.json"; + +export const spec = specJson as GomplateSpec; + +/** Token boundaries produced by the languages' real lexers. */ +export const conformance = conformanceJson as ConformanceCase[]; + +/** Every language id this package registers. */ +export const LANGUAGE_IDS = [ + "cel", + "gomplate", + "json-gomplate", + "jsonpath", + "text-gomplate", + "yaml-gomplate", +] as const; + +export type LanguageId = (typeof LANGUAGE_IDS)[number]; + +export const definitions: Record = { + "cel": { + id: "cel", + monarch: celMonarch as unknown as LanguageDefinition["monarch"], + configuration: celConfig as unknown as LanguageDefinition["configuration"], + }, + "gomplate": { + id: "gomplate", + monarch: gomplateMonarch as unknown as LanguageDefinition["monarch"], + configuration: gomplateConfig as unknown as LanguageDefinition["configuration"], + }, + "json-gomplate": { + id: "json-gomplate", + monarch: jsonGomplateMonarch as unknown as LanguageDefinition["monarch"], + configuration: jsonGomplateConfig as unknown as LanguageDefinition["configuration"], + }, + "jsonpath": { + id: "jsonpath", + monarch: jsonpathMonarch as unknown as LanguageDefinition["monarch"], + configuration: jsonpathConfig as unknown as LanguageDefinition["configuration"], + }, + "text-gomplate": { + id: "text-gomplate", + monarch: textGomplateMonarch as unknown as LanguageDefinition["monarch"], + configuration: textGomplateConfig as unknown as LanguageDefinition["configuration"], + }, + "yaml-gomplate": { + id: "yaml-gomplate", + monarch: yamlGomplateMonarch as unknown as LanguageDefinition["monarch"], + configuration: yamlGomplateConfig as unknown as LanguageDefinition["configuration"], + }, +}; diff --git a/packages/expressions/src/lang/generated/json-gomplate.config.json b/packages/expressions/src/lang/generated/json-gomplate.config.json new file mode 100644 index 000000000..c087049ba --- /dev/null +++ b/packages/expressions/src/lang/generated/json-gomplate.config.json @@ -0,0 +1,31 @@ +{ + "brackets": [ + [ + "{", + "}" + ], + [ + "[", + "]" + ] + ], + "autoClosingPairs": [ + { + "open": "{", + "close": "}" + }, + { + "open": "[", + "close": "]" + }, + { + "open": "\"", + "close": "\"" + }, + { + "open": "'", + "close": "'" + } + ], + "wordPattern": "[A-Za-z_][A-Za-z0-9_]*(?:\\.[A-Za-z_][A-Za-z0-9_]*)*" +} diff --git a/packages/expressions/src/lang/generated/json-gomplate.monarch.json b/packages/expressions/src/lang/generated/json-gomplate.monarch.json new file mode 100644 index 000000000..63a506dce --- /dev/null +++ b/packages/expressions/src/lang/generated/json-gomplate.monarch.json @@ -0,0 +1,353 @@ +{ + "defaultToken": "", + "tokenPostfix": ".json-gomplate", + "brackets": [ + { + "open": "{", + "close": "}", + "token": "delimiter.curly" + }, + { + "open": "[", + "close": "]", + "token": "delimiter.square" + }, + { + "open": "(", + "close": ")", + "token": "delimiter.parenthesis" + } + ], + "builtins": [ + "and", + "call", + "eq", + "ge", + "gt", + "html", + "index", + "js", + "le", + "len", + "lt", + "ne", + "not", + "or", + "print", + "printf", + "println", + "slice", + "urlquery" + ], + "functions": [ + "add", + "append", + "assert", + "bool", + "coalesce", + "contains", + "csv", + "csvByColumn", + "csvByRow", + "default", + "dict", + "div", + "endsWith", + "fail", + "first", + "flatten", + "getHealth", + "getStatus", + "has", + "hasPrefix", + "hasSuffix", + "humanDuration", + "humanSize", + "in_business_hours", + "indent", + "isHealthy", + "isKind", + "isReady", + "jmespath", + "join", + "jq", + "json", + "jsonArray", + "jsonpath", + "keyValToMap", + "keys", + "kind", + "last", + "mapToKeyVal", + "matchLabel", + "merge", + "mul", + "neat", + "parseDateTime", + "pow", + "prepend", + "quote", + "rem", + "replaceAll", + "required", + "reverse", + "semver", + "semverCompare", + "seq", + "shellQuote", + "slice", + "sort", + "split", + "splitN", + "squote", + "startsWith", + "sub", + "ternary", + "title", + "toCSV", + "toJSON", + "toJSONPretty", + "toLower", + "toTOML", + "toUpper", + "toYAML", + "toml", + "trim", + "uniq", + "urlParse", + "urldecode", + "urlencode", + "values", + "xpath", + "yaml", + "yamlArray" + ], + "keywords": [ + "block", + "break", + "continue", + "define", + "else", + "end", + "if", + "nil", + "range", + "template", + "with" + ], + "namespaces": [ + "base64", + "coll", + "conv", + "crypto", + "data", + "filepath", + "k8s", + "math", + "net", + "path", + "random", + "regexp", + "strings", + "test", + "time", + "uuid" + ], + "tokenizer": { + "root": [ + [ + "^#\\s*gotemplate:.*$", + "comment.directive" + ], + [ + "\\{\\{-?\\/\\*", + { + "next": "@tmplComment", + "token": "comment" + } + ], + [ + "\\{\\{-?", + { + "next": "@tmplAction", + "token": "delimiter.template" + } + ], + [ + "(\"(?:[^\"\\\\{]|\\\\.)*\")(\\s*)(:)", + [ + "type.json", + "white", + "delimiter" + ] + ], + [ + "\"", + { + "next": "@jsonString", + "token": "string" + } + ], + [ + "\\b(?:true|false|null)\\b", + "keyword.constant" + ], + [ + "-?(?:0|[1-9]\\d*)(?:\\.\\d+)?(?:[eE][+-]?\\d+)?", + "number" + ], + [ + "[{}\\[\\]]", + "@brackets" + ], + [ + "[,:]", + "delimiter" + ], + [ + "[\\s\\S]", + "" + ] + ], + "jsonString": [ + [ + "\\{\\{-?\\/\\*", + { + "next": "@tmplComment", + "token": "comment" + } + ], + [ + "\\{\\{-?", + { + "next": "@tmplAction", + "token": "delimiter.template" + } + ], + [ + "\"", + { + "next": "@pop", + "token": "string" + } + ], + [ + "\\\\.", + "string.escape" + ], + [ + "[^\"\\\\{]+", + "string" + ], + [ + "[\\s\\S]", + "string" + ] + ], + "tmplComment": [ + [ + "\\*\\/-?\\}\\}", + { + "next": "@pop", + "token": "comment" + } + ], + [ + "[\\s\\S]", + "comment" + ] + ], + "tmplAction": [ + [ + "-?\\}\\}", + { + "next": "@pop", + "token": "delimiter.template" + } + ], + [ + "[ \\t\\r\\n]+", + "white" + ], + [ + "\"(?:[^\"\\\\]|\\\\.)*\"", + "string" + ], + [ + "`[^`]*`", + "string" + ], + [ + "'(?:[^'\\\\]|\\\\.)*'", + "string" + ], + [ + "[+-]?(?:0[xX][0-9a-fA-F]+|(?:\\d+\\.\\d*|\\.\\d+|\\d+)(?:[eE][+-]?\\d+)?)", + "number" + ], + [ + "\\$[A-Za-z_][A-Za-z0-9_]*", + "variable" + ], + [ + "\\$", + "variable" + ], + [ + "(\\.)([A-Za-z_][A-Za-z0-9_]*)", + [ + "delimiter", + "variable.field" + ] + ], + [ + "\\.", + "variable.field" + ], + [ + "([A-Za-z_][A-Za-z0-9_]*)(\\.)([A-Za-z_][A-Za-z0-9_]*)", + { + "cases": { + "$1@namespaces": [ + "namespace", + "delimiter", + "function" + ], + "@default": [ + "identifier", + "delimiter", + "identifier" + ] + } + } + ], + [ + "[A-Za-z_][A-Za-z0-9_]*", + { + "cases": { + "@keywords": "keyword", + "@builtins": "function.builtin", + "@functions": "function", + "@default": "identifier" + } + } + ], + [ + "[()\\[\\]]", + "@brackets" + ], + [ + "\\|", + "operator.pipe" + ], + [ + ":=|=", + "operator" + ], + [ + ",", + "delimiter" + ] + ] + } +} diff --git a/packages/expressions/src/lang/generated/jsonpath.config.json b/packages/expressions/src/lang/generated/jsonpath.config.json new file mode 100644 index 000000000..a5ec4c9db --- /dev/null +++ b/packages/expressions/src/lang/generated/jsonpath.config.json @@ -0,0 +1,31 @@ +{ + "brackets": [ + [ + "[", + "]" + ], + [ + "(", + ")" + ] + ], + "autoClosingPairs": [ + { + "open": "[", + "close": "]" + }, + { + "open": "(", + "close": ")" + }, + { + "open": "\"", + "close": "\"" + }, + { + "open": "'", + "close": "'" + } + ], + "wordPattern": "[A-Za-z_][A-Za-z0-9_]*" +} diff --git a/packages/expressions/src/lang/generated/jsonpath.monarch.json b/packages/expressions/src/lang/generated/jsonpath.monarch.json new file mode 100644 index 000000000..ed02d2e1b --- /dev/null +++ b/packages/expressions/src/lang/generated/jsonpath.monarch.json @@ -0,0 +1,103 @@ +{ + "defaultToken": "", + "tokenPostfix": ".jsonpath", + "brackets": [ + { + "open": "[", + "close": "]", + "token": "delimiter.square" + }, + { + "open": "(", + "close": ")", + "token": "delimiter.parenthesis" + } + ], + "filterOperators": [ + "!", + "!=", + "\u0026\u0026", + "*", + "+", + "-", + "/", + "\u003c", + "\u003c=", + "==", + "=~", + "\u003e", + "\u003e=", + "||" + ], + "tokenizer": { + "root": [ + [ + "\\$", + "variable.root" + ], + [ + "@", + "variable.current" + ], + [ + "\\.\\.", + "operator.descendant" + ], + [ + "\\*", + "operator.wildcard" + ], + [ + "(\\.)([A-Za-z_][A-Za-z0-9_]*)", + [ + "delimiter", + "variable.field" + ] + ], + [ + "\\.", + "delimiter" + ], + [ + "\\?\\(", + "keyword.filter" + ], + [ + "[\\[\\]()]", + "@brackets" + ], + [ + "\"(?:[^\"\\\\]|\\\\.)*\"", + "string" + ], + [ + "'(?:[^'\\\\]|\\\\.)*'", + "string" + ], + [ + "\\b(?:true|false|null)\\b", + "keyword.constant" + ], + [ + "-?\\d+(?:\\.\\d+)?(?:[eE][+-]?\\d+)?", + "number" + ], + [ + "(?:==|!=|\u003c=|\u003e=|\u0026\u0026|\\|\\||=~|[\u003c\u003e!+\\-*/])", + "operator" + ], + [ + ":", + "operator.slice" + ], + [ + ",", + "delimiter" + ], + [ + "[A-Za-z_][A-Za-z0-9_]*", + "variable.field" + ] + ] + } +} diff --git a/packages/expressions/src/lang/generated/spec.json b/packages/expressions/src/lang/generated/spec.json new file mode 100644 index 000000000..57a4fd95c --- /dev/null +++ b/packages/expressions/src/lang/generated/spec.json @@ -0,0 +1,6189 @@ +{ + "cel": { + "namespaces": [ + "aws", + "base64", + "crypto", + "data", + "filepath", + "json", + "k8s", + "lists", + "math", + "net", + "optional", + "random", + "regexp", + "sets", + "strings", + "test", + "time", + "uuid" + ], + "keywords": [ + "as", + "break", + "const", + "continue", + "else", + "false", + "for", + "function", + "if", + "import", + "in", + "let", + "loop", + "namespace", + "null", + "package", + "return", + "true", + "type", + "var", + "void", + "while" + ], + "types": [ + "bool", + "bytes", + "double", + "dyn", + "duration", + "int", + "list", + "map", + "null_type", + "string", + "timestamp", + "type", + "uint" + ], + "macros": [ + { + "name": "all", + "argCount": 2, + "receiverStyle": true, + "doc": "tests whether all elements in the input list or all keys in a map\nsatisfy the given predicate. The all macro behaves in a manner consistent with\nthe Logical AND operator including in how it absorbs errors and short-circuits.", + "examples": [ + "[1, 2, 3].all(x, x \u003e 0) // true", + "[1, 2, 0].all(x, x \u003e 0) // false", + "['apple', 'banana', 'cherry'].all(fruit, fruit.size() \u003e 3) // true", + "[3.14, 2.71, 1.61].all(num, num \u003c 3.0) // false", + "{'a': 1, 'b': 2, 'c': 3}.all(key, key != 'b') // false", + "// an empty list or map as the range will result in a trivially true result\n[].all(x, x \u003e 0) // true" + ] + }, + { + "name": "exists", + "argCount": 2, + "receiverStyle": true, + "doc": "tests whether any value in the list or any key in the map\nsatisfies the predicate expression. The exists macro behaves in a manner\nconsistent with the Logical OR operator including in how it absorbs errors and\nshort-circuits.", + "examples": [ + "[1, 2, 3].exists(i, i % 2 != 0) // true", + "[0, -1, 5].exists(num, num \u003c 0) // true", + "{'x': 'foo', 'y': 'bar'}.exists(key, key.startsWith('z')) // false", + "// an empty list or map as the range will result in a trivially false result\n[].exists(i, i \u003e 0) // false", + "// test whether a key name equalling 'iss' exists in the map and the\n// value contains the substring 'cel.dev'\n// tokens = {'sub': 'me', 'iss': 'https://issuer.cel.dev'}\ntokens.exists(k, k == 'iss' \u0026\u0026 tokens[k].contains('cel.dev'))" + ] + }, + { + "name": "exists_one", + "argCount": 2, + "receiverStyle": true, + "doc": "tests whether exactly one list element or map key satisfies\nthe predicate expression. This macro does not short-circuit in order to remain\nconsistent with logical operators being the only operators which can absorb\nerrors within CEL.", + "examples": [ + "[1, 2, 2].exists_one(i, i \u003c 2) // true", + "{'a': 'hello', 'aa': 'hellohello'}.exists_one(k, k.startsWith('a')) // false", + "[1, 2, 3, 4].exists_one(num, num % 2 == 0) // false", + "// ensure exactly one key in the map ends in @acme.co\n{'wiley@acme.co': 'coyote', 'aa@milne.co': 'bear'}.exists_one(k, k.endsWith('@acme.co')) // true" + ] + }, + { + "name": "filter", + "argCount": 2, + "receiverStyle": true, + "doc": "returns a list containing only the elements from the input list\nthat satisfy the given predicate", + "examples": [ + "[1, 2, 3].filter(x, x \u003e 1) // [2, 3]", + "['cat', 'dog', 'bird', 'fish'].filter(pet, pet.size() == 3) // ['cat', 'dog']", + "[{'a': 10, 'b': 5, 'c': 20}].map(m, m.filter(key, m[key] \u003e 10)) // [['c']]", + "// filter a list to select only emails with the @cel.dev suffix\n['alice@buf.io', 'tristan@cel.dev'].filter(v, v.endsWith('@cel.dev')) // ['tristan@cel.dev']", + "// filter a map into a list, selecting only the values for keys that start with 'http-auth'\n{'http-auth-agent': 'secret', 'user-agent': 'mozilla'}.filter(k,\n k.startsWith('http-auth')) // ['secret']" + ] + }, + { + "name": "fold", + "argCount": 3, + "receiverStyle": true, + "doc": "Folds a list using an element variable, an accumulator variable, and a step expression.", + "examples": [ + "[1, 2, 3].fold(e, acc, acc + e) // 6" + ] + }, + { + "name": "fold", + "argCount": 4, + "receiverStyle": true, + "doc": "Folds a map using key/value variables, an accumulator variable, and a step expression.", + "examples": [ + "{\"a\": \"apple\", \"b\": \"banana\"}.fold(k, v, acc, acc + v) // \"applebanana\"" + ] + }, + { + "name": "greatest", + "argCount": 0, + "receiverStyle": true + }, + { + "name": "has", + "argCount": 1, + "receiverStyle": false, + "doc": "check a protocol buffer message for the presence of a field, or check a map\nfor the presence of a string key.\nOnly map accesses using the select notation are supported.", + "examples": [ + "// true if the 'address' field exists in the 'user' message\nhas(user.address)", + "// test whether the 'key_name' is set on the map which defines it\nhas({'key_name': 'value'}.key_name) // true", + "// test whether the 'id' field is set to a non-default value on the Expr{} message literal\nhas(Expr{}.id) // false" + ] + }, + { + "name": "least", + "argCount": 0, + "receiverStyle": true + }, + { + "name": "map", + "argCount": 2, + "receiverStyle": true, + "doc": "the three-argument form of map transforms all elements in the input range.", + "examples": [ + "[1, 2, 3].map(x, x * 2) // [2, 4, 6]", + "[5, 10, 15].map(x, x / 5) // [1, 2, 3]", + "['apple', 'banana'].map(fruit, fruit.upperAscii()) // ['APPLE', 'BANANA']", + "// Combine all map key-value pairs into a list\n{'hi': 'you', 'howzit': 'bruv'}.map(k,\n k + \":\" + {'hi': 'you', 'howzit': 'bruv'}[k]) // ['hi:you', 'howzit:bruv']" + ] + }, + { + "name": "map", + "argCount": 3, + "receiverStyle": true, + "doc": "the four-argument form of the map transforms only elements which satisfy\nthe predicate which is equivalent to chaining the filter and three-argument\nmap macros together.", + "examples": [ + "// multiply only numbers divisible two, by 2\n[1, 2, 3, 4].map(num, num % 2 == 0, num * 2) // [4, 8]" + ] + }, + { + "name": "optFlatMap", + "argCount": 2, + "receiverStyle": true, + "doc": "perform computation on the value if present and produce an optional value within the computation", + "examples": [ + "// m = {'key': {}}\nm.?key.optFlatMap(k, k.?subkey) // optional.none()", + "// m = {'key': {'subkey': 'value'}}\nm.?key.optFlatMap(k, k.?subkey) // optional.of('value')" + ] + }, + { + "name": "optMap", + "argCount": 2, + "receiverStyle": true, + "doc": "perform computation on the value if present and return the result as an optional", + "examples": [ + "// sub with the prefix 'dev.cel' or optional.none()\nrequest.auth.tokens.?sub.optMap(id, 'dev.cel.' + id)", + "optional.none().optMap(i, i * 2) // optional.none()" + ] + }, + { + "name": "sortBy", + "argCount": 2, + "receiverStyle": true + } + ], + "functions": [ + { + "name": "Age", + "overloads": [ + { + "id": "duration.Age", + "args": [ + "google.protobuf.Any" + ], + "result": "google.protobuf.Duration" + } + ] + }, + { + "name": "Append", + "overloads": [ + { + "id": "Append_interface{}_interface{}", + "args": [ + "dyn", + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "CSV", + "overloads": [ + { + "id": "CSV_interface{}", + "args": [ + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "Contains", + "overloads": [ + { + "id": "Contains_string_interface{}", + "args": [ + "string", + "dyn" + ], + "result": "bool" + } + ] + }, + { + "name": "Dict", + "overloads": [ + { + "id": "Dict_interface{}", + "args": [ + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "Duration", + "overloads": [ + { + "id": "duration.Duration", + "args": [ + "string" + ], + "result": "google.protobuf.Duration" + } + ] + }, + { + "name": "GetHealth", + "overloads": [ + { + "id": "GetHealth_overload", + "args": [ + "google.protobuf.Any" + ], + "result": "google.protobuf.Any" + } + ] + }, + { + "name": "GetStatus", + "overloads": [ + { + "id": "GetStatus_overload", + "args": [ + "google.protobuf.Any" + ], + "result": "google.protobuf.Any" + } + ] + }, + { + "name": "Has", + "overloads": [ + { + "id": "Has_interface{}_any", + "args": [ + "dyn", + "string" + ], + "result": "bool" + } + ] + }, + { + "name": "HumanDuration", + "overloads": [ + { + "id": "HumanDuration_interface{}", + "args": [ + "dyn" + ], + "result": "string" + } + ] + }, + { + "name": "HumanSize", + "overloads": [ + { + "id": "HumanSize_interface{}", + "args": [ + "dyn" + ], + "result": "string" + } + ] + }, + { + "name": "IsHealthy", + "overloads": [ + { + "id": "IsHealthy_overload", + "args": [ + "google.protobuf.Any" + ], + "result": "bool" + } + ] + }, + { + "name": "IsReady", + "overloads": [ + { + "id": "IsReady_overload", + "args": [ + "google.protobuf.Any" + ], + "result": "bool" + } + ] + }, + { + "name": "JSON", + "memberOnly": true, + "overloads": [ + { + "id": ".string.JSON()", + "args": [ + "string" + ], + "result": "dyn", + "member": true + } + ] + }, + { + "name": "JSONArray", + "memberOnly": true, + "overloads": [ + { + "id": ".string.JSONArray()", + "args": [ + "string" + ], + "result": "dyn", + "member": true + } + ] + }, + { + "name": "Prepend", + "overloads": [ + { + "id": "Prepend_interface{}_interface{}", + "args": [ + "dyn", + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "Semver", + "overloads": [ + { + "id": "Semver_string", + "args": [ + "string" + ], + "result": "dyn" + } + ] + }, + { + "name": "SemverCompare", + "overloads": [ + { + "id": "SemverCompare_string_string", + "args": [ + "string", + "string" + ], + "result": "bool" + } + ] + }, + { + "name": "Sort", + "overloads": [ + { + "id": "Sort_string", + "args": [ + "string" + ], + "result": "dyn" + } + ] + }, + { + "name": "SplitN", + "overloads": [ + { + "id": "SplitN_string_int_interface{}", + "args": [ + "string", + "int", + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "TOML", + "overloads": [ + { + "id": "TOML_interface{}", + "args": [ + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "YAML", + "overloads": [ + { + "id": "YAML_interface{}", + "args": [ + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "YAMLArray", + "overloads": [ + { + "id": "data.YAMLArray_interface{}", + "args": [ + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "abbrev", + "memberOnly": true, + "overloads": [ + { + "id": "stringsAbbrevWidthAndOffsetGen", + "args": [ + "string", + "int", + "int" + ], + "result": "string", + "member": true + }, + { + "id": "stringsAbbrevWidthGen", + "args": [ + "string", + "int" + ], + "result": "string", + "member": true + } + ] + }, + { + "name": "age", + "overloads": [ + { + "id": "duration.age", + "args": [ + "google.protobuf.Any" + ], + "result": "google.protobuf.Duration" + } + ] + }, + { + "name": "arnToMap", + "overloads": [ + { + "id": "arnToMap_overload", + "args": [ + "string" + ], + "result": "map(string, string)" + } + ] + }, + { + "name": "aws.arnToMap", + "namespace": "aws", + "overloads": [ + { + "id": "aws.arnToMap_overload", + "args": [ + "string" + ], + "result": "map(string, string)" + } + ] + }, + { + "name": "aws.fromAWSMap", + "namespace": "aws", + "overloads": [ + { + "id": "aws.fromAWSMap_overload", + "args": [ + "list(map(string, string))" + ], + "result": "map(string, string)" + } + ] + }, + { + "name": "base64.decode", + "namespace": "base64", + "overloads": [ + { + "id": "base64_decode_string", + "args": [ + "string" + ], + "result": "bytes" + } + ] + }, + { + "name": "base64.encode", + "namespace": "base64", + "overloads": [ + { + "id": "base64_encode_bytes", + "args": [ + "bytes" + ], + "result": "string" + } + ] + }, + { + "name": "bool", + "doc": "convert a value to a boolean", + "overloads": [ + { + "id": "bool_to_bool", + "args": [ + "bool" + ], + "result": "bool" + }, + { + "id": "string_to_bool", + "args": [ + "string" + ], + "result": "bool" + } + ], + "examples": [ + "bool(true) // true", + "bool('true') // true\nbool('false') // false" + ] + }, + { + "name": "bytes", + "doc": "convert a value to bytes", + "overloads": [ + { + "id": "bytes_to_bytes", + "args": [ + "bytes" + ], + "result": "bytes" + }, + { + "id": "string_to_bytes", + "args": [ + "string" + ], + "result": "bytes" + } + ], + "examples": [ + "bytes(b'abc') // b'abc'", + "bytes('hello') // b'hello'" + ] + }, + { + "name": "camelCase", + "memberOnly": true, + "overloads": [ + { + "id": "string_camel_case", + "args": [ + "string" + ], + "result": "string", + "member": true + } + ] + }, + { + "name": "charAt", + "memberOnly": true, + "overloads": [ + { + "id": "string_char_at_int", + "args": [ + "string", + "int" + ], + "result": "string", + "member": true + } + ] + }, + { + "name": "coalesce", + "overloads": [ + { + "id": "coalesce_1", + "args": [ + "dyn" + ], + "result": "dyn" + }, + { + "id": "coalesce_2", + "args": [ + "dyn", + "dyn" + ], + "result": "dyn" + }, + { + "id": "coalesce_3", + "args": [ + "dyn", + "dyn", + "dyn" + ], + "result": "dyn" + }, + { + "id": "coalesce_4", + "args": [ + "dyn", + "dyn", + "dyn", + "dyn" + ], + "result": "dyn" + }, + { + "id": "coalesce_5", + "args": [ + "dyn", + "dyn", + "dyn", + "dyn", + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "contains", + "memberOnly": true, + "doc": "test whether a string contains a substring", + "overloads": [ + { + "id": "contains_string", + "args": [ + "string", + "string" + ], + "result": "bool", + "member": true + }, + { + "id": "list_a_contains_bool", + "args": [ + "list(\u003cA\u003e)", + "\u003cA\u003e" + ], + "result": "bool", + "member": true + } + ], + "examples": [ + "'hello world'.contains('o w') // true\n'hello world'.contains('goodbye') // false" + ] + }, + { + "name": "crypto.SHA1", + "namespace": "crypto", + "overloads": [ + { + "id": "crypto.SHA1_interface{}", + "args": [ + "dyn" + ], + "result": "string" + } + ] + }, + { + "name": "crypto.SHA1Bytes", + "namespace": "crypto", + "overloads": [ + { + "id": "crypto.SHA1Bytes_interface{}", + "args": [ + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "crypto.SHA224", + "namespace": "crypto", + "overloads": [ + { + "id": "crypto.SHA224_interface{}", + "args": [ + "dyn" + ], + "result": "string" + } + ] + }, + { + "name": "crypto.SHA224Bytes", + "namespace": "crypto", + "overloads": [ + { + "id": "crypto.SHA224Bytes_interface{}", + "args": [ + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "crypto.SHA256", + "namespace": "crypto", + "overloads": [ + { + "id": "crypto.SHA256_interface{}", + "args": [ + "dyn" + ], + "result": "string" + } + ] + }, + { + "name": "crypto.SHA256Bytes", + "namespace": "crypto", + "overloads": [ + { + "id": "crypto.SHA256Bytes_interface{}", + "args": [ + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "crypto.SHA384", + "namespace": "crypto", + "overloads": [ + { + "id": "crypto.SHA384_interface{}", + "args": [ + "dyn" + ], + "result": "string" + } + ] + }, + { + "name": "crypto.SHA384Bytes", + "namespace": "crypto", + "overloads": [ + { + "id": "crypto.SHA384Bytes_interface{}", + "args": [ + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "crypto.SHA512", + "namespace": "crypto", + "overloads": [ + { + "id": "crypto.SHA512_interface{}", + "args": [ + "dyn" + ], + "result": "string" + } + ] + }, + { + "name": "crypto.SHA512Bytes", + "namespace": "crypto", + "overloads": [ + { + "id": "crypto.SHA512Bytes_interface{}", + "args": [ + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "crypto.SHA512_224", + "namespace": "crypto", + "overloads": [ + { + "id": "crypto.SHA512_224_interface{}", + "args": [ + "dyn" + ], + "result": "string" + } + ] + }, + { + "name": "crypto.SHA512_224Bytes", + "namespace": "crypto", + "overloads": [ + { + "id": "crypto.SHA512_224Bytes_interface{}", + "args": [ + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "crypto.SHA512_256", + "namespace": "crypto", + "overloads": [ + { + "id": "crypto.SHA512_256_interface{}", + "args": [ + "dyn" + ], + "result": "string" + } + ] + }, + { + "name": "crypto.SHA512_256Bytes", + "namespace": "crypto", + "overloads": [ + { + "id": "crypto.SHA512_256Bytes_interface{}", + "args": [ + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "data.CSVByColumn", + "namespace": "data", + "overloads": [ + { + "id": "data.CSVByColumn_string", + "args": [ + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "data.CSVByRow", + "namespace": "data", + "overloads": [ + { + "id": "data.CSVByRow_string", + "args": [ + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "debug", + "overloads": [ + { + "id": "debug_dyn", + "args": [ + "dyn" + ], + "result": "dyn" + }, + { + "id": "debug_string_dyn", + "args": [ + "string", + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "distinct", + "memberOnly": true, + "overloads": [ + { + "id": "list_distinct", + "args": [ + "list(\u003cT\u003e)" + ], + "result": "list(\u003cT\u003e)", + "member": true + } + ] + }, + { + "name": "double", + "doc": "convert a value to a double", + "overloads": [ + { + "id": "double_to_double", + "args": [ + "double" + ], + "result": "double" + }, + { + "id": "int64_to_double", + "args": [ + "int" + ], + "result": "double" + }, + { + "id": "string_to_double", + "args": [ + "string" + ], + "result": "double" + }, + { + "id": "uint64_to_double", + "args": [ + "uint" + ], + "result": "double" + } + ], + "examples": [ + "double(1.23) // 1.23", + "double(123) // 123.0", + "double('1.23') // 1.23", + "double(123u) // 123.0" + ] + }, + { + "name": "duration", + "doc": "convert a value to a google.protobuf.Duration", + "overloads": [ + { + "id": "double.duration", + "args": [ + "double" + ], + "result": "google.protobuf.Duration" + }, + { + "id": "duration_to_duration", + "args": [ + "google.protobuf.Duration" + ], + "result": "google.protobuf.Duration" + }, + { + "id": "string_to_duration", + "args": [ + "string" + ], + "result": "google.protobuf.Duration" + } + ], + "examples": [ + "duration(duration('1s')) // duration('1s')", + "duration('1h2m3s') // duration('3723s')" + ] + }, + { + "name": "dyn", + "doc": "indicate that the type is dynamic for type-checking purposes", + "overloads": [ + { + "id": "to_dyn", + "args": [ + "\u003cA\u003e" + ], + "result": "dyn" + } + ], + "examples": [ + "dyn(1) // 1" + ] + }, + { + "name": "endsWith", + "memberOnly": true, + "doc": "test whether a string ends with a substring suffix", + "overloads": [ + { + "id": "ends_with_string", + "args": [ + "string", + "string" + ], + "result": "bool", + "member": true + } + ], + "examples": [ + "'hello world'.endsWith('world') // true\n'hello world'.endsWith('hello') // false" + ] + }, + { + "name": "f", + "overloads": [ + { + "id": "f_string_any", + "args": [ + "string", + "dyn" + ], + "result": "string" + } + ] + }, + { + "name": "filepath.Base", + "namespace": "filepath", + "overloads": [ + { + "id": "filepath.Base_interface{}", + "args": [ + "dyn" + ], + "result": "string" + } + ] + }, + { + "name": "filepath.Clean", + "namespace": "filepath", + "overloads": [ + { + "id": "filepath.Clean_interface{}", + "args": [ + "dyn" + ], + "result": "string" + } + ] + }, + { + "name": "filepath.Dir", + "namespace": "filepath", + "overloads": [ + { + "id": "filepath.Dir_interface{}", + "args": [ + "dyn" + ], + "result": "string" + } + ] + }, + { + "name": "filepath.Ext", + "namespace": "filepath", + "overloads": [ + { + "id": "filepath.Ext_interface{}", + "args": [ + "dyn" + ], + "result": "string" + } + ] + }, + { + "name": "filepath.FromSlash", + "namespace": "filepath", + "overloads": [ + { + "id": "filepath.FromSlash_interface{}", + "args": [ + "dyn" + ], + "result": "string" + } + ] + }, + { + "name": "filepath.IsAbs", + "namespace": "filepath", + "overloads": [ + { + "id": "filepath.IsAbs_interface{}", + "args": [ + "dyn" + ], + "result": "bool" + } + ] + }, + { + "name": "filepath.Join", + "namespace": "filepath", + "overloads": [ + { + "id": "filepath.Join_interface{}", + "args": [ + "dyn" + ], + "result": "string" + } + ] + }, + { + "name": "filepath.Match", + "namespace": "filepath", + "overloads": [ + { + "id": "filepath.Match_interface{}_interface{}", + "args": [ + "dyn", + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "filepath.Rel", + "namespace": "filepath", + "overloads": [ + { + "id": "filepath.Rel_interface{}_interface{}", + "args": [ + "dyn", + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "filepath.Split", + "namespace": "filepath", + "overloads": [ + { + "id": "filepath.Split_interface{}", + "args": [ + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "filepath.ToSlash", + "namespace": "filepath", + "overloads": [ + { + "id": "filepath.ToSlash_interface{}", + "args": [ + "dyn" + ], + "result": "string" + } + ] + }, + { + "name": "filepath.VolumeName", + "namespace": "filepath", + "overloads": [ + { + "id": "filepath.VolumeName_interface{}", + "args": [ + "dyn" + ], + "result": "string" + } + ] + }, + { + "name": "find", + "memberOnly": true, + "overloads": [ + { + "id": "string_find_string", + "args": [ + "string", + "string" + ], + "result": "string", + "member": true + } + ] + }, + { + "name": "findAll", + "memberOnly": true, + "overloads": [ + { + "id": "string_find_all_string", + "args": [ + "string", + "string" + ], + "result": "list(string)", + "member": true + }, + { + "id": "string_find_all_string_int", + "args": [ + "string", + "string", + "int" + ], + "result": "list(string)", + "member": true + } + ] + }, + { + "name": "first", + "overloads": [ + { + "id": "dyn_first", + "args": [ + "dyn" + ], + "result": "dyn", + "member": true + }, + { + "id": "first_dyn", + "args": [ + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "flatten", + "memberOnly": true, + "overloads": [ + { + "id": "list_flatten", + "args": [ + "list(list(\u003cT\u003e))" + ], + "result": "list(\u003cT\u003e)", + "member": true + }, + { + "id": "list_flatten_int", + "args": [ + "list(dyn)", + "int" + ], + "result": "list(dyn)", + "member": true + } + ] + }, + { + "name": "format", + "memberOnly": true, + "overloads": [ + { + "id": "string_format", + "args": [ + "string", + "list(dyn)" + ], + "result": "string", + "member": true + } + ] + }, + { + "name": "fromAWSMap", + "overloads": [ + { + "id": "fromAWSMap_overload", + "args": [ + "list(map(string, string))" + ], + "result": "map(string, string)" + } + ] + }, + { + "name": "getDate", + "memberOnly": true, + "doc": "get the 1-based day of the month from a timestamp, UTC unless an IANA timezone is specified.", + "overloads": [ + { + "id": "timestamp_to_day_of_month_1_based", + "args": [ + "google.protobuf.Timestamp" + ], + "result": "int", + "member": true + }, + { + "id": "timestamp_to_day_of_month_1_based_with_tz", + "args": [ + "google.protobuf.Timestamp", + "string" + ], + "result": "int", + "member": true + } + ], + "examples": [ + "timestamp('2023-07-14T10:30:45.123Z').getDate() // 14", + "timestamp('2023-07-01T05:00:00Z').getDate('America/Los_Angeles') // 30" + ] + }, + { + "name": "getDayOfMonth", + "memberOnly": true, + "doc": "get the 0-based day of the month from a timestamp, UTC unless an IANA timezone is specified.", + "overloads": [ + { + "id": "timestamp_to_day_of_month", + "args": [ + "google.protobuf.Timestamp" + ], + "result": "int", + "member": true + }, + { + "id": "timestamp_to_day_of_month_with_tz", + "args": [ + "google.protobuf.Timestamp", + "string" + ], + "result": "int", + "member": true + } + ], + "examples": [ + "timestamp('2023-07-14T10:30:45.123Z').getDayOfMonth() // 13", + "timestamp('2023-07-01T05:00:00Z').getDayOfMonth('America/Los_Angeles') // 29" + ] + }, + { + "name": "getDayOfWeek", + "memberOnly": true, + "doc": "get the 0-based day of the week from a timestamp, UTC unless an IANA timezone is specified.", + "overloads": [ + { + "id": "timestamp_to_day_of_week", + "args": [ + "google.protobuf.Timestamp" + ], + "result": "int", + "member": true + }, + { + "id": "timestamp_to_day_of_week_with_tz", + "args": [ + "google.protobuf.Timestamp", + "string" + ], + "result": "int", + "member": true + } + ], + "examples": [ + "timestamp('2023-07-14T10:30:45.123Z').getDayOfWeek() // 5", + "timestamp('2023-07-16T05:00:00Z').getDayOfWeek('America/Los_Angeles') // 6" + ] + }, + { + "name": "getDayOfYear", + "memberOnly": true, + "doc": "get the 0-based day of the year from a timestamp, UTC unless an IANA timezone is specified.", + "overloads": [ + { + "id": "timestamp_to_day_of_year", + "args": [ + "google.protobuf.Timestamp" + ], + "result": "int", + "member": true + }, + { + "id": "timestamp_to_day_of_year_with_tz", + "args": [ + "google.protobuf.Timestamp", + "string" + ], + "result": "int", + "member": true + } + ], + "examples": [ + "timestamp('2023-01-02T00:00:00Z').getDayOfYear() // 1", + "timestamp('2023-01-01T05:00:00Z').getDayOfYear('America/Los_Angeles') // 364" + ] + }, + { + "name": "getEscapedPath", + "memberOnly": true, + "overloads": [ + { + "id": "url_get_escaped_path", + "args": [ + "kubernetes.URL" + ], + "result": "string", + "member": true + } + ] + }, + { + "name": "getFullYear", + "memberOnly": true, + "doc": "get the 0-based full year from a timestamp, UTC unless an IANA timezone is specified.", + "overloads": [ + { + "id": "timestamp_to_year", + "args": [ + "google.protobuf.Timestamp" + ], + "result": "int", + "member": true + }, + { + "id": "timestamp_to_year_with_tz", + "args": [ + "google.protobuf.Timestamp", + "string" + ], + "result": "int", + "member": true + } + ], + "examples": [ + "timestamp('2023-07-14T10:30:45.123Z').getFullYear() // 2023", + "timestamp('2023-01-01T05:30:00Z').getFullYear('-08:00') // 2022" + ] + }, + { + "name": "getHost", + "memberOnly": true, + "overloads": [ + { + "id": "url_get_host", + "args": [ + "kubernetes.URL" + ], + "result": "string", + "member": true + } + ] + }, + { + "name": "getHostname", + "memberOnly": true, + "overloads": [ + { + "id": "url_get_hostname", + "args": [ + "kubernetes.URL" + ], + "result": "string", + "member": true + } + ] + }, + { + "name": "getHours", + "memberOnly": true, + "doc": "get the hours portion from a timestamp, or convert a duration to hours", + "overloads": [ + { + "id": "duration_to_hours", + "args": [ + "google.protobuf.Duration" + ], + "result": "int", + "member": true + }, + { + "id": "timestamp_to_hours", + "args": [ + "google.protobuf.Timestamp" + ], + "result": "int", + "member": true + }, + { + "id": "timestamp_to_hours_with_tz", + "args": [ + "google.protobuf.Timestamp", + "string" + ], + "result": "int", + "member": true + } + ], + "examples": [ + "timestamp('2023-07-14T10:30:45.123Z').getHours() // 10", + "timestamp('2023-07-14T10:30:45.123Z').getHours('America/Los_Angeles') // 2", + "duration('3723s').getHours() // 1" + ] + }, + { + "name": "getMilliseconds", + "memberOnly": true, + "doc": "get the milliseconds portion from a timestamp", + "overloads": [ + { + "id": "duration_to_milliseconds", + "args": [ + "google.protobuf.Duration" + ], + "result": "int", + "member": true + }, + { + "id": "timestamp_to_milliseconds", + "args": [ + "google.protobuf.Timestamp" + ], + "result": "int", + "member": true + }, + { + "id": "timestamp_to_milliseconds_with_tz", + "args": [ + "google.protobuf.Timestamp", + "string" + ], + "result": "int", + "member": true + } + ], + "examples": [ + "timestamp('2023-07-14T10:30:45.123Z').getMilliseconds() // 123", + "timestamp('2023-07-14T10:30:45.123Z').getMilliseconds('America/Los_Angeles') // 123" + ] + }, + { + "name": "getMinutes", + "memberOnly": true, + "doc": "get the minutes portion from a timestamp, or convert a duration to minutes", + "overloads": [ + { + "id": "duration_to_minutes", + "args": [ + "google.protobuf.Duration" + ], + "result": "int", + "member": true + }, + { + "id": "timestamp_to_minutes", + "args": [ + "google.protobuf.Timestamp" + ], + "result": "int", + "member": true + }, + { + "id": "timestamp_to_minutes_with_tz", + "args": [ + "google.protobuf.Timestamp", + "string" + ], + "result": "int", + "member": true + } + ], + "examples": [ + "timestamp('2023-07-14T10:30:45.123Z').getMinutes() // 30", + "timestamp('2023-07-14T10:30:45.123Z').getMinutes('America/Los_Angeles') // 30", + "duration('3723s').getMinutes() // 62" + ] + }, + { + "name": "getMonth", + "memberOnly": true, + "doc": "get the 0-based month from a timestamp, UTC unless an IANA timezone is specified.", + "overloads": [ + { + "id": "timestamp_to_month", + "args": [ + "google.protobuf.Timestamp" + ], + "result": "int", + "member": true + }, + { + "id": "timestamp_to_month_with_tz", + "args": [ + "google.protobuf.Timestamp", + "string" + ], + "result": "int", + "member": true + } + ], + "examples": [ + "timestamp('2023-07-14T10:30:45.123Z').getMonth() // 6", + "timestamp('2023-01-01T05:30:00Z').getMonth('America/Los_Angeles') // 11" + ] + }, + { + "name": "getPort", + "memberOnly": true, + "overloads": [ + { + "id": "url_get_port", + "args": [ + "kubernetes.URL" + ], + "result": "string", + "member": true + } + ] + }, + { + "name": "getQuery", + "memberOnly": true, + "overloads": [ + { + "id": "url_get_query", + "args": [ + "kubernetes.URL" + ], + "result": "map(string, list(string))", + "member": true + } + ] + }, + { + "name": "getScheme", + "memberOnly": true, + "overloads": [ + { + "id": "url_get_scheme", + "args": [ + "kubernetes.URL" + ], + "result": "string", + "member": true + } + ] + }, + { + "name": "getSeconds", + "memberOnly": true, + "doc": "get the seconds portion from a timestamp, or convert a duration to seconds", + "overloads": [ + { + "id": "duration_to_seconds", + "args": [ + "google.protobuf.Duration" + ], + "result": "int", + "member": true + }, + { + "id": "timestamp_to_seconds", + "args": [ + "google.protobuf.Timestamp" + ], + "result": "int", + "member": true + }, + { + "id": "timestamp_to_seconds_tz", + "args": [ + "google.protobuf.Timestamp", + "string" + ], + "result": "int", + "member": true + } + ], + "examples": [ + "timestamp('2023-07-14T10:30:45.123Z').getSeconds() // 45", + "timestamp('2023-07-14T10:30:45.123Z').getSeconds('America/Los_Angeles') // 45", + "duration('3723.456s').getSeconds() // 3723" + ] + }, + { + "name": "hasValue", + "memberOnly": true, + "doc": "determine whether the optional contains a value", + "overloads": [ + { + "id": "optional_hasValue", + "args": [ + "optional_type(\u003cV\u003e)" + ], + "result": "bool", + "member": true + } + ], + "examples": [ + "optional.of({1: 2}).hasValue() // true" + ] + }, + { + "name": "in", + "overloads": [ + { + "id": "in_list", + "args": [ + "\u003cA\u003e", + "list(\u003cA\u003e)" + ], + "result": "bool" + }, + { + "id": "in_map", + "args": [ + "\u003cA\u003e", + "map(\u003cA\u003e, \u003cB\u003e)" + ], + "result": "bool" + } + ] + }, + { + "name": "in_business_hours", + "overloads": [ + { + "id": "in_business_hours_string", + "args": [ + "string" + ], + "result": "dyn" + } + ] + }, + { + "name": "indent", + "memberOnly": true, + "overloads": [ + { + "id": "string_indent", + "args": [ + "string", + "string" + ], + "result": "string", + "member": true + }, + { + "id": "string_indent_with_width", + "args": [ + "string", + "int", + "string" + ], + "result": "string", + "member": true + } + ] + }, + { + "name": "indexOf", + "memberOnly": true, + "overloads": [ + { + "id": "list_a_index_of_int", + "args": [ + "list(\u003cA\u003e)", + "\u003cA\u003e" + ], + "result": "int", + "member": true + }, + { + "id": "string_index_of_string", + "args": [ + "string", + "string" + ], + "result": "int", + "member": true + }, + { + "id": "string_index_of_string_int", + "args": [ + "string", + "string", + "int" + ], + "result": "int", + "member": true + } + ] + }, + { + "name": "int", + "doc": "convert a value to an int", + "overloads": [ + { + "id": "double_to_int64", + "args": [ + "double" + ], + "result": "int" + }, + { + "id": "duration_to_int64", + "args": [ + "google.protobuf.Duration" + ], + "result": "int" + }, + { + "id": "int64_to_int64", + "args": [ + "int" + ], + "result": "int" + }, + { + "id": "string_to_int64", + "args": [ + "string" + ], + "result": "int" + }, + { + "id": "timestamp_to_int64", + "args": [ + "google.protobuf.Timestamp" + ], + "result": "int" + }, + { + "id": "uint64_to_int64", + "args": [ + "uint" + ], + "result": "int" + } + ], + "examples": [ + "int(123) // 123", + "int(123.45) // 123", + "int(duration('1s')) // 1000000000", + "int('123') // 123\nint('-456') // -456", + "int(timestamp('1970-01-01T00:00:01Z')) // 1", + "int(123u) // 123" + ] + }, + { + "name": "isSorted", + "memberOnly": true, + "overloads": [ + { + "id": "list_bool_is_sorted_bool", + "args": [ + "list(bool)" + ], + "result": "bool", + "member": true + }, + { + "id": "list_bytes_is_sorted_bool", + "args": [ + "list(bytes)" + ], + "result": "bool", + "member": true + }, + { + "id": "list_double_is_sorted_bool", + "args": [ + "list(double)" + ], + "result": "bool", + "member": true + }, + { + "id": "list_duration_is_sorted_bool", + "args": [ + "list(google.protobuf.Duration)" + ], + "result": "bool", + "member": true + }, + { + "id": "list_int_is_sorted_bool", + "args": [ + "list(int)" + ], + "result": "bool", + "member": true + }, + { + "id": "list_string_is_sorted_bool", + "args": [ + "list(string)" + ], + "result": "bool", + "member": true + }, + { + "id": "list_timestamp_is_sorted_bool", + "args": [ + "list(google.protobuf.Timestamp)" + ], + "result": "bool", + "member": true + }, + { + "id": "list_uint_is_sorted_bool", + "args": [ + "list(uint)" + ], + "result": "bool", + "member": true + } + ] + }, + { + "name": "isURL", + "overloads": [ + { + "id": "is_url_string", + "args": [ + "string" + ], + "result": "bool" + } + ] + }, + { + "name": "jmespath", + "overloads": [ + { + "id": "jmespath_string_interface{}", + "args": [ + "string", + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "join", + "memberOnly": true, + "overloads": [ + { + "id": "list_join", + "args": [ + "list(string)" + ], + "result": "string", + "member": true + }, + { + "id": "list_join_string", + "args": [ + "list(string)", + "string" + ], + "result": "string", + "member": true + } + ] + }, + { + "name": "jq", + "overloads": [ + { + "id": "jq_string_interface{}", + "args": [ + "string", + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "json.encode", + "namespace": "json", + "overloads": [ + { + "id": "json_encode_dyn", + "args": [ + "dyn" + ], + "result": "string" + } + ] + }, + { + "name": "jsonpath", + "overloads": [ + { + "id": "jsonpath_string_interface{}", + "args": [ + "string", + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "k8s.cpuAsMillicores", + "namespace": "k8s", + "overloads": [ + { + "id": "k8s.cpuAsMillicores_string", + "args": [ + "string" + ], + "result": "int" + } + ] + }, + { + "name": "k8s.getHealth", + "namespace": "k8s", + "overloads": [ + { + "id": "k8s.getHealth_overload", + "args": [ + "google.protobuf.Any" + ], + "result": "google.protobuf.Any" + } + ] + }, + { + "name": "k8s.getResourcesLimit", + "namespace": "k8s", + "overloads": [ + { + "id": "k8s.getResourcesLimit_obj_str_int", + "args": [ + "google.protobuf.Any", + "string" + ], + "result": "google.protobuf.Any" + } + ] + }, + { + "name": "k8s.getResourcesRequests", + "namespace": "k8s", + "overloads": [ + { + "id": "k8s.getResourcesRequests_obj_str_int", + "args": [ + "google.protobuf.Any", + "string" + ], + "result": "google.protobuf.Any" + } + ] + }, + { + "name": "k8s.getStatus", + "namespace": "k8s", + "overloads": [ + { + "id": "k8s.getStatus_overload", + "args": [ + "google.protobuf.Any" + ], + "result": "google.protobuf.Any" + } + ] + }, + { + "name": "k8s.isHealthy", + "namespace": "k8s", + "overloads": [ + { + "id": "k8s.isHealthy_overload", + "args": [ + "google.protobuf.Any" + ], + "result": "bool" + } + ] + }, + { + "name": "k8s.isReady", + "namespace": "k8s", + "overloads": [ + { + "id": "k8s.isReady_overload", + "args": [ + "google.protobuf.Any" + ], + "result": "bool" + } + ] + }, + { + "name": "k8s.is_healthy", + "namespace": "k8s", + "overloads": [ + { + "id": "k8s.is_healthy_overload", + "args": [ + "google.protobuf.Any" + ], + "result": "bool" + } + ] + }, + { + "name": "k8s.labels", + "namespace": "k8s", + "overloads": [ + { + "id": "k8s.labels_map_map", + "args": [ + "google.protobuf.Any" + ], + "result": "google.protobuf.Any" + } + ] + }, + { + "name": "k8s.memoryAsBytes", + "namespace": "k8s", + "overloads": [ + { + "id": "k8s.memoryAsBytes_string", + "args": [ + "string" + ], + "result": "int" + } + ] + }, + { + "name": "k8s.neat", + "namespace": "k8s", + "overloads": [ + { + "id": "k8s_neat", + "args": [ + "dyn" + ], + "result": "string" + }, + { + "id": "k8s_neat_with_option", + "args": [ + "dyn", + "string" + ], + "result": "string" + } + ] + }, + { + "name": "k8s.nodeProperties", + "namespace": "k8s", + "overloads": [ + { + "id": "k8s.nodeProperties_list_dyn_map", + "args": [ + "google.protobuf.Any" + ], + "result": "google.protobuf.Any" + } + ] + }, + { + "name": "k8s.podProperties", + "namespace": "k8s", + "overloads": [ + { + "id": "k8s.podProperties_list_dyn_map", + "args": [ + "google.protobuf.Any" + ], + "result": "google.protobuf.Any" + } + ] + }, + { + "name": "kebabCase", + "memberOnly": true, + "overloads": [ + { + "id": "string_kebab_case", + "args": [ + "string" + ], + "result": "string", + "member": true + } + ] + }, + { + "name": "keyValToMap", + "overloads": [ + { + "id": "keyValToMap_interface{}", + "args": [ + "google.protobuf.Any" + ], + "result": "map(string, google.protobuf.Any)" + } + ] + }, + { + "name": "keys", + "memberOnly": true, + "overloads": [ + { + "id": "map_keys", + "args": [ + "map(string, google.protobuf.Any)" + ], + "result": "list(string)", + "member": true + } + ] + }, + { + "name": "last", + "overloads": [ + { + "id": "dyn_last", + "args": [ + "dyn" + ], + "result": "dyn", + "member": true + }, + { + "id": "last_dyn", + "args": [ + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "lastIndexOf", + "memberOnly": true, + "overloads": [ + { + "id": "list_a_last_index_of_int", + "args": [ + "list(\u003cA\u003e)", + "\u003cA\u003e" + ], + "result": "int", + "member": true + }, + { + "id": "string_last_index_of_string", + "args": [ + "string", + "string" + ], + "result": "int", + "member": true + }, + { + "id": "string_last_index_of_string_int", + "args": [ + "string", + "string", + "int" + ], + "result": "int", + "member": true + } + ] + }, + { + "name": "lists.range", + "namespace": "lists", + "overloads": [ + { + "id": "lists_range", + "args": [ + "int" + ], + "result": "list(int)" + } + ] + }, + { + "name": "lowerAscii", + "memberOnly": true, + "overloads": [ + { + "id": "string_lower_ascii", + "args": [ + "string" + ], + "result": "string", + "member": true + } + ] + }, + { + "name": "mapToKeyVal", + "overloads": [ + { + "id": "mapToKeyVal_interface{}", + "args": [ + "map(string, google.protobuf.Any)" + ], + "result": "string" + } + ] + }, + { + "name": "match", + "memberOnly": true, + "overloads": [ + { + "id": "string_match_string", + "args": [ + "string", + "string" + ], + "result": "bool", + "member": true + } + ] + }, + { + "name": "matchLabel", + "overloads": [ + { + "id": "matchLabel_map_string_string", + "args": [ + "map(string, dyn)", + "string", + "string" + ], + "result": "bool" + } + ] + }, + { + "name": "matches", + "doc": "test whether a string matches an RE2 regular expression", + "overloads": [ + { + "id": "matches", + "args": [ + "string", + "string" + ], + "result": "bool" + }, + { + "id": "matches_string", + "args": [ + "string", + "string" + ], + "result": "bool", + "member": true + } + ], + "examples": [ + "matches('123-456', '^[0-9]+(-[0-9]+)?$') // true\nmatches('hello', '^h.*o$') // true", + "'123-456'.matches('^[0-9]+(-[0-9]+)?$') // true\n'hello'.matches('^h.*o$') // true" + ] + }, + { + "name": "math.Abs", + "namespace": "math", + "overloads": [ + { + "id": "math.Abs_interface{}", + "args": [ + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "math.Add", + "namespace": "math", + "overloads": [ + { + "id": "math.Add_interface{}", + "args": [ + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "math.Ceil", + "namespace": "math", + "overloads": [ + { + "id": "math.Ceil_interface{}", + "args": [ + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "math.Div", + "namespace": "math", + "overloads": [ + { + "id": "math.Div_interface{}_interface{}", + "args": [ + "dyn", + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "math.Floor", + "namespace": "math", + "overloads": [ + { + "id": "math.Floor_interface{}", + "args": [ + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "math.IsFloat", + "namespace": "math", + "overloads": [ + { + "id": "math.IsFloat_interface{}", + "args": [ + "dyn" + ], + "result": "bool" + } + ] + }, + { + "name": "math.IsInt", + "namespace": "math", + "overloads": [ + { + "id": "math.IsInt_interface{}", + "args": [ + "dyn" + ], + "result": "bool" + } + ] + }, + { + "name": "math.IsNum", + "namespace": "math", + "overloads": [ + { + "id": "math.IsNum_interface{}", + "args": [ + "dyn" + ], + "result": "bool" + } + ] + }, + { + "name": "math.Mul", + "namespace": "math", + "overloads": [ + { + "id": "math.Mul_interface{}", + "args": [ + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "math.Pow", + "namespace": "math", + "overloads": [ + { + "id": "math.Pow_interface{}_interface{}", + "args": [ + "dyn", + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "math.Rem", + "namespace": "math", + "overloads": [ + { + "id": "math.Rem_interface{}_interface{}", + "args": [ + "dyn", + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "math.Round", + "namespace": "math", + "overloads": [ + { + "id": "math.Round_interface{}", + "args": [ + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "math.Seq", + "namespace": "math", + "overloads": [ + { + "id": "math.Seq_interface{}", + "args": [ + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "math.Sub", + "namespace": "math", + "overloads": [ + { + "id": "math.Sub_interface{}_interface{}", + "args": [ + "dyn", + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "math.abs", + "namespace": "math", + "overloads": [ + { + "id": "math_abs_double", + "args": [ + "double" + ], + "result": "double" + }, + { + "id": "math_abs_int", + "args": [ + "int" + ], + "result": "int" + }, + { + "id": "math_abs_uint", + "args": [ + "uint" + ], + "result": "uint" + } + ] + }, + { + "name": "math.bitAnd", + "namespace": "math", + "overloads": [ + { + "id": "math_bitAnd_int_int", + "args": [ + "int", + "int" + ], + "result": "int" + }, + { + "id": "math_bitAnd_uint_uint", + "args": [ + "uint", + "uint" + ], + "result": "uint" + } + ] + }, + { + "name": "math.bitNot", + "namespace": "math", + "overloads": [ + { + "id": "math_bitNot_int_int", + "args": [ + "int" + ], + "result": "int" + }, + { + "id": "math_bitNot_uint_uint", + "args": [ + "uint" + ], + "result": "uint" + } + ] + }, + { + "name": "math.bitOr", + "namespace": "math", + "overloads": [ + { + "id": "math_bitOr_int_int", + "args": [ + "int", + "int" + ], + "result": "int" + }, + { + "id": "math_bitOr_uint_uint", + "args": [ + "uint", + "uint" + ], + "result": "uint" + } + ] + }, + { + "name": "math.bitShiftLeft", + "namespace": "math", + "overloads": [ + { + "id": "math_bitShiftLeft_int_int", + "args": [ + "int", + "int" + ], + "result": "int" + }, + { + "id": "math_bitShiftLeft_uint_int", + "args": [ + "uint", + "int" + ], + "result": "uint" + } + ] + }, + { + "name": "math.bitShiftRight", + "namespace": "math", + "overloads": [ + { + "id": "math_bitShiftRight_int_int", + "args": [ + "int", + "int" + ], + "result": "int" + }, + { + "id": "math_bitShiftRight_uint_int", + "args": [ + "uint", + "int" + ], + "result": "uint" + } + ] + }, + { + "name": "math.bitXor", + "namespace": "math", + "overloads": [ + { + "id": "math_bitXor_int_int", + "args": [ + "int", + "int" + ], + "result": "int" + }, + { + "id": "math_bitXor_uint_uint", + "args": [ + "uint", + "uint" + ], + "result": "uint" + } + ] + }, + { + "name": "math.ceil", + "namespace": "math", + "overloads": [ + { + "id": "math_ceil_double", + "args": [ + "double" + ], + "result": "double" + } + ] + }, + { + "name": "math.containsFloat", + "namespace": "math", + "overloads": [ + { + "id": "math.containsFloat_interface{}", + "args": [ + "dyn" + ], + "result": "bool" + } + ] + }, + { + "name": "math.floor", + "namespace": "math", + "overloads": [ + { + "id": "math_floor_double", + "args": [ + "double" + ], + "result": "double" + } + ] + }, + { + "name": "math.isFinite", + "namespace": "math", + "overloads": [ + { + "id": "math_isFinite_double", + "args": [ + "double" + ], + "result": "bool" + } + ] + }, + { + "name": "math.isInf", + "namespace": "math", + "overloads": [ + { + "id": "math_isInf_double", + "args": [ + "double" + ], + "result": "bool" + } + ] + }, + { + "name": "math.isNaN", + "namespace": "math", + "overloads": [ + { + "id": "math_isNaN_double", + "args": [ + "double" + ], + "result": "bool" + } + ] + }, + { + "name": "math.round", + "namespace": "math", + "overloads": [ + { + "id": "math_round_double", + "args": [ + "double" + ], + "result": "double" + } + ] + }, + { + "name": "math.sign", + "namespace": "math", + "overloads": [ + { + "id": "math_sign_double", + "args": [ + "double" + ], + "result": "double" + }, + { + "id": "math_sign_int", + "args": [ + "int" + ], + "result": "int" + }, + { + "id": "math_sign_uint", + "args": [ + "uint" + ], + "result": "uint" + } + ] + }, + { + "name": "math.sqrt", + "namespace": "math", + "overloads": [ + { + "id": "math_sqrt_double", + "args": [ + "double" + ], + "result": "double" + }, + { + "id": "math_sqrt_int", + "args": [ + "int" + ], + "result": "double" + }, + { + "id": "math_sqrt_uint", + "args": [ + "uint" + ], + "result": "double" + } + ] + }, + { + "name": "math.trunc", + "namespace": "math", + "overloads": [ + { + "id": "math_trunc_double", + "args": [ + "double" + ], + "result": "double" + } + ] + }, + { + "name": "max", + "memberOnly": true, + "overloads": [ + { + "id": "list_bool_max_bool", + "args": [ + "list(bool)" + ], + "result": "bool", + "member": true + }, + { + "id": "list_bytes_max_bytes", + "args": [ + "list(bytes)" + ], + "result": "bytes", + "member": true + }, + { + "id": "list_double_max_double", + "args": [ + "list(double)" + ], + "result": "double", + "member": true + }, + { + "id": "list_duration_max_duration", + "args": [ + "list(google.protobuf.Duration)" + ], + "result": "google.protobuf.Duration", + "member": true + }, + { + "id": "list_int_max_int", + "args": [ + "list(int)" + ], + "result": "int", + "member": true + }, + { + "id": "list_string_max_string", + "args": [ + "list(string)" + ], + "result": "string", + "member": true + }, + { + "id": "list_timestamp_max_timestamp", + "args": [ + "list(google.protobuf.Timestamp)" + ], + "result": "google.protobuf.Timestamp", + "member": true + }, + { + "id": "list_uint_max_uint", + "args": [ + "list(uint)" + ], + "result": "uint", + "member": true + } + ] + }, + { + "name": "merge", + "overloads": [ + { + "id": "merge_map[string]interface{}", + "args": [ + "map(string, dyn)", + "map(string, dyn)" + ], + "result": "map(string, dyn)", + "member": true + }, + { + "id": "merge_map_map", + "args": [ + "map(dyn, dyn)", + "map(dyn, dyn)" + ], + "result": "map(dyn, dyn)" + } + ] + }, + { + "name": "min", + "memberOnly": true, + "overloads": [ + { + "id": "list_bool_min_bool", + "args": [ + "list(bool)" + ], + "result": "bool", + "member": true + }, + { + "id": "list_bytes_min_bytes", + "args": [ + "list(bytes)" + ], + "result": "bytes", + "member": true + }, + { + "id": "list_double_min_double", + "args": [ + "list(double)" + ], + "result": "double", + "member": true + }, + { + "id": "list_duration_min_duration", + "args": [ + "list(google.protobuf.Duration)" + ], + "result": "google.protobuf.Duration", + "member": true + }, + { + "id": "list_int_min_int", + "args": [ + "list(int)" + ], + "result": "int", + "member": true + }, + { + "id": "list_string_min_string", + "args": [ + "list(string)" + ], + "result": "string", + "member": true + }, + { + "id": "list_timestamp_min_timestamp", + "args": [ + "list(google.protobuf.Timestamp)" + ], + "result": "google.protobuf.Timestamp", + "member": true + }, + { + "id": "list_uint_min_uint", + "args": [ + "list(uint)" + ], + "result": "uint", + "member": true + } + ] + }, + { + "name": "net.ContainsCIDR", + "namespace": "net", + "overloads": [ + { + "id": "net.ContainsCIDR_string_string", + "args": [ + "string", + "string" + ], + "result": "bool" + } + ] + }, + { + "name": "net.IsValidIP", + "namespace": "net", + "overloads": [ + { + "id": "net.IsValidIP_string", + "args": [ + "string" + ], + "result": "bool" + } + ] + }, + { + "name": "omit", + "memberOnly": true, + "overloads": [ + { + "id": "omit_interface{}", + "args": [ + "map(string, google.protobuf.Any)", + "list(string)" + ], + "result": "map(string, google.protobuf.Any)", + "member": true + } + ] + }, + { + "name": "optional.none", + "namespace": "optional", + "doc": "singleton value representing an optional without a value", + "overloads": [ + { + "id": "optional_none", + "args": [], + "result": "optional_type(\u003cV\u003e)" + } + ], + "examples": [ + "optional.none()" + ] + }, + { + "name": "optional.of", + "namespace": "optional", + "doc": "create a new optional_type(T) with a value where any value is considered valid", + "overloads": [ + { + "id": "optional_of", + "args": [ + "\u003cV\u003e" + ], + "result": "optional_type(\u003cV\u003e)" + } + ], + "examples": [ + "optional.of(1) // optional(1)" + ] + }, + { + "name": "optional.ofNonZeroValue", + "namespace": "optional", + "doc": "create a new optional_type(T) with a value, if the value is not a zero or empty value", + "overloads": [ + { + "id": "optional_ofNonZeroValue", + "args": [ + "\u003cV\u003e" + ], + "result": "optional_type(\u003cV\u003e)" + } + ], + "examples": [ + "optional.ofNonZeroValue(null) // optional.none()\noptional.ofNonZeroValue(\"\") // optional.none()\noptional.ofNonZeroValue(\"hello\") // optional.of('hello')" + ] + }, + { + "name": "or", + "memberOnly": true, + "doc": "chain optional expressions together, picking the first valued optional expression", + "overloads": [ + { + "id": "optional_or_optional", + "args": [ + "optional_type(\u003cV\u003e)", + "optional_type(\u003cV\u003e)" + ], + "result": "optional_type(\u003cV\u003e)", + "member": true + } + ], + "examples": [ + "optional.none().or(optional.of(1)) // optional.of(1)\n// either a value from the first list, a value from the second, or optional.none()\n[1, 2, 3][?x].or([3, 4, 5][?y])" + ] + }, + { + "name": "orValue", + "memberOnly": true, + "doc": "chain optional expressions together picking the first valued optional or the default value", + "overloads": [ + { + "id": "optional_orValue_value", + "args": [ + "optional_type(\u003cV\u003e)", + "\u003cV\u003e" + ], + "result": "\u003cV\u003e", + "member": true + } + ], + "examples": [ + "// pick the value for the given key if the key exists, otherwise return 'you'\n{'hello': 'world', 'goodbye': 'cruel world'}[?greeting].orValue('you')" + ] + }, + { + "name": "pick", + "memberOnly": true, + "overloads": [ + { + "id": "pick_interface{}", + "args": [ + "google.protobuf.Any", + "list(string)" + ], + "result": "google.protobuf.Any", + "member": true + } + ] + }, + { + "name": "quote", + "memberOnly": true, + "overloads": [ + { + "id": "string_quote", + "args": [ + "dyn" + ], + "result": "string", + "member": true + } + ] + }, + { + "name": "random.ASCII", + "namespace": "random", + "overloads": [ + { + "id": "random.ASCII_interface{}", + "args": [ + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "random.Alpha", + "namespace": "random", + "overloads": [ + { + "id": "random.Alpha_interface{}", + "args": [ + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "random.AlphaNum", + "namespace": "random", + "overloads": [ + { + "id": "random.AlphaNum_interface{}", + "args": [ + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "random.Float", + "namespace": "random", + "overloads": [ + { + "id": "random.Float_interface{}", + "args": [ + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "random.Item", + "namespace": "random", + "overloads": [ + { + "id": "random.Item_interface{}", + "args": [ + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "random.Number", + "namespace": "random", + "overloads": [ + { + "id": "random.Number_interface{}", + "args": [ + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "random.String", + "namespace": "random", + "overloads": [ + { + "id": "random.String_interface{}_interface{}", + "args": [ + "dyn", + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "regexp.Find", + "namespace": "regexp", + "overloads": [ + { + "id": "regexp.Find_interface{}_interface{}", + "args": [ + "dyn", + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "regexp.FindAll", + "namespace": "regexp", + "overloads": [ + { + "id": "regexp.FindAll_interface{}", + "args": [ + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "regexp.Match", + "namespace": "regexp", + "overloads": [ + { + "id": "regexp.Match_interface{}_interface{}", + "args": [ + "dyn", + "dyn" + ], + "result": "bool" + } + ] + }, + { + "name": "regexp.QuoteMeta", + "namespace": "regexp", + "overloads": [ + { + "id": "regexp.QuoteMeta_interface{}", + "args": [ + "dyn" + ], + "result": "string" + } + ] + }, + { + "name": "regexp.Replace", + "namespace": "regexp", + "overloads": [ + { + "id": "regexp.Replace_interface{}_interface{}_interface{}", + "args": [ + "dyn", + "dyn", + "dyn" + ], + "result": "string" + } + ] + }, + { + "name": "regexp.ReplaceLiteral", + "namespace": "regexp", + "overloads": [ + { + "id": "regexp.ReplaceLiteral_interface{}_interface{}_interface{}", + "args": [ + "dyn", + "dyn", + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "regexp.Split", + "namespace": "regexp", + "overloads": [ + { + "id": "regexp.Split_interface{}", + "args": [ + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "repeat", + "memberOnly": true, + "overloads": [ + { + "id": "string_repeat", + "args": [ + "string", + "int" + ], + "result": "string", + "member": true + } + ] + }, + { + "name": "replace", + "memberOnly": true, + "overloads": [ + { + "id": "string_replace_string_string", + "args": [ + "string", + "string", + "string" + ], + "result": "string", + "member": true + }, + { + "id": "string_replace_string_string_int", + "args": [ + "string", + "string", + "string", + "int" + ], + "result": "string", + "member": true + } + ] + }, + { + "name": "replaceAll", + "memberOnly": true, + "overloads": [ + { + "id": "ReplaceAll_string_string_interface{}", + "args": [ + "string", + "string", + "dyn" + ], + "result": "string", + "member": true + } + ] + }, + { + "name": "replaceAllRegex", + "memberOnly": true, + "overloads": [ + { + "id": "string_replaceAllRegex_string", + "args": [ + "string", + "string", + "string" + ], + "result": "string", + "member": true + } + ] + }, + { + "name": "reverse", + "memberOnly": true, + "overloads": [ + { + "id": "list_reverse", + "args": [ + "list(\u003cT\u003e)" + ], + "result": "list(\u003cT\u003e)", + "member": true + }, + { + "id": "string_reverse", + "args": [ + "string" + ], + "result": "string", + "member": true + } + ] + }, + { + "name": "runeCount", + "memberOnly": true, + "overloads": [ + { + "id": "string_rune_count", + "args": [ + "string" + ], + "result": "int", + "member": true + } + ] + }, + { + "name": "sets.contains", + "namespace": "sets", + "overloads": [ + { + "id": "list_sets_contains_list", + "args": [ + "list(\u003cT\u003e)", + "list(\u003cT\u003e)" + ], + "result": "bool" + } + ] + }, + { + "name": "sets.equivalent", + "namespace": "sets", + "overloads": [ + { + "id": "list_sets_equivalent_list", + "args": [ + "list(\u003cT\u003e)", + "list(\u003cT\u003e)" + ], + "result": "bool" + } + ] + }, + { + "name": "sets.intersects", + "namespace": "sets", + "overloads": [ + { + "id": "list_sets_intersects_list", + "args": [ + "list(\u003cT\u003e)", + "list(\u003cT\u003e)" + ], + "result": "bool" + } + ] + }, + { + "name": "shellQuote", + "memberOnly": true, + "overloads": [ + { + "id": "string_shell_quote", + "args": [ + "string" + ], + "result": "string", + "member": true + } + ] + }, + { + "name": "size", + "doc": "compute the size of a list or map, the number of characters in a string,\nor the number of bytes in a sequence", + "overloads": [ + { + "id": "bytes_size", + "args": [ + "bytes" + ], + "result": "int", + "member": true + }, + { + "id": "list_size", + "args": [ + "list(\u003cA\u003e)" + ], + "result": "int", + "member": true + }, + { + "id": "map_size", + "args": [ + "map(\u003cA\u003e, \u003cB\u003e)" + ], + "result": "int", + "member": true + }, + { + "id": "size_bytes", + "args": [ + "bytes" + ], + "result": "int" + }, + { + "id": "size_list", + "args": [ + "list(\u003cA\u003e)" + ], + "result": "int" + }, + { + "id": "size_map", + "args": [ + "map(\u003cA\u003e, \u003cB\u003e)" + ], + "result": "int" + }, + { + "id": "size_string", + "args": [ + "string" + ], + "result": "int" + }, + { + "id": "string_size", + "args": [ + "string" + ], + "result": "int", + "member": true + } + ], + "examples": [ + "size(b'123') // 3", + "b'123'.size() // 3", + "size([1, 2, 3]) // 3", + "[1, 2, 3].size() // 3", + "size({'a': 1, 'b': 2}) // 2", + "{'a': 1, 'b': 2}.size() // 2", + "size('hello') // 5", + "'hello'.size() // 5" + ] + }, + { + "name": "slice", + "memberOnly": true, + "overloads": [ + { + "id": "list_slice", + "args": [ + "list(\u003cT\u003e)", + "int", + "int" + ], + "result": "list(\u003cT\u003e)", + "member": true + } + ] + }, + { + "name": "slug", + "memberOnly": true, + "overloads": [ + { + "id": "string_slug", + "args": [ + "string" + ], + "result": "string", + "member": true + } + ] + }, + { + "name": "snakeCase", + "memberOnly": true, + "overloads": [ + { + "id": "string_snakeCase", + "args": [ + "string" + ], + "result": "string", + "member": true + } + ] + }, + { + "name": "sort", + "memberOnly": true, + "overloads": [ + { + "id": "list_bool_sort", + "args": [ + "list(bool)" + ], + "result": "list(bool)", + "member": true + }, + { + "id": "list_bytes_sort", + "args": [ + "list(bytes)" + ], + "result": "list(bytes)", + "member": true + }, + { + "id": "list_double_sort", + "args": [ + "list(double)" + ], + "result": "list(double)", + "member": true + }, + { + "id": "list_google.protobuf.Duration_sort", + "args": [ + "list(google.protobuf.Duration)" + ], + "result": "list(google.protobuf.Duration)", + "member": true + }, + { + "id": "list_google.protobuf.Timestamp_sort", + "args": [ + "list(google.protobuf.Timestamp)" + ], + "result": "list(google.protobuf.Timestamp)", + "member": true + }, + { + "id": "list_int_sort", + "args": [ + "list(int)" + ], + "result": "list(int)", + "member": true + }, + { + "id": "list_string_sort", + "args": [ + "list(string)" + ], + "result": "list(string)", + "member": true + }, + { + "id": "list_uint_sort", + "args": [ + "list(uint)" + ], + "result": "list(uint)", + "member": true + } + ] + }, + { + "name": "sortBy", + "memberOnly": true, + "overloads": [ + { + "id": "sortBy_interface{}", + "args": [ + "list(google.protobuf.Any)", + "string" + ], + "result": "list(google.protobuf.Any)", + "member": true + } + ] + }, + { + "name": "split", + "memberOnly": true, + "overloads": [ + { + "id": "string_split_string", + "args": [ + "string", + "string" + ], + "result": "list(string)", + "member": true + }, + { + "id": "string_split_string_int", + "args": [ + "string", + "string", + "int" + ], + "result": "list(string)", + "member": true + } + ] + }, + { + "name": "splitRegex", + "memberOnly": true, + "overloads": [ + { + "id": "string_splitRegex_string", + "args": [ + "string", + "string" + ], + "result": "list(string)", + "member": true + } + ] + }, + { + "name": "squote", + "memberOnly": true, + "overloads": [ + { + "id": "string_squote", + "args": [ + "string" + ], + "result": "string", + "member": true + } + ] + }, + { + "name": "startsWith", + "memberOnly": true, + "doc": "test whether a string starts with a substring prefix", + "overloads": [ + { + "id": "starts_with_string", + "args": [ + "string", + "string" + ], + "result": "bool", + "member": true + } + ], + "examples": [ + "'hello world'.startsWith('hello') // true\n'hello world'.startsWith('world') // false" + ] + }, + { + "name": "string", + "doc": "convert a value to a string", + "overloads": [ + { + "id": "bool_to_string", + "args": [ + "bool" + ], + "result": "string" + }, + { + "id": "bytes_to_string", + "args": [ + "bytes" + ], + "result": "string" + }, + { + "id": "double_to_string", + "args": [ + "double" + ], + "result": "string" + }, + { + "id": "duration_to_string", + "args": [ + "google.protobuf.Duration" + ], + "result": "string" + }, + { + "id": "int64_to_string", + "args": [ + "int" + ], + "result": "string" + }, + { + "id": "string_to_string", + "args": [ + "string" + ], + "result": "string" + }, + { + "id": "timestamp_to_string", + "args": [ + "google.protobuf.Timestamp" + ], + "result": "string" + }, + { + "id": "uint64_to_string", + "args": [ + "uint" + ], + "result": "string" + } + ], + "examples": [ + "string('hello') // 'hello'", + "string(true) // 'true'", + "string(b'hello') // 'hello'", + "string(-1.23e4) // '-12300'", + "string(duration('1h30m')) // '5400s'", + "string(-123) // '-123'", + "string(timestamp('1970-01-01T00:00:00Z')) // '1970-01-01T00:00:00Z'", + "string(123u) // '123'" + ] + }, + { + "name": "strings.quote", + "namespace": "strings", + "overloads": [ + { + "id": "strings_quote", + "args": [ + "string" + ], + "result": "string" + } + ] + }, + { + "name": "substring", + "memberOnly": true, + "overloads": [ + { + "id": "string_substring_int", + "args": [ + "string", + "int" + ], + "result": "string", + "member": true + }, + { + "id": "string_substring_int_int", + "args": [ + "string", + "int", + "int" + ], + "result": "string", + "member": true + } + ] + }, + { + "name": "sum", + "memberOnly": true, + "overloads": [ + { + "id": "list_double_sum_double", + "args": [ + "list(double)" + ], + "result": "double", + "member": true + }, + { + "id": "list_duration_sum_duration", + "args": [ + "list(google.protobuf.Duration)" + ], + "result": "google.protobuf.Duration", + "member": true + }, + { + "id": "list_int_sum_int", + "args": [ + "list(int)" + ], + "result": "int", + "member": true + }, + { + "id": "list_uint_sum_uint", + "args": [ + "list(uint)" + ], + "result": "uint", + "member": true + } + ] + }, + { + "name": "test.Assert", + "namespace": "test", + "overloads": [ + { + "id": "test.Assert_interface{}", + "args": [ + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "test.Fail", + "namespace": "test", + "overloads": [ + { + "id": "test.Fail_interface{}", + "args": [ + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "test.IsKind", + "namespace": "test", + "overloads": [ + { + "id": "test.IsKind_string_interface{}", + "args": [ + "string", + "dyn" + ], + "result": "bool" + } + ] + }, + { + "name": "test.Kind", + "namespace": "test", + "overloads": [ + { + "id": "test.Kind_interface{}", + "args": [ + "dyn" + ], + "result": "string" + } + ] + }, + { + "name": "test.Required", + "namespace": "test", + "overloads": [ + { + "id": "test.Required_interface{}", + "args": [ + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "test.Ternary", + "namespace": "test", + "overloads": [ + { + "id": "test.Ternary_interface{}_interface{}_interface{}", + "args": [ + "dyn", + "dyn", + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "time.Hour", + "namespace": "time", + "overloads": [ + { + "id": "time.Hour_interface{}", + "args": [ + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "time.InTimeRange", + "namespace": "time", + "overloads": [ + { + "id": "time.InTimeRange_any_string_string", + "args": [ + "google.protobuf.Any", + "string", + "string" + ], + "result": "bool" + } + ] + }, + { + "name": "time.Microsecond", + "namespace": "time", + "overloads": [ + { + "id": "time.Microsecond_interface{}", + "args": [ + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "time.Millisecond", + "namespace": "time", + "overloads": [ + { + "id": "time.Millisecond_interface{}", + "args": [ + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "time.Minute", + "namespace": "time", + "overloads": [ + { + "id": "time.Minute_interface{}", + "args": [ + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "time.Nanosecond", + "namespace": "time", + "overloads": [ + { + "id": "time.Nanosecond_interface{}", + "args": [ + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "time.Now", + "namespace": "time", + "overloads": [ + { + "id": "time.Now_", + "args": [], + "result": "dyn" + } + ] + }, + { + "name": "time.Parse", + "namespace": "time", + "overloads": [ + { + "id": "time.Parse_string_interface{}", + "args": [ + "string", + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "time.ParseDateTime", + "namespace": "time", + "overloads": [ + { + "id": "time.ParseDateTime_string", + "args": [ + "string" + ], + "result": "dyn" + } + ] + }, + { + "name": "time.ParseDuration", + "namespace": "time", + "overloads": [ + { + "id": "time.ParseDuration_interface{}", + "args": [ + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "time.ParseInLocation", + "namespace": "time", + "overloads": [ + { + "id": "time.ParseInLocation_string_string_interface{}", + "args": [ + "string", + "string", + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "time.ParseLocal", + "namespace": "time", + "overloads": [ + { + "id": "time.ParseLocal_string_interface{}", + "args": [ + "string", + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "time.Second", + "namespace": "time", + "overloads": [ + { + "id": "time.Second_interface{}", + "args": [ + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "time.Since", + "namespace": "time", + "overloads": [ + { + "id": "time.Since_gotime.Time", + "args": [ + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "time.Unix", + "namespace": "time", + "overloads": [ + { + "id": "time.Unix_interface{}", + "args": [ + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "time.Until", + "namespace": "time", + "overloads": [ + { + "id": "time.Until_gotime.Time", + "args": [ + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "time.ZoneName", + "namespace": "time", + "overloads": [ + { + "id": "time.ZoneName_", + "args": [], + "result": "string" + } + ] + }, + { + "name": "time.ZoneOffset", + "namespace": "time", + "overloads": [ + { + "id": "time.ZoneOffset_", + "args": [], + "result": "int" + } + ] + }, + { + "name": "timestamp", + "doc": "convert a value to a google.protobuf.Timestamp", + "overloads": [ + { + "id": "int64_to_timestamp", + "args": [ + "int" + ], + "result": "google.protobuf.Timestamp" + }, + { + "id": "string_to_timestamp", + "args": [ + "string" + ], + "result": "google.protobuf.Timestamp" + }, + { + "id": "timestamp_to_timestamp", + "args": [ + "google.protobuf.Timestamp" + ], + "result": "google.protobuf.Timestamp" + } + ], + "examples": [ + "timestamp(timestamp('2023-01-01T00:00:00Z')) // timestamp('2023-01-01T00:00:00Z')", + "timestamp(1) // timestamp('1970-01-01T00:00:01Z')", + "timestamp('2025-01-01T12:34:56Z') // timestamp('2025-01-01T12:34:56Z')" + ] + }, + { + "name": "title", + "memberOnly": true, + "overloads": [ + { + "id": "string_title", + "args": [ + "string" + ], + "result": "string", + "member": true + } + ] + }, + { + "name": "toCSV", + "overloads": [ + { + "id": "toCSV_interface{}", + "args": [ + "dyn" + ], + "result": "string" + } + ] + }, + { + "name": "toJSON", + "memberOnly": true, + "overloads": [ + { + "id": "dyn_toJSON", + "args": [ + "dyn" + ], + "result": "string", + "member": true + } + ] + }, + { + "name": "toJSONPretty", + "memberOnly": true, + "overloads": [ + { + "id": "toJSONPretty_interface{}", + "args": [ + "dyn", + "string" + ], + "result": "string", + "member": true + } + ] + }, + { + "name": "toLower", + "memberOnly": true, + "overloads": [ + { + "id": "string_toLower", + "args": [ + "dyn" + ], + "result": "string", + "member": true + } + ] + }, + { + "name": "toTOML", + "overloads": [ + { + "id": "toTOML_interface{}", + "args": [ + "dyn" + ], + "result": "string" + } + ] + }, + { + "name": "toUpper", + "memberOnly": true, + "overloads": [ + { + "id": "string_toUpper", + "args": [ + "dyn" + ], + "result": "string", + "member": true + } + ] + }, + { + "name": "toYAML", + "overloads": [ + { + "id": "toYAML_interface{}", + "args": [ + "dyn" + ], + "result": "string" + } + ] + }, + { + "name": "trim", + "memberOnly": true, + "overloads": [ + { + "id": "string_trim", + "args": [ + "string" + ], + "result": "string", + "member": true + } + ] + }, + { + "name": "trimPrefix", + "memberOnly": true, + "overloads": [ + { + "id": "string_trimPrefix", + "args": [ + "string", + "dyn" + ], + "result": "string", + "member": true + } + ] + }, + { + "name": "trimSpace", + "memberOnly": true, + "overloads": [ + { + "id": "string_trimSpace", + "args": [ + "dyn" + ], + "result": "string", + "member": true + } + ] + }, + { + "name": "trimSuffix", + "memberOnly": true, + "overloads": [ + { + "id": "string_trimSuffix", + "args": [ + "string", + "dyn" + ], + "result": "string", + "member": true + } + ] + }, + { + "name": "trunc", + "memberOnly": true, + "overloads": [ + { + "id": "string_trunc", + "args": [ + "int", + "dyn" + ], + "result": "string", + "member": true + } + ] + }, + { + "name": "type", + "doc": "convert a value to its type identifier", + "overloads": [ + { + "id": "type", + "args": [ + "\u003cA\u003e" + ], + "result": "type(\u003cA\u003e)" + } + ], + "examples": [ + "type(1) // int\ntype('hello') // string\ntype(int) // type\ntype(type) // type" + ] + }, + { + "name": "uint", + "doc": "convert a value to a uint", + "overloads": [ + { + "id": "double_to_uint64", + "args": [ + "double" + ], + "result": "uint" + }, + { + "id": "int64_to_uint64", + "args": [ + "int" + ], + "result": "uint" + }, + { + "id": "string_to_uint64", + "args": [ + "string" + ], + "result": "uint" + }, + { + "id": "uint64_to_uint64", + "args": [ + "uint" + ], + "result": "uint" + } + ], + "examples": [ + "uint(123u) // 123u", + "uint(123.45) // 123u", + "uint(123) // 123u", + "uint('123') // 123u" + ] + }, + { + "name": "uniq", + "memberOnly": true, + "overloads": [ + { + "id": "uniq_interface{}", + "args": [ + "list(dyn)" + ], + "result": "list(dyn)", + "member": true + } + ] + }, + { + "name": "upperAscii", + "memberOnly": true, + "overloads": [ + { + "id": "string_upper_ascii", + "args": [ + "string" + ], + "result": "string", + "member": true + } + ] + }, + { + "name": "url", + "overloads": [ + { + "id": "string_to_url", + "args": [ + "string" + ], + "result": "kubernetes.URL" + } + ] + }, + { + "name": "urldecode", + "overloads": [ + { + "id": "urldecode.string", + "args": [ + "string" + ], + "result": "string" + } + ] + }, + { + "name": "urlencode", + "overloads": [ + { + "id": "urlencode.string", + "args": [ + "string" + ], + "result": "string" + } + ] + }, + { + "name": "uuid.HashUUID", + "namespace": "uuid", + "overloads": [ + { + "id": "uuid.HashUUID_list", + "args": [ + "list(dyn)" + ], + "result": "string" + } + ] + }, + { + "name": "uuid.IsValid", + "namespace": "uuid", + "overloads": [ + { + "id": "uuid.IsValid_interface{}", + "args": [ + "string" + ], + "result": "bool" + } + ] + }, + { + "name": "uuid.Nil", + "namespace": "uuid", + "overloads": [ + { + "id": "uuid.Nil_", + "args": [], + "result": "string" + } + ] + }, + { + "name": "uuid.Parse", + "namespace": "uuid", + "overloads": [ + { + "id": "uuid.Parse_interface{}", + "args": [ + "dyn" + ], + "result": "dyn" + } + ] + }, + { + "name": "uuid.V1", + "namespace": "uuid", + "overloads": [ + { + "id": "uuid.V1_", + "args": [], + "result": "string" + } + ] + }, + { + "name": "uuid.V4", + "namespace": "uuid", + "overloads": [ + { + "id": "uuid.V4_", + "args": [], + "result": "string" + } + ] + }, + { + "name": "value", + "memberOnly": true, + "doc": "obtain the value contained by the optional, error if optional.none()", + "overloads": [ + { + "id": "optional_value", + "args": [ + "optional_type(\u003cV\u003e)" + ], + "result": "\u003cV\u003e", + "member": true + } + ], + "examples": [ + "optional.of(1).value() // 1\noptional.none().value() // error" + ] + }, + { + "name": "values", + "memberOnly": true, + "overloads": [ + { + "id": "map_values", + "args": [ + "map(string, google.protobuf.Any)" + ], + "result": "list(google.protobuf.Any)", + "member": true + } + ] + }, + { + "name": "wordWrap", + "memberOnly": true, + "overloads": [ + { + "id": "WordWrap_interface{}", + "args": [ + "string", + "int" + ], + "result": "string", + "member": true + }, + { + "id": "stringsWordWrapSeqAndWidthGen", + "args": [ + "string", + "int", + "string" + ], + "result": "string", + "member": true + } + ] + }, + { + "name": "xpath", + "overloads": [ + { + "id": "xpath_string_string", + "args": [ + "string", + "string" + ], + "result": "dyn" + } + ] + } + ] + }, + "gotemplate": { + "namespaces": [ + "base64", + "coll", + "conv", + "crypto", + "data", + "filepath", + "k8s", + "math", + "net", + "path", + "random", + "regexp", + "strings", + "test", + "time", + "uuid" + ], + "keywords": [ + "block", + "break", + "continue", + "define", + "else", + "end", + "if", + "nil", + "range", + "template", + "with" + ], + "builtins": [ + "and", + "call", + "eq", + "ge", + "gt", + "html", + "index", + "js", + "le", + "len", + "lt", + "ne", + "not", + "or", + "print", + "printf", + "println", + "slice", + "urlquery" + ], + "delimiters": { + "left": "{{", + "right": "}}", + "leftComment": "/*", + "rightComment": "*/", + "trimMarker": "-" + }, + "functions": [ + { + "name": "add", + "signature": "(n ...interface {}) interface {}" + }, + { + "name": "append", + "signature": "(v interface {}, list interface {}) ([]interface {}, error)" + }, + { + "name": "assert", + "signature": "(args ...interface {}) (string, error)" + }, + { + "name": "base64.Decode", + "namespace": "base64", + "signature": "(in interface {}) (string, error)" + }, + { + "name": "base64.DecodeBytes", + "namespace": "base64", + "signature": "(in interface {}) ([]uint8, error)" + }, + { + "name": "base64.Encode", + "namespace": "base64", + "signature": "(in interface {}) (string, error)" + }, + { + "name": "bool", + "signature": "(s interface {}) bool" + }, + { + "name": "coalesce", + "signature": "(args ...interface {}) interface {}" + }, + { + "name": "coll.Append", + "namespace": "coll", + "signature": "(v interface {}, list interface {}) ([]interface {}, error)" + }, + { + "name": "coll.Coalesce", + "namespace": "coll", + "signature": "(args ...interface {}) interface {}" + }, + { + "name": "coll.Dict", + "namespace": "coll", + "signature": "(in ...interface {}) (map[string]interface {}, error)" + }, + { + "name": "coll.First", + "namespace": "coll", + "signature": "(in interface {}) interface {}" + }, + { + "name": "coll.Flatten", + "namespace": "coll", + "signature": "(args ...interface {}) ([]interface {}, error)" + }, + { + "name": "coll.Has", + "namespace": "coll", + "signature": "(in interface {}, key string) bool" + }, + { + "name": "coll.JQ", + "namespace": "coll", + "signature": "(jqExpr string, in interface {}) (interface {}, error)" + }, + { + "name": "coll.Keys", + "namespace": "coll", + "signature": "(in map[string]interface {}) []string" + }, + { + "name": "coll.Last", + "namespace": "coll", + "signature": "(in interface {}) interface {}" + }, + { + "name": "coll.Merge", + "namespace": "coll", + "signature": "(dst map[string]interface {}, src ...map[string]interface {}) (map[string]interface {}, error)" + }, + { + "name": "coll.Omit", + "namespace": "coll", + "signature": "(args ...interface {}) (map[string]interface {}, error)" + }, + { + "name": "coll.Pick", + "namespace": "coll", + "signature": "(args ...interface {}) (map[string]interface {}, error)" + }, + { + "name": "coll.Prepend", + "namespace": "coll", + "signature": "(v interface {}, list interface {}) ([]interface {}, error)" + }, + { + "name": "coll.Reverse", + "namespace": "coll", + "signature": "(in interface {}) ([]interface {}, error)" + }, + { + "name": "coll.Slice", + "namespace": "coll", + "signature": "(args ...interface {}) []interface {}" + }, + { + "name": "coll.Sort", + "namespace": "coll", + "signature": "(args ...interface {}) ([]interface {}, error)" + }, + { + "name": "coll.Uniq", + "namespace": "coll", + "signature": "(in interface {}) ([]interface {}, error)" + }, + { + "name": "coll.Values", + "namespace": "coll", + "signature": "(in map[string]interface {}) []interface {}" + }, + { + "name": "contains", + "signature": "(s string, substr string) bool" + }, + { + "name": "conv.Atoi", + "namespace": "conv", + "signature": "(s interface {}) int" + }, + { + "name": "conv.Bool", + "namespace": "conv", + "signature": "(s interface {}) bool" + }, + { + "name": "conv.Default", + "namespace": "conv", + "signature": "(def interface {}, in interface {}) interface {}" + }, + { + "name": "conv.Dict", + "namespace": "conv", + "signature": "(in ...interface {}) (map[string]interface {}, error)" + }, + { + "name": "conv.Has", + "namespace": "conv", + "signature": "(in interface {}, key string) bool" + }, + { + "name": "conv.Join", + "namespace": "conv", + "signature": "(in interface {}, sep string) (string, error)" + }, + { + "name": "conv.ParseFloat", + "namespace": "conv", + "signature": "(s interface {}, bitSize int) float64" + }, + { + "name": "conv.ParseInt", + "namespace": "conv", + "signature": "(s interface {}, base int, bitSize int) int64" + }, + { + "name": "conv.ParseUint", + "namespace": "conv", + "signature": "(s interface {}, base int, bitSize int) uint64" + }, + { + "name": "conv.Slice", + "namespace": "conv", + "signature": "(args ...interface {}) []interface {}" + }, + { + "name": "conv.ToBool", + "namespace": "conv", + "signature": "(in interface {}) bool" + }, + { + "name": "conv.ToBools", + "namespace": "conv", + "signature": "(in ...interface {}) []bool" + }, + { + "name": "conv.ToFloat64", + "namespace": "conv", + "signature": "(in interface {}) float64" + }, + { + "name": "conv.ToFloat64s", + "namespace": "conv", + "signature": "(in ...interface {}) []float64" + }, + { + "name": "conv.ToInt", + "namespace": "conv", + "signature": "(in interface {}) int" + }, + { + "name": "conv.ToInt64", + "namespace": "conv", + "signature": "(in interface {}) int64" + }, + { + "name": "conv.ToInt64s", + "namespace": "conv", + "signature": "(in ...interface {}) []int64" + }, + { + "name": "conv.ToInts", + "namespace": "conv", + "signature": "(in ...interface {}) []int" + }, + { + "name": "conv.ToString", + "namespace": "conv", + "signature": "(in interface {}) string" + }, + { + "name": "conv.ToStrings", + "namespace": "conv", + "signature": "(in ...interface {}) []string" + }, + { + "name": "conv.URL", + "namespace": "conv", + "signature": "(s interface {}) (*url.URL, error)" + }, + { + "name": "crypto.SHA1", + "namespace": "crypto", + "signature": "(input interface {}) string" + }, + { + "name": "crypto.SHA1Bytes", + "namespace": "crypto", + "signature": "(input interface {}) ([]uint8, error)" + }, + { + "name": "crypto.SHA224", + "namespace": "crypto", + "signature": "(input interface {}) string" + }, + { + "name": "crypto.SHA224Bytes", + "namespace": "crypto", + "signature": "(input interface {}) ([]uint8, error)" + }, + { + "name": "crypto.SHA256", + "namespace": "crypto", + "signature": "(input interface {}) string" + }, + { + "name": "crypto.SHA256Bytes", + "namespace": "crypto", + "signature": "(input interface {}) ([]uint8, error)" + }, + { + "name": "crypto.SHA384", + "namespace": "crypto", + "signature": "(input interface {}) string" + }, + { + "name": "crypto.SHA384Bytes", + "namespace": "crypto", + "signature": "(input interface {}) ([]uint8, error)" + }, + { + "name": "crypto.SHA512", + "namespace": "crypto", + "signature": "(input interface {}) string" + }, + { + "name": "crypto.SHA512Bytes", + "namespace": "crypto", + "signature": "(input interface {}) ([]uint8, error)" + }, + { + "name": "crypto.SHA512_224", + "namespace": "crypto", + "signature": "(input interface {}) string" + }, + { + "name": "crypto.SHA512_224Bytes", + "namespace": "crypto", + "signature": "(input interface {}) ([]uint8, error)" + }, + { + "name": "crypto.SHA512_256", + "namespace": "crypto", + "signature": "(input interface {}) string" + }, + { + "name": "crypto.SHA512_256Bytes", + "namespace": "crypto", + "signature": "(input interface {}) ([]uint8, error)" + }, + { + "name": "csv", + "signature": "(args ...string) ([][]string, error)" + }, + { + "name": "csvByColumn", + "signature": "(args ...string) (map[string][]string, error)" + }, + { + "name": "csvByRow", + "signature": "(args ...string) ([]map[string]string, error)" + }, + { + "name": "data.CSV", + "namespace": "data", + "signature": "(args ...string) ([][]string, error)" + }, + { + "name": "data.CSVByColumn", + "namespace": "data", + "signature": "(args ...string) (map[string][]string, error)" + }, + { + "name": "data.CSVByRow", + "namespace": "data", + "signature": "(args ...string) ([]map[string]string, error)" + }, + { + "name": "data.JSON", + "namespace": "data", + "signature": "(in interface {}) (map[string]interface {}, error)" + }, + { + "name": "data.JSONArray", + "namespace": "data", + "signature": "(in interface {}) ([]interface {}, error)" + }, + { + "name": "data.TOML", + "namespace": "data", + "signature": "(in interface {}) (interface {}, error)" + }, + { + "name": "data.ToCSV", + "namespace": "data", + "signature": "(args ...interface {}) (string, error)" + }, + { + "name": "data.ToJSON", + "namespace": "data", + "signature": "(in interface {}) (string, error)" + }, + { + "name": "data.ToJSONPretty", + "namespace": "data", + "signature": "(indent string, in interface {}) (string, error)" + }, + { + "name": "data.ToTOML", + "namespace": "data", + "signature": "(in interface {}) (string, error)" + }, + { + "name": "data.ToYAML", + "namespace": "data", + "signature": "(in interface {}) (string, error)" + }, + { + "name": "data.YAML", + "namespace": "data", + "signature": "(in interface {}) (map[string]interface {}, error)" + }, + { + "name": "data.YAMLArray", + "namespace": "data", + "signature": "(in interface {}) ([]interface {}, error)" + }, + { + "name": "default", + "signature": "(def interface {}, in interface {}) interface {}" + }, + { + "name": "dict", + "signature": "(in ...interface {}) (map[string]interface {}, error)" + }, + { + "name": "div", + "signature": "(a interface {}, b interface {}) (interface {}, error)" + }, + { + "name": "endsWith", + "signature": "(s string, suffix string) bool" + }, + { + "name": "fail", + "signature": "(args ...interface {}) (string, error)" + }, + { + "name": "filepath.Base", + "namespace": "filepath", + "signature": "(in interface {}) string" + }, + { + "name": "filepath.Clean", + "namespace": "filepath", + "signature": "(in interface {}) string" + }, + { + "name": "filepath.Dir", + "namespace": "filepath", + "signature": "(in interface {}) string" + }, + { + "name": "filepath.Ext", + "namespace": "filepath", + "signature": "(in interface {}) string" + }, + { + "name": "filepath.FromSlash", + "namespace": "filepath", + "signature": "(in interface {}) string" + }, + { + "name": "filepath.IsAbs", + "namespace": "filepath", + "signature": "(in interface {}) bool" + }, + { + "name": "filepath.Join", + "namespace": "filepath", + "signature": "(elem ...interface {}) string" + }, + { + "name": "filepath.Match", + "namespace": "filepath", + "signature": "(pattern interface {}, name interface {}) (bool, error)" + }, + { + "name": "filepath.Rel", + "namespace": "filepath", + "signature": "(basepath interface {}, targpath interface {}) (string, error)" + }, + { + "name": "filepath.Split", + "namespace": "filepath", + "signature": "(in interface {}) []string" + }, + { + "name": "filepath.ToSlash", + "namespace": "filepath", + "signature": "(in interface {}) string" + }, + { + "name": "filepath.VolumeName", + "namespace": "filepath", + "signature": "(in interface {}) string" + }, + { + "name": "first", + "signature": "(in interface {}) interface {}" + }, + { + "name": "flatten", + "signature": "(args ...interface {}) ([]interface {}, error)" + }, + { + "name": "getHealth", + "signature": "(in interface {}) kubernetes.HealthStatus" + }, + { + "name": "getStatus", + "signature": "(in interface {}) string" + }, + { + "name": "has", + "signature": "(in interface {}, key string) bool" + }, + { + "name": "hasPrefix", + "signature": "(s string, prefix string) bool" + }, + { + "name": "hasSuffix", + "signature": "(s string, suffix string) bool" + }, + { + "name": "humanDuration", + "signature": "(duration interface {}) string" + }, + { + "name": "humanSize", + "signature": "(size interface {}) string" + }, + { + "name": "in_business_hours", + "signature": "(value string) (interface {}, error)" + }, + { + "name": "indent", + "signature": "(args ...interface {}) (string, error)" + }, + { + "name": "isHealthy", + "signature": "(in interface {}) bool" + }, + { + "name": "isKind", + "signature": "(kind string, arg interface {}) bool" + }, + { + "name": "isReady", + "signature": "(in interface {}) bool" + }, + { + "name": "jmespath", + "signature": "(jmesPath string, in interface {}) (interface {}, error)" + }, + { + "name": "join", + "signature": "(in interface {}, sep string) (string, error)" + }, + { + "name": "jq", + "signature": "(jqExpr string, in interface {}) (interface {}, error)" + }, + { + "name": "json", + "signature": "(in interface {}) (map[string]interface {}, error)" + }, + { + "name": "jsonArray", + "signature": "(in interface {}) ([]interface {}, error)" + }, + { + "name": "jsonpath", + "signature": "(jsonPath string, in interface {}) (interface {}, error)" + }, + { + "name": "k8s.GetHealth", + "namespace": "k8s", + "signature": "(in interface {}) kubernetes.HealthStatus" + }, + { + "name": "k8s.GetHealthMap", + "namespace": "k8s", + "signature": "(in interface {}) map[string]string" + }, + { + "name": "k8s.GetStatus", + "namespace": "k8s", + "signature": "(in interface {}) string" + }, + { + "name": "k8s.IsHealthy", + "namespace": "k8s", + "signature": "(in interface {}) bool" + }, + { + "name": "k8s.IsReady", + "namespace": "k8s", + "signature": "(in interface {}) bool" + }, + { + "name": "k8s.Neat", + "namespace": "k8s", + "signature": "(in string) (string, error)" + }, + { + "name": "keyValToMap", + "signature": "(s string) (map[string]string, error)" + }, + { + "name": "keys", + "signature": "(in map[string]interface {}) []string" + }, + { + "name": "kind", + "signature": "(arg interface {}) string" + }, + { + "name": "last", + "signature": "(in interface {}) interface {}" + }, + { + "name": "mapToKeyVal", + "signature": "(m map[string]interface {}) string" + }, + { + "name": "matchLabel", + "signature": "(labels map[string]interface {}, key string, valuePatterns ...string) bool" + }, + { + "name": "math.Abs", + "namespace": "math", + "signature": "(n interface {}) interface {}" + }, + { + "name": "math.Add", + "namespace": "math", + "signature": "(n ...interface {}) interface {}" + }, + { + "name": "math.Ceil", + "namespace": "math", + "signature": "(n interface {}) interface {}" + }, + { + "name": "math.Div", + "namespace": "math", + "signature": "(a interface {}, b interface {}) (interface {}, error)" + }, + { + "name": "math.Floor", + "namespace": "math", + "signature": "(n interface {}) interface {}" + }, + { + "name": "math.IsFloat", + "namespace": "math", + "signature": "(n interface {}) bool" + }, + { + "name": "math.IsInt", + "namespace": "math", + "signature": "(n interface {}) bool" + }, + { + "name": "math.IsNum", + "namespace": "math", + "signature": "(n interface {}) bool" + }, + { + "name": "math.Max", + "namespace": "math", + "signature": "(a interface {}, b ...interface {}) (interface {}, error)" + }, + { + "name": "math.Min", + "namespace": "math", + "signature": "(a interface {}, b ...interface {}) (interface {}, error)" + }, + { + "name": "math.Mul", + "namespace": "math", + "signature": "(n ...interface {}) interface {}" + }, + { + "name": "math.Pow", + "namespace": "math", + "signature": "(a interface {}, b interface {}) interface {}" + }, + { + "name": "math.Rem", + "namespace": "math", + "signature": "(a interface {}, b interface {}) interface {}" + }, + { + "name": "math.Round", + "namespace": "math", + "signature": "(n interface {}) interface {}" + }, + { + "name": "math.Seq", + "namespace": "math", + "signature": "(n ...interface {}) ([]int64, error)" + }, + { + "name": "math.Sub", + "namespace": "math", + "signature": "(a interface {}, b interface {}) interface {}" + }, + { + "name": "merge", + "signature": "(dst map[string]interface {}, src ...map[string]interface {}) (map[string]interface {}, error)" + }, + { + "name": "mul", + "signature": "(n ...interface {}) interface {}" + }, + { + "name": "neat", + "signature": "(in string) (string, error)" + }, + { + "name": "net.ContainsCIDR", + "namespace": "net", + "signature": "(cidr string, ip string) bool" + }, + { + "name": "net.IsValidIP", + "namespace": "net", + "signature": "(ip string) bool" + }, + { + "name": "parseDateTime", + "signature": "(timeStr string) *time.Time" + }, + { + "name": "path.Base", + "namespace": "path", + "signature": "(in interface {}) string" + }, + { + "name": "path.Clean", + "namespace": "path", + "signature": "(in interface {}) string" + }, + { + "name": "path.Dir", + "namespace": "path", + "signature": "(in interface {}) string" + }, + { + "name": "path.Ext", + "namespace": "path", + "signature": "(in interface {}) string" + }, + { + "name": "path.IsAbs", + "namespace": "path", + "signature": "(in interface {}) bool" + }, + { + "name": "path.Join", + "namespace": "path", + "signature": "(elem ...interface {}) string" + }, + { + "name": "path.Match", + "namespace": "path", + "signature": "(pattern interface {}, name interface {}) (bool, error)" + }, + { + "name": "path.Split", + "namespace": "path", + "signature": "(in interface {}) []string" + }, + { + "name": "pow", + "signature": "(a interface {}, b interface {}) interface {}" + }, + { + "name": "prepend", + "signature": "(v interface {}, list interface {}) ([]interface {}, error)" + }, + { + "name": "quote", + "signature": "(in interface {}) string" + }, + { + "name": "random.ASCII", + "namespace": "random", + "signature": "(count interface {}) (string, error)" + }, + { + "name": "random.Alpha", + "namespace": "random", + "signature": "(count interface {}) (string, error)" + }, + { + "name": "random.AlphaNum", + "namespace": "random", + "signature": "(count interface {}) (string, error)" + }, + { + "name": "random.Float", + "namespace": "random", + "signature": "(args ...interface {}) (float64, error)" + }, + { + "name": "random.Item", + "namespace": "random", + "signature": "(items interface {}) (interface {}, error)" + }, + { + "name": "random.Number", + "namespace": "random", + "signature": "(args ...interface {}) (int64, error)" + }, + { + "name": "random.String", + "namespace": "random", + "signature": "(count interface {}, args ...interface {}) (string, error)" + }, + { + "name": "regexp.Find", + "namespace": "regexp", + "signature": "(re interface {}, input interface {}) (string, error)" + }, + { + "name": "regexp.FindAll", + "namespace": "regexp", + "signature": "(args ...interface {}) ([]string, error)" + }, + { + "name": "regexp.Match", + "namespace": "regexp", + "signature": "(re interface {}, input interface {}) bool" + }, + { + "name": "regexp.QuoteMeta", + "namespace": "regexp", + "signature": "(in interface {}) string" + }, + { + "name": "regexp.Replace", + "namespace": "regexp", + "signature": "(re interface {}, replacement interface {}, input interface {}) string" + }, + { + "name": "regexp.ReplaceLiteral", + "namespace": "regexp", + "signature": "(re interface {}, replacement interface {}, input interface {}) (string, error)" + }, + { + "name": "regexp.Split", + "namespace": "regexp", + "signature": "(args ...interface {}) ([]string, error)" + }, + { + "name": "rem", + "signature": "(a interface {}, b interface {}) interface {}" + }, + { + "name": "replaceAll", + "signature": "(old string, new string, s interface {}) string" + }, + { + "name": "required", + "signature": "(args ...interface {}) (interface {}, error)" + }, + { + "name": "reverse", + "signature": "(in interface {}) ([]interface {}, error)" + }, + { + "name": "semver", + "signature": "(version string) (*semver.Version, error)" + }, + { + "name": "semverCompare", + "signature": "(constraint string, version string) (bool, error)" + }, + { + "name": "seq", + "signature": "(n ...interface {}) ([]int64, error)" + }, + { + "name": "shellQuote", + "signature": "(in interface {}) string" + }, + { + "name": "slice", + "signature": "(args ...interface {}) []interface {}" + }, + { + "name": "sort", + "signature": "(args ...interface {}) ([]interface {}, error)" + }, + { + "name": "split", + "signature": "(s string, sep string) []string" + }, + { + "name": "splitN", + "signature": "(s string, sep string, n int) []string" + }, + { + "name": "squote", + "signature": "(in interface {}) string" + }, + { + "name": "startsWith", + "signature": "(s string, prefix string) bool" + }, + { + "name": "strings.Abbrev", + "namespace": "strings", + "signature": "(args ...interface {}) (string, error)" + }, + { + "name": "strings.CamelCase", + "namespace": "strings", + "signature": "(in interface {}) (string, error)" + }, + { + "name": "strings.Contains", + "namespace": "strings", + "signature": "(substr string, s interface {}) bool" + }, + { + "name": "strings.HasPrefix", + "namespace": "strings", + "signature": "(prefix string, s interface {}) bool" + }, + { + "name": "strings.HasSuffix", + "namespace": "strings", + "signature": "(suffix string, s interface {}) bool" + }, + { + "name": "strings.HumanDuration", + "namespace": "strings", + "signature": "(in interface {}) (string, error)" + }, + { + "name": "strings.HumanSize", + "namespace": "strings", + "signature": "(in interface {}) (string, error)" + }, + { + "name": "strings.Indent", + "namespace": "strings", + "signature": "(args ...interface {}) (string, error)" + }, + { + "name": "strings.KebabCase", + "namespace": "strings", + "signature": "(in interface {}) (string, error)" + }, + { + "name": "strings.Quote", + "namespace": "strings", + "signature": "(in interface {}) string" + }, + { + "name": "strings.Repeat", + "namespace": "strings", + "signature": "(count int, s interface {}) (string, error)" + }, + { + "name": "strings.ReplaceAll", + "namespace": "strings", + "signature": "(old string, new string, s interface {}) string" + }, + { + "name": "strings.RuneCount", + "namespace": "strings", + "signature": "(args ...interface {}) (int, error)" + }, + { + "name": "strings.Semver", + "namespace": "strings", + "signature": "(in string) (*semver.Version, error)" + }, + { + "name": "strings.SemverCompare", + "namespace": "strings", + "signature": "(v1 string, v2 string) (bool, error)" + }, + { + "name": "strings.SemverMap", + "namespace": "strings", + "signature": "(in string) (map[string]string, error)" + }, + { + "name": "strings.ShellQuote", + "namespace": "strings", + "signature": "(in interface {}) string" + }, + { + "name": "strings.Slug", + "namespace": "strings", + "signature": "(in interface {}) string" + }, + { + "name": "strings.SnakeCase", + "namespace": "strings", + "signature": "(in interface {}) (string, error)" + }, + { + "name": "strings.Sort", + "namespace": "strings", + "signature": "(list interface {}) ([]string, error)" + }, + { + "name": "strings.Split", + "namespace": "strings", + "signature": "(sep string, s interface {}) []string" + }, + { + "name": "strings.SplitN", + "namespace": "strings", + "signature": "(sep string, n int, s interface {}) []string" + }, + { + "name": "strings.Squote", + "namespace": "strings", + "signature": "(in interface {}) string" + }, + { + "name": "strings.Title", + "namespace": "strings", + "signature": "(s interface {}) string" + }, + { + "name": "strings.ToLower", + "namespace": "strings", + "signature": "(s interface {}) string" + }, + { + "name": "strings.ToUpper", + "namespace": "strings", + "signature": "(s interface {}) string" + }, + { + "name": "strings.Trim", + "namespace": "strings", + "signature": "(cutset string, s interface {}) string" + }, + { + "name": "strings.TrimPrefix", + "namespace": "strings", + "signature": "(cutset string, s interface {}) string" + }, + { + "name": "strings.TrimSpace", + "namespace": "strings", + "signature": "(s interface {}) string" + }, + { + "name": "strings.TrimSuffix", + "namespace": "strings", + "signature": "(cutset string, s interface {}) string" + }, + { + "name": "strings.Trunc", + "namespace": "strings", + "signature": "(length int, s interface {}) string" + }, + { + "name": "strings.WordWrap", + "namespace": "strings", + "signature": "(args ...interface {}) (string, error)" + }, + { + "name": "sub", + "signature": "(a interface {}, b interface {}) interface {}" + }, + { + "name": "ternary", + "signature": "(tval interface {}, fval interface {}, b interface {}) interface {}" + }, + { + "name": "test.Assert", + "namespace": "test", + "signature": "(args ...interface {}) (string, error)" + }, + { + "name": "test.Fail", + "namespace": "test", + "signature": "(args ...interface {}) (string, error)" + }, + { + "name": "test.IsKind", + "namespace": "test", + "signature": "(kind string, arg interface {}) bool" + }, + { + "name": "test.Kind", + "namespace": "test", + "signature": "(arg interface {}) string" + }, + { + "name": "test.Required", + "namespace": "test", + "signature": "(args ...interface {}) (interface {}, error)" + }, + { + "name": "test.Ternary", + "namespace": "test", + "signature": "(tval interface {}, fval interface {}, b interface {}) interface {}" + }, + { + "name": "time.Hour", + "namespace": "time", + "signature": "(n interface {}) time.Duration" + }, + { + "name": "time.InBusinessHour", + "namespace": "time", + "signature": "(value string) (interface {}, error)" + }, + { + "name": "time.InTimeRange", + "namespace": "time", + "signature": "(t interface {}, start string, end string) (bool, error)" + }, + { + "name": "time.Microsecond", + "namespace": "time", + "signature": "(n interface {}) time.Duration" + }, + { + "name": "time.Millisecond", + "namespace": "time", + "signature": "(n interface {}) time.Duration" + }, + { + "name": "time.Minute", + "namespace": "time", + "signature": "(n interface {}) time.Duration" + }, + { + "name": "time.Nanosecond", + "namespace": "time", + "signature": "(n interface {}) time.Duration" + }, + { + "name": "time.Now", + "namespace": "time", + "signature": "() time.Time" + }, + { + "name": "time.Parse", + "namespace": "time", + "signature": "(layout string, value interface {}) (time.Time, error)" + }, + { + "name": "time.ParseDuration", + "namespace": "time", + "signature": "(n interface {}) (time.Duration, error)" + }, + { + "name": "time.ParseInLocation", + "namespace": "time", + "signature": "(layout string, location string, value interface {}) (time.Time, error)" + }, + { + "name": "time.ParseLocal", + "namespace": "time", + "signature": "(layout string, value interface {}) (time.Time, error)" + }, + { + "name": "time.Second", + "namespace": "time", + "signature": "(n interface {}) time.Duration" + }, + { + "name": "time.Since", + "namespace": "time", + "signature": "(n time.Time) time.Duration" + }, + { + "name": "time.Unix", + "namespace": "time", + "signature": "(in interface {}) (time.Time, error)" + }, + { + "name": "time.Until", + "namespace": "time", + "signature": "(n time.Time) time.Duration" + }, + { + "name": "time.ZoneName", + "namespace": "time", + "signature": "() string" + }, + { + "name": "time.ZoneOffset", + "namespace": "time", + "signature": "() int" + }, + { + "name": "title", + "signature": "(s interface {}) string" + }, + { + "name": "toCSV", + "signature": "(args ...interface {}) (string, error)" + }, + { + "name": "toJSON", + "signature": "(in interface {}) (string, error)" + }, + { + "name": "toJSONPretty", + "signature": "(indent string, in interface {}) (string, error)" + }, + { + "name": "toLower", + "signature": "(s interface {}) string" + }, + { + "name": "toTOML", + "signature": "(in interface {}) (string, error)" + }, + { + "name": "toUpper", + "signature": "(s interface {}) string" + }, + { + "name": "toYAML", + "signature": "(in interface {}) (string, error)" + }, + { + "name": "toml", + "signature": "(in interface {}) (interface {}, error)" + }, + { + "name": "trim", + "signature": "(s string, cutset string) string" + }, + { + "name": "uniq", + "signature": "(in interface {}) ([]interface {}, error)" + }, + { + "name": "urlParse", + "signature": "(s interface {}) (*url.URL, error)" + }, + { + "name": "urldecode", + "signature": "(input string) (string, error)" + }, + { + "name": "urlencode", + "signature": "(input string) string" + }, + { + "name": "uuid.HashUUID", + "namespace": "uuid", + "signature": "(args ...interface {}) (string, error)" + }, + { + "name": "uuid.IsValid", + "namespace": "uuid", + "signature": "(in interface {}) (bool, error)" + }, + { + "name": "uuid.Nil", + "namespace": "uuid", + "signature": "() (string, error)" + }, + { + "name": "uuid.Parse", + "namespace": "uuid", + "signature": "(in interface {}) (string, error)" + }, + { + "name": "uuid.V1", + "namespace": "uuid", + "signature": "() (string, error)" + }, + { + "name": "uuid.V4", + "namespace": "uuid", + "signature": "() (string, error)" + }, + { + "name": "values", + "signature": "(in map[string]interface {}) []interface {}" + }, + { + "name": "xpath", + "signature": "(xpathStr string, xmlStr string) ([]string, error)" + }, + { + "name": "yaml", + "signature": "(in interface {}) (map[string]interface {}, error)" + }, + { + "name": "yamlArray", + "signature": "(in interface {}) ([]interface {}, error)" + } + ] + } +} diff --git a/packages/expressions/src/lang/generated/text-gomplate.config.json b/packages/expressions/src/lang/generated/text-gomplate.config.json new file mode 100644 index 000000000..c087049ba --- /dev/null +++ b/packages/expressions/src/lang/generated/text-gomplate.config.json @@ -0,0 +1,31 @@ +{ + "brackets": [ + [ + "{", + "}" + ], + [ + "[", + "]" + ] + ], + "autoClosingPairs": [ + { + "open": "{", + "close": "}" + }, + { + "open": "[", + "close": "]" + }, + { + "open": "\"", + "close": "\"" + }, + { + "open": "'", + "close": "'" + } + ], + "wordPattern": "[A-Za-z_][A-Za-z0-9_]*(?:\\.[A-Za-z_][A-Za-z0-9_]*)*" +} diff --git a/packages/expressions/src/lang/generated/text-gomplate.monarch.json b/packages/expressions/src/lang/generated/text-gomplate.monarch.json new file mode 100644 index 000000000..829720674 --- /dev/null +++ b/packages/expressions/src/lang/generated/text-gomplate.monarch.json @@ -0,0 +1,287 @@ +{ + "defaultToken": "", + "tokenPostfix": ".text-gomplate", + "brackets": [ + { + "open": "{", + "close": "}", + "token": "delimiter.curly" + }, + { + "open": "[", + "close": "]", + "token": "delimiter.square" + }, + { + "open": "(", + "close": ")", + "token": "delimiter.parenthesis" + } + ], + "builtins": [ + "and", + "call", + "eq", + "ge", + "gt", + "html", + "index", + "js", + "le", + "len", + "lt", + "ne", + "not", + "or", + "print", + "printf", + "println", + "slice", + "urlquery" + ], + "functions": [ + "add", + "append", + "assert", + "bool", + "coalesce", + "contains", + "csv", + "csvByColumn", + "csvByRow", + "default", + "dict", + "div", + "endsWith", + "fail", + "first", + "flatten", + "getHealth", + "getStatus", + "has", + "hasPrefix", + "hasSuffix", + "humanDuration", + "humanSize", + "in_business_hours", + "indent", + "isHealthy", + "isKind", + "isReady", + "jmespath", + "join", + "jq", + "json", + "jsonArray", + "jsonpath", + "keyValToMap", + "keys", + "kind", + "last", + "mapToKeyVal", + "matchLabel", + "merge", + "mul", + "neat", + "parseDateTime", + "pow", + "prepend", + "quote", + "rem", + "replaceAll", + "required", + "reverse", + "semver", + "semverCompare", + "seq", + "shellQuote", + "slice", + "sort", + "split", + "splitN", + "squote", + "startsWith", + "sub", + "ternary", + "title", + "toCSV", + "toJSON", + "toJSONPretty", + "toLower", + "toTOML", + "toUpper", + "toYAML", + "toml", + "trim", + "uniq", + "urlParse", + "urldecode", + "urlencode", + "values", + "xpath", + "yaml", + "yamlArray" + ], + "keywords": [ + "block", + "break", + "continue", + "define", + "else", + "end", + "if", + "nil", + "range", + "template", + "with" + ], + "namespaces": [ + "base64", + "coll", + "conv", + "crypto", + "data", + "filepath", + "k8s", + "math", + "net", + "path", + "random", + "regexp", + "strings", + "test", + "time", + "uuid" + ], + "tokenizer": { + "root": [ + [ + "^#\\s*gotemplate:.*$", + "comment.directive" + ], + [ + "\\{\\{-?\\/\\*", + { + "next": "@tmplComment", + "token": "comment" + } + ], + [ + "\\{\\{-?", + { + "next": "@tmplAction", + "token": "delimiter.template" + } + ], + [ + "[\\s\\S]", + "source" + ] + ], + "tmplComment": [ + [ + "\\*\\/-?\\}\\}", + { + "next": "@pop", + "token": "comment" + } + ], + [ + "[\\s\\S]", + "comment" + ] + ], + "tmplAction": [ + [ + "-?\\}\\}", + { + "next": "@pop", + "token": "delimiter.template" + } + ], + [ + "[ \\t\\r\\n]+", + "white" + ], + [ + "\"(?:[^\"\\\\]|\\\\.)*\"", + "string" + ], + [ + "`[^`]*`", + "string" + ], + [ + "'(?:[^'\\\\]|\\\\.)*'", + "string" + ], + [ + "[+-]?(?:0[xX][0-9a-fA-F]+|(?:\\d+\\.\\d*|\\.\\d+|\\d+)(?:[eE][+-]?\\d+)?)", + "number" + ], + [ + "\\$[A-Za-z_][A-Za-z0-9_]*", + "variable" + ], + [ + "\\$", + "variable" + ], + [ + "(\\.)([A-Za-z_][A-Za-z0-9_]*)", + [ + "delimiter", + "variable.field" + ] + ], + [ + "\\.", + "variable.field" + ], + [ + "([A-Za-z_][A-Za-z0-9_]*)(\\.)([A-Za-z_][A-Za-z0-9_]*)", + { + "cases": { + "$1@namespaces": [ + "namespace", + "delimiter", + "function" + ], + "@default": [ + "identifier", + "delimiter", + "identifier" + ] + } + } + ], + [ + "[A-Za-z_][A-Za-z0-9_]*", + { + "cases": { + "@keywords": "keyword", + "@builtins": "function.builtin", + "@functions": "function", + "@default": "identifier" + } + } + ], + [ + "[()\\[\\]]", + "@brackets" + ], + [ + "\\|", + "operator.pipe" + ], + [ + ":=|=", + "operator" + ], + [ + ",", + "delimiter" + ] + ] + } +} diff --git a/packages/expressions/src/lang/generated/yaml-gomplate.config.json b/packages/expressions/src/lang/generated/yaml-gomplate.config.json new file mode 100644 index 000000000..6f112c25c --- /dev/null +++ b/packages/expressions/src/lang/generated/yaml-gomplate.config.json @@ -0,0 +1,38 @@ +{ + "comments": { + "lineComment": "#", + "blockComment": [ + "", + "" + ] + }, + "brackets": [ + [ + "{", + "}" + ], + [ + "[", + "]" + ] + ], + "autoClosingPairs": [ + { + "open": "{", + "close": "}" + }, + { + "open": "[", + "close": "]" + }, + { + "open": "\"", + "close": "\"" + }, + { + "open": "'", + "close": "'" + } + ], + "wordPattern": "[A-Za-z_][A-Za-z0-9_]*(?:\\.[A-Za-z_][A-Za-z0-9_]*)*" +} diff --git a/packages/expressions/src/lang/generated/yaml-gomplate.monarch.json b/packages/expressions/src/lang/generated/yaml-gomplate.monarch.json new file mode 100644 index 000000000..79a7c9908 --- /dev/null +++ b/packages/expressions/src/lang/generated/yaml-gomplate.monarch.json @@ -0,0 +1,434 @@ +{ + "defaultToken": "", + "tokenPostfix": ".yaml-gomplate", + "brackets": [ + { + "open": "{", + "close": "}", + "token": "delimiter.curly" + }, + { + "open": "[", + "close": "]", + "token": "delimiter.square" + }, + { + "open": "(", + "close": ")", + "token": "delimiter.parenthesis" + } + ], + "builtins": [ + "and", + "call", + "eq", + "ge", + "gt", + "html", + "index", + "js", + "le", + "len", + "lt", + "ne", + "not", + "or", + "print", + "printf", + "println", + "slice", + "urlquery" + ], + "functions": [ + "add", + "append", + "assert", + "bool", + "coalesce", + "contains", + "csv", + "csvByColumn", + "csvByRow", + "default", + "dict", + "div", + "endsWith", + "fail", + "first", + "flatten", + "getHealth", + "getStatus", + "has", + "hasPrefix", + "hasSuffix", + "humanDuration", + "humanSize", + "in_business_hours", + "indent", + "isHealthy", + "isKind", + "isReady", + "jmespath", + "join", + "jq", + "json", + "jsonArray", + "jsonpath", + "keyValToMap", + "keys", + "kind", + "last", + "mapToKeyVal", + "matchLabel", + "merge", + "mul", + "neat", + "parseDateTime", + "pow", + "prepend", + "quote", + "rem", + "replaceAll", + "required", + "reverse", + "semver", + "semverCompare", + "seq", + "shellQuote", + "slice", + "sort", + "split", + "splitN", + "squote", + "startsWith", + "sub", + "ternary", + "title", + "toCSV", + "toJSON", + "toJSONPretty", + "toLower", + "toTOML", + "toUpper", + "toYAML", + "toml", + "trim", + "uniq", + "urlParse", + "urldecode", + "urlencode", + "values", + "xpath", + "yaml", + "yamlArray" + ], + "keywords": [ + "block", + "break", + "continue", + "define", + "else", + "end", + "if", + "nil", + "range", + "template", + "with" + ], + "namespaces": [ + "base64", + "coll", + "conv", + "crypto", + "data", + "filepath", + "k8s", + "math", + "net", + "path", + "random", + "regexp", + "strings", + "test", + "time", + "uuid" + ], + "tokenizer": { + "root": [ + [ + "^#\\s*gotemplate:.*$", + "comment.directive" + ], + [ + "\\{\\{-?\\/\\*", + { + "next": "@tmplComment", + "token": "comment" + } + ], + [ + "\\{\\{-?", + { + "next": "@tmplAction", + "token": "delimiter.template" + } + ], + [ + "#.*$", + "comment" + ], + [ + "^---\\s*$", + "keyword.directive" + ], + [ + "^\\.\\.\\.\\s*$", + "keyword.directive" + ], + [ + "^(\\s*)(-\\s+)([^-\\s#\"'][^:#]*?)(\\s*)(:)(?=\\s|$)", + [ + "white", + "delimiter.list", + "type.yaml", + "white", + "delimiter" + ] + ], + [ + "^(\\s*)([^-\\s#\"'][^:#]*?)(\\s*)(:)(?=\\s|$)", + [ + "white", + "type.yaml", + "white", + "delimiter" + ] + ], + [ + "^\\s*-\\s", + "delimiter.list" + ], + [ + "[\u0026*][A-Za-z0-9_-]+", + "variable.anchor" + ], + [ + "!!?[A-Za-z0-9_/-]*", + "type" + ], + [ + "[|\u003e][-+]?", + "keyword.scalar" + ], + [ + "\"", + { + "next": "@yamlDouble", + "token": "string" + } + ], + [ + "'", + { + "next": "@yamlSingle", + "token": "string" + } + ], + [ + "\\b(?:true|false|null|~|yes|no|on|off)\\b", + "keyword.constant" + ], + [ + "[+-]?(?:0[xX][0-9a-fA-F]+|(?:\\d+\\.\\d*|\\.\\d+|\\d+)(?:[eE][+-]?\\d+)?)\\b", + "number" + ], + [ + "[{}\\[\\]]", + "@brackets" + ], + [ + ",", + "delimiter" + ], + [ + "[\\s\\S]", + "" + ] + ], + "yamlDouble": [ + [ + "\\{\\{-?\\/\\*", + { + "next": "@tmplComment", + "token": "comment" + } + ], + [ + "\\{\\{-?", + { + "next": "@tmplAction", + "token": "delimiter.template" + } + ], + [ + "\"", + { + "next": "@pop", + "token": "string" + } + ], + [ + "\\\\.", + "string.escape" + ], + [ + "[^\"\\\\{]+", + "string" + ], + [ + "[\\s\\S]", + "string" + ] + ], + "yamlSingle": [ + [ + "\\{\\{-?\\/\\*", + { + "next": "@tmplComment", + "token": "comment" + } + ], + [ + "\\{\\{-?", + { + "next": "@tmplAction", + "token": "delimiter.template" + } + ], + [ + "'", + { + "next": "@pop", + "token": "string" + } + ], + [ + "\\\\.", + "string.escape" + ], + [ + "[^'\\\\{]+", + "string" + ], + [ + "[\\s\\S]", + "string" + ] + ], + "tmplComment": [ + [ + "\\*\\/-?\\}\\}", + { + "next": "@pop", + "token": "comment" + } + ], + [ + "[\\s\\S]", + "comment" + ] + ], + "tmplAction": [ + [ + "-?\\}\\}", + { + "next": "@pop", + "token": "delimiter.template" + } + ], + [ + "[ \\t\\r\\n]+", + "white" + ], + [ + "\"(?:[^\"\\\\]|\\\\.)*\"", + "string" + ], + [ + "`[^`]*`", + "string" + ], + [ + "'(?:[^'\\\\]|\\\\.)*'", + "string" + ], + [ + "[+-]?(?:0[xX][0-9a-fA-F]+|(?:\\d+\\.\\d*|\\.\\d+|\\d+)(?:[eE][+-]?\\d+)?)", + "number" + ], + [ + "\\$[A-Za-z_][A-Za-z0-9_]*", + "variable" + ], + [ + "\\$", + "variable" + ], + [ + "(\\.)([A-Za-z_][A-Za-z0-9_]*)", + [ + "delimiter", + "variable.field" + ] + ], + [ + "\\.", + "variable.field" + ], + [ + "([A-Za-z_][A-Za-z0-9_]*)(\\.)([A-Za-z_][A-Za-z0-9_]*)", + { + "cases": { + "$1@namespaces": [ + "namespace", + "delimiter", + "function" + ], + "@default": [ + "identifier", + "delimiter", + "identifier" + ] + } + } + ], + [ + "[A-Za-z_][A-Za-z0-9_]*", + { + "cases": { + "@keywords": "keyword", + "@builtins": "function.builtin", + "@functions": "function", + "@default": "identifier" + } + } + ], + [ + "[()\\[\\]]", + "@brackets" + ], + [ + "\\|", + "operator.pipe" + ], + [ + ":=|=", + "operator" + ], + [ + ",", + "delimiter" + ] + ] + } +} diff --git a/packages/expressions/src/lang/hover.ts b/packages/expressions/src/lang/hover.ts new file mode 100644 index 000000000..749975f0b --- /dev/null +++ b/packages/expressions/src/lang/hover.ts @@ -0,0 +1,140 @@ +import type { GomplateSpec, Monaco, SpecFunction, SpecMacro } from "./types"; + +/** + * Indexes are built per registration, not once per module. + * + * A host's catalogue arrives from its `/api/spec` after the editor has already + * mounted, so anything computed at module scope is frozen before the host can + * speak. `setSpec` re-registers with a fresh index instead. + */ +export function registerCelHover(monaco: Monaco, languageId: string, spec: GomplateSpec) { + return monaco.languages.registerHoverProvider(languageId, celHoverProvider(spec)); +} + +export function registerGoTemplateHover(monaco: Monaco, languageId: string, spec: GomplateSpec) { + return monaco.languages.registerHoverProvider(languageId, goTemplateHoverProvider(spec)); +} + +/** + * The providers the register functions install, exposed so a test can drive + * them over a real model rather than through Monaco's registry, which offers no + * way to enumerate what is registered. + */ +export function celHoverProvider(spec: GomplateSpec) { + const functions = new Map(spec.cel.functions.map((f) => [f.name, f])); + const macrosByName = new Map(); + for (const macro of spec.cel.macros) { + macrosByName.set(macro.name, [...(macrosByName.get(macro.name) ?? []), macro]); + } + + return { + provideHover(model: Model, position: Position) { + const word = dottedWordAt(model, position); + if (!word) return null; + + const fn = functions.get(word.text) ?? functions.get(word.leaf); + if (fn) return { range: word.range, contents: [{ value: functionDocumentation(fn) }] }; + + const macros = macrosByName.get(word.leaf); + if (macros) { + return { + range: word.range, + contents: macros.map((macro) => ({ value: macroDocumentation(macro) })), + }; + } + return null; + }, + }; +} + +export function goTemplateHoverProvider(spec: GomplateSpec) { + const functions = new Map( + spec.gotemplate.functions.map((f) => [f.name, f]), + ); + + return { + provideHover(model: Model, position: Position) { + const word = dottedWordAt(model, position); + const fn = word && functions.get(word.text); + if (!fn || !word) return null; + return { range: word.range, contents: [{ value: functionDocumentation(fn) }] }; + }, + }; +} + +/** Renders a function as markdown: signature, overloads, docs, examples. */ +export function functionDocumentation(fn: SpecFunction): string { + const lines: string[] = []; + lines.push("```", fn.signature ? `${fn.name}${fn.signature}` : fn.name, "```"); + + if (fn.memberOnly) { + lines.push("", "_Callable only in member position: `value." + leafOf(fn.name) + "(...)`._"); + } + if (fn.doc) lines.push("", fn.doc); + + if (fn.overloads?.length) { + lines.push("", "**Overloads**", ""); + for (const overload of fn.overloads) { + const receiver = overload.member && overload.args.length > 0 ? `${overload.args[0]}.` : ""; + const args = overload.member ? overload.args.slice(1) : overload.args; + lines.push(`- \`${receiver}${leafOf(fn.name)}(${args.join(", ")}) -> ${overload.result}\``); + } + } + if (fn.examples?.length) { + lines.push("", "**Examples**", "", "```cel", ...fn.examples, "```"); + } + return lines.join("\n"); +} + +/** Renders a macro as markdown. */ +export function macroDocumentation(macro: SpecMacro): string { + const shape = macro.receiverStyle ? `value.${macro.name}(...)` : `${macro.name}(...)`; + const arity = macro.argCount === 0 ? "variadic" : `${macro.argCount} arguments`; + const lines = ["```", shape, "```", "", `_Macro, ${arity}. Expanded at parse time._`]; + if (macro.doc) lines.push("", macro.doc); + if (macro.examples?.length) lines.push("", "```cel", ...macro.examples, "```"); + return lines.join("\n"); +} + +function leafOf(name: string) { + const dot = name.lastIndexOf("."); + return dot < 0 ? name : name.slice(dot + 1); +} + +interface Model { + getLineContent(line: number): string; +} + +interface Position { + lineNumber: number; + column: number; +} + +/** + * Monaco's own word lookup stops at a dot, so `k8s.isHealthy` would be read as + * `isHealthy`. The dotted name is what the spec is keyed by, so widen it here. + */ +function dottedWordAt(model: Model, position: Position) { + const line = model.getLineContent(position.lineNumber); + const isWord = (c: string) => /[A-Za-z0-9_.]/.test(c); + + let start = position.column - 1; + while (start > 0 && isWord(line[start - 1]!)) start--; + let end = position.column - 1; + while (end < line.length && isWord(line[end]!)) end++; + if (start === end) return null; + + const text = line.slice(start, end).replace(/^\.+|\.+$/g, ""); + if (!text) return null; + + return { + text, + leaf: leafOf(text), + range: { + startLineNumber: position.lineNumber, + endLineNumber: position.lineNumber, + startColumn: start + 1, + endColumn: end + 1, + }, + }; +} diff --git a/packages/expressions/src/lang/index.ts b/packages/expressions/src/lang/index.ts new file mode 100644 index 000000000..687c6c15e --- /dev/null +++ b/packages/expressions/src/lang/index.ts @@ -0,0 +1,170 @@ +import { LANGUAGE_IDS, definitions, spec } from "./generated"; +import type { LanguageId } from "./generated"; +import { registerCompletion } from "./completion"; +import type { EnvironmentSource } from "./completion"; +import { registerCelHover, registerGoTemplateHover } from "./hover"; +import { pathFlavour } from "./environment"; +import { attributesFor } from "./attributes"; +import { mergeSpec } from "./merge"; +import { defineThemes } from "./theme"; +import type { GomplateSpec, LanguageDefinition, Monaco } from "./types"; + +export { spec, LANGUAGE_IDS, definitions }; +export type { LanguageId }; +export * from "./types"; +export { GOMPLATE_DARK_THEME, GOMPLATE_LIGHT_THEME } from "./theme"; +export { + childEntries, + isIdentifier, + kindOf, + pathExpression, + pathFlavour, + resolvePath, + summarize, +} from "./environment"; +export type { EnvironmentEntry, PathSegment, ValueKind } from "./environment"; +export { environmentPrefixAt } from "./prefix"; +export type { EnvironmentPrefix } from "./prefix"; +export type { EnvironmentSource } from "./completion"; +export { mergeSpec } from "./merge"; +export { attributesFor, celAttributes, goTemplateAttributes } from "./attributes"; +export type { Attributes } from "./attributes"; + +export interface RegisterOptions { + /** Which languages to register. Defaults to all of them. */ + languages?: readonly LanguageId[]; + /** Register completion providers. Defaults to true. */ + completions?: boolean; + /** Register hover providers. Defaults to true. */ + hovers?: boolean; + /** Define the gomplate colour themes. Defaults to true. */ + themes?: boolean; + /** + * The document expressions are evaluated against, so completion can offer the + * key paths it actually contains. + * + * A getter rather than a value: registration happens once, before the first + * editor mounts, while the document keeps being edited afterwards. It is + * called on every completion request. + */ + environment?: EnvironmentSource; + /** + * A host's own catalogue, merged over gomplate's. + * + * Usually left unset here and supplied later through `setSpec`, because it + * arrives from the host's `GET /api/spec` after the editor has mounted. + */ + spec?: GomplateSpec; +} + +/** What `registerGomplateLanguages` hands back. */ +export interface RegisteredLanguages { + /** + * Replaces the catalogue and re-applies it. + * + * Registration has to happen in `beforeMount`, before the first model exists, + * while a host's spec arrives over the network afterwards — so gating + * registration on the fetch would stall the editor. Register with the baked + * catalogue instead and call this when the response lands: the tokenizers are + * re-applied with the merged word lists, and completion and hover are + * re-registered against the merged functions. + */ + setSpec(spec: GomplateSpec | undefined): void; + dispose(): void; +} + +/** + * Registers gomplate's languages with a Monaco instance. + * + * Safe to call more than once: a language already registered is left alone, so + * a component tree with several editors does not need to coordinate. The + * returned handle removes only the providers this call added. + */ +export function registerGomplateLanguages( + monaco: Monaco, + options: RegisterOptions = {}, +): RegisteredLanguages { + const { languages = LANGUAGE_IDS, completions = true, hovers = true, themes = true } = options; + + if (themes) defineThemes(monaco); + + const known = new Set(monaco.languages.getLanguages().map((l) => l.id)); + const selected: LanguageId[] = []; + + for (const id of languages) { + const definition: LanguageDefinition | undefined = definitions[id]; + if (!definition) { + throw new Error( + `unknown gomplate language "${id}"; expected one of ${LANGUAGE_IDS.join(", ")}`, + ); + } + selected.push(id); + + // Only the registration itself is once-only. The tokenizer and the + // providers are re-applied below for every selected language, whether or + // not this call is the one that introduced it -- otherwise a second editor + // would get a handle whose setSpec silently does nothing. + if (known.has(id)) continue; + monaco.languages.register({ id }); + monaco.languages.setLanguageConfiguration(id, definition.configuration); + } + + const applySpec = (merged: GomplateSpec) => { + for (const id of selected) { + const definition = definitions[id]!; + const attributes = attributesFor(id, merged); + // Spread over the generated definition rather than replacing it: the + // tokenizer rules and the grammar-derived lists (CEL's `operators`) are + // not the spec's to change. + monaco.languages.setMonarchTokensProvider( + id, + attributes ? { ...definition.monarch, ...attributes } : definition.monarch, + ); + + const flavour = pathFlavour(id); + const installed: { dispose(): void }[] = []; + if (completions) { + installed.push( + registerCompletion(monaco, id, { spec: merged, environment: options.environment }), + ); + } + if (hovers && flavour === "cel") installed.push(registerCelHover(monaco, id, merged)); + if (hovers && flavour === "gotemplate") { + installed.push(registerGoTemplateHover(monaco, id, merged)); + } + replaceProviders(monaco, id, installed); + } + }; + + applySpec(mergeSpec(spec, options.spec)); + + return { + setSpec(next) { + applySpec(mergeSpec(spec, next)); + }, + dispose() { + for (const id of selected) replaceProviders(monaco, id, []); + }, + }; +} + +/** + * One set of completion and hover providers per language, per Monaco. + * + * Monaco stacks providers rather than replacing them, so registering twice for + * a language shows every suggestion twice. Tracking them here means the latest + * registration wins instead, and a component tree with several editors does not + * have to coordinate. + */ +const providersByLanguage = new WeakMap>(); + +function replaceProviders(monaco: Monaco, id: string, installed: { dispose(): void }[]) { + let byLanguage = providersByLanguage.get(monaco); + if (!byLanguage) { + byLanguage = new Map(); + providersByLanguage.set(monaco, byLanguage); + } + for (const disposable of byLanguage.get(id) ?? []) disposable.dispose(); + if (installed.length === 0) byLanguage.delete(id); + else byLanguage.set(id, installed); +} diff --git a/packages/expressions/src/lang/merge.ts b/packages/expressions/src/lang/merge.ts new file mode 100644 index 000000000..44fc4e24d --- /dev/null +++ b/packages/expressions/src/lang/merge.ts @@ -0,0 +1,69 @@ +import type { CelSpec, GoTemplateSpec, GomplateSpec, SpecFunction, SpecMacro } from "./types"; + +/** + * Folds a host's catalogue into the one this package ships. + * + * A host binary registers functions on top of gomplate's — mission-control's + * `catalog.query`, `gitops.source` — and serves the result from `/api/spec`. + * That response already *contains* gomplate's own functions, so merging is + * mostly a union; the interesting case is a host that overrides a name, where + * the host wins because its binary is what will actually evaluate. + */ +export function mergeSpec(base: GomplateSpec, incoming: GomplateSpec | undefined): GomplateSpec { + if (!incoming) return base; + return { + cel: mergeCel(base.cel, incoming.cel), + gotemplate: mergeGoTemplate(base.gotemplate, incoming.gotemplate), + }; +} + +function mergeCel(base: CelSpec, incoming: CelSpec): CelSpec { + return { + namespaces: union(base.namespaces, incoming.namespaces), + keywords: union(base.keywords, incoming.keywords), + types: union(base.types, incoming.types), + variables: union(base.variables ?? [], incoming.variables ?? []), + // Keyed by arity as well as name: `map` is registered twice, at 2 and 3 + // arguments, and folding those together would drop an overload the hover + // list already renders separately. + macros: keyed( + base.macros, + incoming.macros, + (macro) => `${macro.name}/${macro.argCount}/${macro.receiverStyle}`, + ), + functions: byName(base.functions, incoming.functions), + }; +} + +function mergeGoTemplate(base: GoTemplateSpec, incoming: GoTemplateSpec): GoTemplateSpec { + return { + namespaces: union(base.namespaces, incoming.namespaces), + keywords: union(base.keywords, incoming.keywords), + builtins: union(base.builtins, incoming.builtins), + // Delimiters are a property of the binary's parser, not a list to merge: + // a host that has changed them means it, and half of a pair would be worse + // than either. + delimiters: incoming.delimiters ?? base.delimiters, + functions: byName(base.functions, incoming.functions), + }; +} + +function union(base: readonly string[], incoming: readonly string[]): string[] { + return [...new Set([...base, ...incoming])].sort(); +} + +/** Keyed by name, incoming wins, order stable and alphabetical. */ +function byName(base: readonly T[], incoming: readonly T[]): T[] { + return keyed(base, incoming, (item) => item.name); +} + +function keyed( + base: readonly T[], + incoming: readonly T[], + key: (item: T) => string, +): T[] { + const merged = new Map(); + for (const item of base) merged.set(key(item), item); + for (const item of incoming) merged.set(key(item), item); + return [...merged.values()].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); +} diff --git a/packages/expressions/src/lang/prefix.ts b/packages/expressions/src/lang/prefix.ts new file mode 100644 index 000000000..d78276c91 --- /dev/null +++ b/packages/expressions/src/lang/prefix.ts @@ -0,0 +1,173 @@ +import { spec } from "./generated"; +import { pathFlavour } from "./environment"; +import type { PathSegment } from "./environment"; + +/** The subset of a Monaco model this module reads. */ +export interface PrefixModel { + getLineContent(line: number): string; +} + +export interface PrefixPosition { + lineNumber: number; + column: number; +} + +/** A path expression under construction at the cursor. */ +export interface EnvironmentPrefix { + /** The segments already committed, left of the trailing dot. */ + segments: PathSegment[]; + /** The partially typed leaf, empty right after a dot. */ + leaf: string; + /** + * 1-based columns spanning the whole expression typed so far, root marker + * included. A completion replaces this range with a freshly rendered path, so + * the inserted text is always well formed rather than glued onto what is + * already there. + */ + startColumn: number; + endColumn: number; + /** The text in that range, which Monaco filters candidates against. */ + typed: string; +} + +const IDENT = /[A-Za-z0-9_]/; +const SUBSCRIPT = /^(?:(\d+)|"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)')$/; + +/** + * Reads the path expression the cursor sits in, or null when it sits somewhere + * the document's keys have no meaning. + * + * `dottedWordAt` in `hover.ts` cannot serve here: it scans past the cursor and + * strips the trailing dot, and the trailing dot is precisely the signal that a + * child is being completed. + */ +export function environmentPrefixAt( + model: PrefixModel, + position: PrefixPosition, + languageId: string, +): EnvironmentPrefix | null { + const flavour = pathFlavour(languageId); + if (!flavour) return null; + + const line = model.getLineContent(position.lineNumber); + const before = line.slice(0, position.column - 1); + + if (flavour === "gotemplate" && !insideAction(before)) return null; + + let leafStart = before.length; + while (leafStart > 0 && IDENT.test(before[leafStart - 1]!)) leafStart--; + const leaf = before.slice(leafStart); + const head = before.slice(0, leafStart); + + const chain = parseChain(head); + if (!chain) return null; + // Without a dot the leaf can only be a root name, so anything that looks like + // the tail of a path before it (`items[0]name`) is malformed, not a prefix. + if (leaf !== "" && !chain.dotted && chain.segments.length > 0) return null; + + // `chain.start` already sits on the leading `.` when there is one, so a go + // template needs no adjustment; JSONPath's `$` sits one place further left. + let start = chain.start; + if (flavour === "gotemplate" && !chain.rooted) return null; + if (flavour === "jsonpath") { + const marker = head[chain.start - 1]; + if (marker !== "$" && marker !== "@") return null; + start -= 1; + } + if (flavour === "cel" && chain.rooted) return null; // a leading `.` is not CEL + + return { + segments: chain.segments, + leaf, + startColumn: start + 1, + endColumn: position.column, + typed: before.slice(start), + }; +} + +interface Chain { + segments: PathSegment[]; + /** Index in `head` where the segment list starts, root marker excluded. */ + start: number; + /** Whether `head` ends with the dot that opens a new segment. */ + dotted: boolean; + /** Whether the outermost segment is preceded by a `.`, as go templates need. */ + rooted: boolean; +} + +/** + * Parses the trailing path of `head` backwards. + * + * Backwards because a path has no left boundary of its own: it ends where the + * expression around it begins, and that is only knowable by walking off it. + */ +function parseChain(head: string): Chain | null { + let pos = head.length; + const dotted = pos > 0 && head[pos - 1] === "."; + if (dotted) pos--; + + const reversed: PathSegment[] = []; + let rooted = false; + + for (;;) { + if (pos > 0 && head[pos - 1] === "]") { + const open = head.lastIndexOf("[", pos - 2); + if (open < 0) return null; + const segment = parseSubscript(head.slice(open + 1, pos - 1)); + if (segment === null) return null; + reversed.push(segment); + pos = open; + rooted = false; + continue; + } + if (pos > 0 && IDENT.test(head[pos - 1]!)) { + let start = pos; + while (start > 0 && IDENT.test(head[start - 1]!)) start--; + reversed.push(head.slice(start, pos)); + pos = start; + rooted = pos > 0 && head[pos - 1] === "."; + if (rooted) { + pos--; + continue; + } + break; + } + break; + } + + // Only the trailing dot was consumed, so that dot is itself the root marker. + if (reversed.length === 0 && dotted) rooted = true; + + return { segments: reversed.reverse(), start: pos, dotted, rooted }; +} + +function parseSubscript(inner: string): PathSegment | null { + const match = SUBSCRIPT.exec(inner.trim()); + if (!match) return null; + if (match[1] !== undefined) return Number(match[1]); + const quoted = match[2] ?? match[3]!; + try { + return JSON.parse(`"${quoted.replace(/\\'/g, "'")}"`) as string; + } catch { + return null; + } +} + +/** + * Whether the cursor sits between template delimiters. Outside them the text is + * literal output, where a key path means nothing. + */ +function insideAction(before: string): boolean { + const { left, right, leftComment, rightComment } = spec.gotemplate.delimiters; + + const open = before.lastIndexOf(left); + if (open < 0) return false; + if (before.lastIndexOf(right) > open) return false; + + // `{{/*` opens with the ordinary left delimiter, so an unterminated comment + // reads as an open action unless it is checked for separately. + const comment = before.lastIndexOf(leftComment); + if (comment >= open && before.lastIndexOf(rightComment) < comment) return false; + + return true; +} diff --git a/packages/expressions/src/lang/spec.ts b/packages/expressions/src/lang/spec.ts new file mode 100644 index 000000000..2d8ae5052 --- /dev/null +++ b/packages/expressions/src/lang/spec.ts @@ -0,0 +1,25 @@ +import { spec } from "./generated"; +import type { GomplateSpec, SpecFunction, SpecMacro } from "./types"; + +export { spec }; +export type { GomplateSpec, SpecFunction, SpecMacro }; + +/** Looks up a CEL function by its fully qualified name. */ +export function celFunction(name: string): SpecFunction | undefined { + return spec.cel.functions.find((fn) => fn.name === name); +} + +/** Looks up a go-template function by its fully qualified name. */ +export function goTemplateFunction(name: string): SpecFunction | undefined { + return spec.gotemplate.functions.find((fn) => fn.name === name); +} + +/** Every CEL function in a namespace, e.g. all of `k8s.*`. */ +export function celNamespace(namespace: string): SpecFunction[] { + return spec.cel.functions.filter((fn) => fn.namespace === namespace); +} + +/** Every go-template function in a namespace. */ +export function goTemplateNamespace(namespace: string): SpecFunction[] { + return spec.gotemplate.functions.filter((fn) => fn.namespace === namespace); +} diff --git a/packages/expressions/src/lang/theme.ts b/packages/expressions/src/lang/theme.ts new file mode 100644 index 000000000..a10f4beba --- /dev/null +++ b/packages/expressions/src/lang/theme.ts @@ -0,0 +1,109 @@ +import type { Monaco } from "./types"; + +export const GOMPLATE_LIGHT_THEME = "gomplate-light"; +export const GOMPLATE_DARK_THEME = "gomplate-dark"; + +/** + * Token rules shared by both themes. Only the colours differ, so the token + * vocabulary stays in one place. + * + * The token names are the ones the generated tokenizers emit; anything not + * listed falls back to the base theme, which is why the themes inherit from + * `vs` and `vs-dark` rather than starting from nothing. + */ +const TOKENS = [ + "namespace", + "function", + "function.member", + "function.builtin", + "keyword.macro", + "keyword.constant", + "keyword.directive", + "operator.optional", + "operator.pipe", + "delimiter.template", + "variable", + "variable.field", + "variable.anchor", + "variable.root", + "variable.current", + "identifier.escaped", + "string.bytes", + "number.uint", + "number.float", + "type.yaml", + "type.json", + "comment.directive", +] as const; + +type Palette = Record<(typeof TOKENS)[number], string>; + +const LIGHT: Palette = { + namespace: "267F99", + function: "795E26", + "function.member": "795E26", + "function.builtin": "0000FF", + "keyword.macro": "AF00DB", + "keyword.constant": "0000FF", + "keyword.directive": "AF00DB", + "operator.optional": "AF00DB", + "operator.pipe": "AF00DB", + "delimiter.template": "AF00DB", + variable: "001080", + "variable.field": "001080", + "variable.anchor": "267F99", + "variable.root": "AF00DB", + "variable.current": "AF00DB", + "identifier.escaped": "001080", + "string.bytes": "A31515", + "number.uint": "098658", + "number.float": "098658", + "type.yaml": "0451A5", + "type.json": "0451A5", + "comment.directive": "008000", +}; + +const DARK: Palette = { + namespace: "4EC9B0", + function: "DCDCAA", + "function.member": "DCDCAA", + "function.builtin": "569CD6", + "keyword.macro": "C586C0", + "keyword.constant": "569CD6", + "keyword.directive": "C586C0", + "operator.optional": "C586C0", + "operator.pipe": "C586C0", + "delimiter.template": "C586C0", + variable: "9CDCFE", + "variable.field": "9CDCFE", + "variable.anchor": "4EC9B0", + "variable.root": "C586C0", + "variable.current": "C586C0", + "identifier.escaped": "9CDCFE", + "string.bytes": "CE9178", + "number.uint": "B5CEA8", + "number.float": "B5CEA8", + "type.yaml": "9CDCFE", + "type.json": "9CDCFE", + "comment.directive": "6A9955", +}; + +/** Defines the gomplate themes. Idempotent -- Monaco overwrites by name. */ +export function defineThemes(monaco: Monaco) { + monaco.editor.defineTheme(GOMPLATE_LIGHT_THEME, { + base: "vs", + inherit: true, + rules: rulesFor(LIGHT), + colors: {}, + }); + monaco.editor.defineTheme(GOMPLATE_DARK_THEME, { + base: "vs-dark", + inherit: true, + rules: rulesFor(DARK), + colors: {}, + }); +} + +function rulesFor(palette: Palette) { + return TOKENS.map((token) => ({ token, foreground: palette[token] })); +} diff --git a/packages/expressions/src/lang/types.ts b/packages/expressions/src/lang/types.ts new file mode 100644 index 000000000..4392998ed --- /dev/null +++ b/packages/expressions/src/lang/types.ts @@ -0,0 +1,90 @@ +import type * as monaco from "monaco-editor"; + +/** The subset of the Monaco namespace this package needs. */ +export type Monaco = typeof monaco; + +/** One generated language: its tokenizer and its editor configuration. */ +export interface LanguageDefinition { + id: string; + monarch: monaco.languages.IMonarchLanguage; + configuration: monaco.languages.LanguageConfiguration; +} + +/** One typed signature of a CEL function. */ +export interface Overload { + id: string; + args: string[]; + result: string; + member?: boolean; +} + +/** A callable name, with every registered overload. */ +export interface SpecFunction { + name: string; + namespace?: string; + /** Callable only as `x.f()`, never as `f(x)`. */ + memberOnly?: boolean; + doc?: string; + /** Go signature; go-template functions only. */ + signature?: string; + overloads?: Overload[]; + examples?: string[]; +} + +/** A CEL macro. Expanded at parse time, so never a function. */ +export interface SpecMacro { + name: string; + argCount: number; + receiverStyle: boolean; + doc?: string; + examples?: string[]; +} + +export interface CelSpec { + namespaces: string[]; + keywords: string[]; + types: string[]; + variables?: string[]; + macros: SpecMacro[]; + functions: SpecFunction[]; +} + +export interface Delimiters { + left: string; + right: string; + leftComment: string; + rightComment: string; + trimMarker: string; +} + +export interface GoTemplateSpec { + namespaces: string[]; + keywords: string[]; + builtins: string[]; + delimiters: Delimiters; + functions: SpecFunction[]; +} + +/** The full catalogue, generated from gomplate's own registries. */ +export interface GomplateSpec { + cel: CelSpec; + gotemplate: GoTemplateSpec; +} + +/** + * One snippet plus the token boundaries the language's real lexer produces. + * + * Generated by running cel-go's own ANTLR lexer over the snippet, so the + * tokenizer is checked against the parser gomplate evaluates with rather than + * against a snapshot of its own output. Snippets for languages whose lexer is + * not reachable carry no boundaries; they are validated by their real parser at + * generation time and only smoke-tested here. + */ +export interface ConformanceCase { + language: string; + source: string; + /** 0-based offsets where a token starts, whitespace excluded. */ + boundaries?: number[]; + /** Where the snippet came from, so a failure is traceable. */ + origin: string; +} diff --git a/packages/expressions/src/playground.ts b/packages/expressions/src/playground.ts new file mode 100644 index 000000000..4927e16b0 --- /dev/null +++ b/packages/expressions/src/playground.ts @@ -0,0 +1,23 @@ +/** + * The playground UI over the language support in the root entry. + * + * A separate entry because it pulls in React, clicky-ui and Monaco's editor, + * while a host that only wants highlighting and completion in its own editor + * needs none of that. + */ +export { ExpressionPlayground } from "./playground/ExpressionPlayground.tsx"; +export type { + ExpressionPlaygroundProps, + PlaygroundState, +} from "./playground/ExpressionPlayground.tsx"; + +export { LANGUAGES, SECTIONS, languageById } from "./playground/languages.ts"; +export type { EvalLanguage, PlaygroundLanguage } from "./playground/languages.ts"; + +export { DEFAULT_API_BASE, evaluate, fetchExamples, fetchSpec } from "./playground/api.ts"; +export type { EvalError, EvalRequest, EvalResponse, Example } from "./playground/api.ts"; + +export { useEvaluator } from "./playground/useEvaluator.ts"; +export type { Evaluator } from "./playground/useEvaluator.ts"; +export { useParsedInput } from "./playground/useParsedInput.ts"; +export type { ParsedInput } from "./playground/useParsedInput.ts"; diff --git a/packages/expressions/src/playground/ExpressionPlayground.tsx b/packages/expressions/src/playground/ExpressionPlayground.tsx new file mode 100644 index 000000000..79a894cf6 --- /dev/null +++ b/packages/expressions/src/playground/ExpressionPlayground.tsx @@ -0,0 +1,360 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Combobox, Tabs } from "@flanksource/clicky-ui"; +import { MonacoEditor } from "@flanksource/clicky-ui/monaco"; +import type { Monaco } from "@flanksource/clicky-ui/monaco"; +import * as monacoEditor from "monaco-editor"; + +import { mergeSpec, registerGomplateLanguages, spec as packagedSpec } from "../lang/index.ts"; +import type { GomplateSpec, RegisteredLanguages } from "../lang/index.ts"; +import { DEFAULT_API_BASE, fetchExamples, fetchSpec } from "./api.ts"; +import type { EvalResponse, Example } from "./api.ts"; +import { LANGUAGES, languageById } from "./languages.ts"; +import type { PlaygroundLanguage } from "./languages.ts"; +import { GraphPanel } from "./panels/GraphPanel.tsx"; +import { ResultPanel } from "./panels/ResultPanel.tsx"; +import { SpecPanel } from "./panels/SpecPanel.tsx"; +import { TokensPanel } from "./panels/TokensPanel.tsx"; +import { RunControls } from "./RunControls.tsx"; +import { registerRunAction } from "./runAction.ts"; +import { useEditorTheme } from "./useEditorTheme.ts"; +import { useEvaluator } from "./useEvaluator.ts"; +import { useParsedInput } from "./useParsedInput.ts"; +import { VerticalSplit, rowsToPaneHeight } from "./VerticalSplit.tsx"; + +/** What the author is editing. Hosts persist this wherever suits them. */ +export interface PlaygroundState { + language: string; + source: string; + input: string; +} + +export interface ExpressionPlaygroundProps { + /** + * Where the host mounted the playground API. Defaults to `/api`; a host + * serving it behind a prefix passes that instead. + */ + apiBase?: string; + /** Languages offered. Defaults to everything gomplate evaluates. */ + languages?: PlaygroundLanguage[]; + /** + * Samples to offer. Fetched from the server when omitted, so a host that + * configured `Options.Examples` needs no prop at all. + */ + examples?: Example[]; + /** Controlled state. Pair with `onChange`. */ + value?: PlaygroundState; + onChange?: (state: PlaygroundState) => void; + /** Starting state when uncontrolled. */ + defaultValue?: PlaygroundState; + className?: string; +} + +const SOURCE_MODEL_PATH = "inmemory://expressions/source"; +const INPUT_MODEL_PATH = "inmemory://expressions/input.yaml"; + +const EMPTY_STATE: PlaygroundState = { language: "cel", source: "", input: "" }; + +/** + * An editor for one expression, the document it runs against, and its result. + * + * Deliberately shell-less: it renders the editors and the output panels and + * nothing around them, so a host frames it with its own navigation. gomplate's + * playground wraps it in an AppShell with a language rail; an app embedding it + * in a drawer wraps it in nothing at all. + * + * Highlighting, completion and hover come from the catalogue the *server* + * reports, so a host's own functions are first-class here without this + * component knowing anything about them. + */ +export function ExpressionPlayground({ + apiBase = DEFAULT_API_BASE, + languages: offered = LANGUAGES, + examples, + value, + onChange, + defaultValue, + className, +}: ExpressionPlaygroundProps) { + const [uncontrolled, setUncontrolled] = useState( + () => defaultValue ?? { ...EMPTY_STATE, language: offered[0]?.id ?? "cel" }, + ); + const state = value ?? uncontrolled; + + const update = useCallback( + (patch: Partial) => { + const next = { ...state, ...patch }; + if (!value) setUncontrolled(next); + onChange?.(next); + }, + [state, value, onChange], + ); + + const language = useMemo( + () => languageById(state.language, offered), + [state.language, offered], + ); + const [outputTab, setOutputTab] = useState("result"); + + const evaluator = useEvaluator(apiBase, { + language: language.evalLanguage, + source: state.source, + input: state.input, + }); + + // Completion reads the document through a ref: languages are registered once, + // before the first editor mounts, while the input keeps being edited after. + const parsedInput = useParsedInput(state.input); + const environmentRef = useRef(undefined); + environmentRef.current = parsedInput.value; + + const [served, setServed] = useState(); + const servedRef = useRef(undefined); + servedRef.current = served; + const activeSpec = useMemo(() => mergeSpec(packagedSpec, served), [served]); + + const [fetched, setFetched] = useState(); + const available = examples ?? fetched ?? []; + const forLanguage = available.filter( + (example) => example.language === language.evalLanguage, + ); + + // Registration must happen before the first model is created, or Monaco + // resolves the language id to plaintext and never revisits it. Which of the + // two lands first is a race -- Monaco loads slowly, the fetch returns fast -- + // so both paths apply the catalogue. + const registered = useRef(null); + const registerLanguages = useCallback((monaco: Monaco) => { + const current = servedRef.current; + registered.current = registerGomplateLanguages(monaco, { + environment: () => environmentRef.current, + ...(current ? { spec: current } : {}), + }); + }, []); + + useEffect(() => { + const controller = new AbortController(); + void fetchSpec(apiBase, controller.signal).then((next) => { + if (!controller.signal.aborted) setServed(next); + }); + return () => controller.abort(); + }, [apiBase]); + + useEffect(() => { + if (served) registered.current?.setSpec(served); + }, [served]); + + useEffect(() => { + if (examples) return; + const controller = new AbortController(); + void fetchExamples(apiBase, controller.signal).then((next) => { + if (!controller.signal.aborted && next) setFetched(next); + }); + return () => controller.abort(); + }, [apiBase, examples]); + + useMarkers(evaluator.response); + const applyTheme = useEditorTheme(); + + useEffect(() => { + if (outputTab === "spec" && !language.catalogue) setOutputTab("result"); + }, [language.catalogue, outputTab]); + + // The Monaco action is registered once per editor, so it has to reach the + // current `run` through a ref rather than capturing it. + const runRef = useRef(evaluator.run); + runRef.current = evaluator.run; + const sourceEditor = useRef[0] | null>(null); + const onEditorMount = useCallback( + (editor: Parameters[0], monaco: Monaco) => { + applyTheme(); + registerRunAction(editor, monaco, () => runRef.current()); + if (editor.getModel()?.uri.toString() === SOURCE_MODEL_PATH) sourceEditor.current = editor; + }, + [applyTheme], + ); + + /** Writes a path from the object graph where the author last left the caret. */ + const insertExpression = useCallback((expression: string) => { + const editor = sourceEditor.current; + const selection = editor?.getSelection(); + if (!editor || !selection) return; + editor.executeEdits("object-graph", [ + { range: selection, text: expression, forceMoveMarkers: true }, + ]); + editor.focus(); + }, []); + + const catalogue = language.catalogue ? activeSpec[language.catalogue].functions : []; + + return ( +
+
+ } + hint={ + forLanguage.length > 0 ? ( + ({ + value: example.name, + label: example.name, + }))} + value="" + allowCustomValue={false} + placeholder="Load an example…" + ariaLabel="Load an example" + className="w-56" + onChange={(name) => { + const example = forLanguage.find((candidate) => candidate.name === name); + if (example) update({ source: example.source, input: example.input }); + }} + /> + ) : undefined + } + > + update({ source: source ?? "" })} + language={language.editorLanguage} + path={SOURCE_MODEL_PATH} + height="100%" + beforeMount={registerLanguages} + onMount={onEditorMount} + /> + + } + bottom={ + + {parsedInput.error ?? "YAML or JSON"} + + } + > + update({ input: input ?? "" })} + language="yaml" + path={INPUT_MODEL_PATH} + height="100%" + onMount={onEditorMount} + /> + + } + /> +
+ +
+
+ +
+ +
+ {outputTab === "result" ? ( + + ) : null} + {outputTab === "graph" ? ( + + ) : null} + {outputTab === "tokens" ? ( + + ) : null} + {outputTab === "spec" && language.catalogue ? ( + + ) : null} +
+
+
+ ); +} + +function EditorPane({ + label, + hint, + actions, + children, +}: { + label: string; + hint?: React.ReactNode; + actions?: React.ReactNode; + children: React.ReactNode; +}) { + return ( +
+
+
+

+ {label} +

+ {hint} +
+ {actions} +
+
+ {children} +
+
+ ); +} + +/** + * Mirrors a compile error onto the editor as a marker, so the position the + * server reported is visible where the mistake is rather than only in the + * result panel. + */ +function useMarkers(response: EvalResponse | null) { + useEffect(() => { + const model = monacoEditor.editor + .getModels() + .find((candidate) => candidate.uri.toString() === SOURCE_MODEL_PATH); + if (!model) return; + + const error = response?.error; + if (!error?.line) { + monacoEditor.editor.setModelMarkers(model, "expressions", []); + return; + } + + const column = error.column ?? 1; + monacoEditor.editor.setModelMarkers(model, "expressions", [ + { + severity: monacoEditor.MarkerSeverity.Error, + message: error.message, + startLineNumber: error.line, + startColumn: column, + endLineNumber: error.line, + endColumn: column + 1, + }, + ]); + }, [response]); +} diff --git a/packages/expressions/src/playground/RunControls.tsx b/packages/expressions/src/playground/RunControls.tsx new file mode 100644 index 000000000..0c74b138e --- /dev/null +++ b/packages/expressions/src/playground/RunControls.tsx @@ -0,0 +1,68 @@ +import { useEffect } from "react"; +import { Button, Switch } from "@flanksource/clicky-ui"; +import { UiPlay } from "@flanksource/clicky-ui/icons"; +import type { Evaluator } from "./useEvaluator.ts"; + +/** The accelerator, spelled the way the platform spells it. */ +export const RUN_SHORTCUT_LABEL = isApple() ? "⌘⏎" : "Ctrl+↵"; + +interface RunControlsProps { + evaluator: Evaluator; +} + +export function RunControls({ evaluator }: RunControlsProps) { + useGlobalRunShortcut(evaluator.run); + + return ( +
+ Auto-run} + /> + +
+ ); +} + +/** + * Cmd/Ctrl+Enter outside the editors. + * + * Monaco swallows keystrokes while it has focus, so the editors register the + * same accelerator as a Monaco action of their own (see `runAction`). This + * covers the rest of the page. + */ +function useGlobalRunShortcut(run: () => void) { + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Enter") return; + if (!(event.metaKey || event.ctrlKey)) return; + event.preventDefault(); + run(); + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [run]); +} + +function isApple(): boolean { + if (typeof navigator === "undefined") return false; + return /mac|iphone|ipad/i.test(navigator.userAgent); +} diff --git a/packages/expressions/src/playground/VerticalSplit.tsx b/packages/expressions/src/playground/VerticalSplit.tsx new file mode 100644 index 000000000..3f0dc23de --- /dev/null +++ b/packages/expressions/src/playground/VerticalSplit.tsx @@ -0,0 +1,161 @@ +import { + useCallback, + useEffect, + useRef, + useState, + type KeyboardEvent as ReactKeyboardEvent, + type ReactNode, +} from "react"; + +/** Monaco's line height, as clicky-ui's MonacoEditor configures it. */ +export const EDITOR_LINE_HEIGHT = 20; + +/** Height of an EditorPane header row (text-xs on py-2). */ +const PANE_HEADER_HEIGHT = 33; + +/** Slack for Monaco's own chrome: the horizontal scrollbar and top padding. */ +const EDITOR_CHROME = 12; + +/** + * Pane height that shows `rows` lines of code without scrolling. + * + * Expressed in rows because that is how the size is actually reasoned about -- + * a CEL expression is one or two lines, a templated manifest is a screenful -- + * and it keeps the default honest if the editor's line height ever changes. + */ +export function rowsToPaneHeight(rows: number): number { + return PANE_HEADER_HEIGHT + rows * EDITOR_LINE_HEIGHT + EDITOR_CHROME; +} + +export interface VerticalSplitProps { + top: ReactNode; + bottom: ReactNode; + /** Top-pane height in pixels, used until the reader drags the divider. */ + defaultTopHeight: number; + /** Smallest the top pane may be dragged. Defaults to two rows. */ + minTop?: number; + /** Smallest the bottom pane may be dragged. */ + minBottom?: number; + /** localStorage key persisting the dragged height. */ + storageKey?: string; +} + +/** + * A vertically stacked, resizable pair of panes. + * + * clicky-ui's SplitPane only splits horizontally, and the two editors stack, so + * the divider here is its own component. Sizing is in pixels rather than a + * percentage because the useful size of the expression editor is a number of + * rows, which a percentage of a varying viewport does not express. + */ +export function VerticalSplit({ + top, + bottom, + defaultTopHeight, + minTop = rowsToPaneHeight(2), + minBottom = 96, + storageKey, +}: VerticalSplitProps) { + const container = useRef(null); + const [topHeight, setTopHeight] = useState(() => readStored(storageKey) ?? defaultTopHeight); + const [dragging, setDragging] = useState(false); + + // Only follow the language's default while the reader has not chosen a size: + // a dragged divider is a decision, and switching language should not undo it. + const hasStoredSize = useRef(readStored(storageKey) !== null); + useEffect(() => { + if (!hasStoredSize.current) setTopHeight(defaultTopHeight); + }, [defaultTopHeight]); + + const clamp = useCallback( + (height: number) => { + const available = container.current?.getBoundingClientRect().height ?? 0; + const upperBound = Math.max(minTop, available - minBottom); + return Math.round(Math.max(minTop, Math.min(upperBound, height))); + }, + [minTop, minBottom], + ); + + const commit = useCallback( + (height: number) => { + const next = clamp(height); + setTopHeight(next); + hasStoredSize.current = true; + if (storageKey) window.localStorage.setItem(storageKey, String(next)); + }, + [clamp, storageKey], + ); + + const onPointerDown = useCallback( + (event: React.PointerEvent) => { + event.preventDefault(); + const rect = container.current?.getBoundingClientRect(); + if (!rect) return; + + setDragging(true); + const onMove = (moveEvent: PointerEvent) => commit(moveEvent.clientY - rect.top); + const onUp = () => { + setDragging(false); + document.removeEventListener("pointermove", onMove); + document.removeEventListener("pointerup", onUp); + }; + document.addEventListener("pointermove", onMove); + document.addEventListener("pointerup", onUp); + }, + [commit], + ); + + // A separator that can only be dragged is unusable without a mouse, and this + // one gates access to the input editor. + const onKeyDown = useCallback( + (event: ReactKeyboardEvent) => { + const step = event.shiftKey ? EDITOR_LINE_HEIGHT * 5 : EDITOR_LINE_HEIGHT; + if (event.key === "ArrowUp") { + event.preventDefault(); + commit(topHeight - step); + } else if (event.key === "ArrowDown") { + event.preventDefault(); + commit(topHeight + step); + } else if (event.key === "Home") { + event.preventDefault(); + commit(minTop); + } + }, + [commit, topHeight, minTop], + ); + + return ( +
+
+ {top} +
+ +
+ {/* A 6px strip is a small target; widen the grab area without moving + anything by overflowing an invisible band above and below it. */} + +
+ +
{bottom}
+
+ ); +} + +function readStored(key: string | undefined): number | null { + if (!key || typeof window === "undefined") return null; + const raw = window.localStorage.getItem(key); + if (raw === null) return null; + const parsed = Number.parseInt(raw, 10); + return Number.isFinite(parsed) ? parsed : null; +} diff --git a/packages/expressions/src/playground/api.ts b/packages/expressions/src/playground/api.ts new file mode 100644 index 000000000..50a1da795 --- /dev/null +++ b/packages/expressions/src/playground/api.ts @@ -0,0 +1,110 @@ +import type { GomplateSpec } from "../lang/index.ts"; +import type { EvalLanguage } from "./languages.ts"; + +export interface EvalRequest { + language: EvalLanguage; + source: string; + input?: string; + leftDelim?: string; + rightDelim?: string; +} + +export interface EvalError { + message: string; + line?: number; + column?: number; +} + +export interface EvalResponse { + result: string; + value?: unknown; + type?: string; + error?: EvalError; + durationMs: number; +} + +/** One sample an author can load, as `GET /api/examples` returns it. */ +export interface Example { + name: string; + language: EvalLanguage; + source: string; + input: string; +} + +/** + * Where the host mounted the playground API. + * + * A host serves it under its own prefix -- mission-control behind an + * authenticated route group -- so nothing here may assume `/api`. + */ +export const DEFAULT_API_BASE = "/api"; + +/** + * Evaluates against the host's Go server. + * + * A transport failure is surfaced as an error result rather than thrown: the + * usual cause is the server not running yet, and the playground should say so + * rather than blank out. + */ +export async function evaluate( + apiBase: string, + request: EvalRequest, + signal?: AbortSignal, +): Promise { + let response: Response; + try { + response = await fetch(`${apiBase}/eval`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(request), + ...(signal ? { signal } : {}), + }); + } catch (cause) { + if (signal?.aborted) throw cause; + return { + result: "", + durationMs: 0, + error: { message: `could not reach the evaluation server: ${String(cause)}` }, + }; + } + + if (!response.ok && response.status !== 400) { + return { + result: "", + durationMs: 0, + error: { message: `evaluation server returned ${response.status} ${response.statusText}` }, + }; + } + return (await response.json()) as EvalResponse; +} + +/** + * The catalogue the running server can actually evaluate. + * + * Wider than the one this package ships whenever the host registers functions + * of its own. Undefined on any failure, which leaves the packaged catalogue in + * place -- right when the server is simply not up yet. + */ +export async function fetchSpec( + apiBase: string, + signal?: AbortSignal, +): Promise { + return fetchJSON(`${apiBase}/spec`, signal); +} + +export async function fetchExamples( + apiBase: string, + signal?: AbortSignal, +): Promise { + return fetchJSON(`${apiBase}/examples`, signal); +} + +async function fetchJSON(url: string, signal?: AbortSignal): Promise { + try { + const response = await fetch(url, signal ? { signal } : {}); + if (!response.ok) return undefined; + return (await response.json()) as T; + } catch { + return undefined; + } +} diff --git a/packages/expressions/src/playground/languages.ts b/packages/expressions/src/playground/languages.ts new file mode 100644 index 000000000..3c667f8cd --- /dev/null +++ b/packages/expressions/src/playground/languages.ts @@ -0,0 +1,136 @@ +import type { LanguageId } from "../lang/index.ts"; +import { + UiBraces, + UiCode2, + UiFileCode, + UiFileJson, + UiFileText, + UiFunction, +} from "@flanksource/clicky-ui/icons"; +import type { StaticIconComponent } from "@flanksource/clicky-ui"; + +/** An evaluator on the Go side. */ +export type EvalLanguage = "cel" | "gotemplate" | "jsonpath" | "javascript"; + +/** One entry in the playground's language rail. */ +export interface PlaygroundLanguage { + id: string; + label: string; + icon: StaticIconComponent; + /** Rail section this language belongs to. */ + section: "Expressions" | "Templates"; + /** Monaco language for the source editor. */ + editorLanguage: LanguageId | "javascript"; + /** Which evaluator the Go server should run. */ + evalLanguage: EvalLanguage; + description: string; + /** + * Which half of the catalogue the Functions tab browses, if either. JSONPath + * and JavaScript have no generated catalogue, so they get no tab rather than + * an empty one. + */ + catalogue?: "cel" | "gotemplate"; + /** + * Rows the expression editor opens at, before the reader drags the divider. + * + * An expression is a line or two, so five rows leaves the input document the + * rest of the pane. A template is a whole document, and starting it at five + * rows would hide most of what is being written. + */ + editorRows: number; +} + +/** Default rows for a language whose source is an expression. */ +const EXPRESSION_ROWS = 5; + +/** Default rows for a language whose source is a document. */ +const TEMPLATE_ROWS = 14; + +export const LANGUAGES: PlaygroundLanguage[] = [ + { + id: "cel", + label: "CEL", + icon: UiFunction, + section: "Expressions", + editorLanguage: "cel", + evalLanguage: "cel", + catalogue: "cel", + description: "Common Expression Language, with gomplate's k8s, aws and math helpers", + editorRows: EXPRESSION_ROWS, + }, + { + id: "jsonpath", + label: "JSONPath", + icon: UiBraces, + section: "Expressions", + editorLanguage: "jsonpath", + evalLanguage: "jsonpath", + description: "JSONPath, as evaluated by ojg", + editorRows: EXPRESSION_ROWS, + }, + { + id: "javascript", + label: "JavaScript", + icon: UiCode2, + section: "Expressions", + editorLanguage: "javascript", + evalLanguage: "javascript", + description: "JavaScript, as evaluated by otto", + editorRows: EXPRESSION_ROWS, + }, + { + id: "gomplate", + label: "Go template", + icon: UiFileCode, + section: "Templates", + editorLanguage: "gomplate", + evalLanguage: "gotemplate", + catalogue: "gotemplate", + description: "Go text/template with gomplate's function library", + editorRows: TEMPLATE_ROWS, + }, + { + id: "yaml-gomplate", + label: "YAML + template", + icon: UiFileText, + section: "Templates", + editorLanguage: "yaml-gomplate", + evalLanguage: "gotemplate", + catalogue: "gotemplate", + description: "YAML with templates embedded in it, as real configuration is written", + editorRows: TEMPLATE_ROWS, + }, + { + id: "json-gomplate", + label: "JSON + template", + icon: UiFileJson, + section: "Templates", + editorLanguage: "json-gomplate", + evalLanguage: "gotemplate", + catalogue: "gotemplate", + description: "JSON with templates embedded in it", + editorRows: TEMPLATE_ROWS, + }, +]; + +/** Rail section order. */ +export const SECTIONS = ["Expressions", "Templates"] as const; + +/** + * Resolves a language id against the offered list. + * + * Falls back to the first offered language rather than throwing: the id can + * come from a URL an author edited or a bookmark from before a host narrowed + * the list, and losing the page to an exception is the worse outcome. + */ +export function languageById( + id: string, + offered: PlaygroundLanguage[] = LANGUAGES, +): PlaygroundLanguage { + const found = offered.find((language) => language.id === id); + if (found) return found; + + const [first] = offered; + if (!first) throw new Error("no playground languages were offered"); + return first; +} diff --git a/packages/expressions/src/playground/panels/GraphPanel.tsx b/packages/expressions/src/playground/panels/GraphPanel.tsx new file mode 100644 index 000000000..7e5a87522 --- /dev/null +++ b/packages/expressions/src/playground/panels/GraphPanel.tsx @@ -0,0 +1,95 @@ +import { useMemo, useState } from "react"; +import { ObjectGraph } from "@flanksource/clicky-ui/data"; +import { createLazyJSONPathTree, literalSegments } from "@flanksource/clicky-ui/components"; +import type { JSONPathNode, LazyJSONPathTree } from "@flanksource/clicky-ui/components"; +import { pathExpression } from "../../lang/index.ts"; +import { toGraphNode } from "./graphNodes.ts"; +import type { GraphNode } from "./graphNodes.ts"; + +interface GraphPanelProps { + /** The parsed input document, or undefined when there is nothing to show. */ + document: unknown; + /** Monaco language id, which decides the syntax a click inserts. */ + languageId: string; + /** Writes an expression at the source editor's cursor. */ + onInsert: (expression: string) => void; +} + +/** + * The shape of the document being evaluated against. + * + * The input pane shows the document as text; this shows it as paths. Clicking a + * row writes that path into the expression, in the syntax of the language being + * written, which is the step that otherwise means reading YAML and retyping it + * by hand. + */ +export function GraphPanel({ document, languageId, onInsert }: GraphPanelProps) { + const [selectedId, setSelectedId] = useState(); + const [note, setNote] = useState(); + + const tree = useMemo( + () => + document === undefined || document === null + ? null + : createLazyJSONPathTree(document, { keyPrefix: "input" }), + [document], + ); + + const roots = useMemo(() => (tree ? tree.roots.map(toGraphNode) : []), [tree]); + + if (!tree) { + return ( +
+ Nothing to show yet — write a YAML or JSON document in the input pane and its shape + appears here. +
+ ); + } + + const select = (node: GraphNode) => { + setSelectedId(node.id); + const segments = node.path ? literalSegments(node.path) : undefined; + if (!segments) { + setNote("That row has no addressable path."); + return; + } + const expression = pathExpression(languageId, segments); + if (expression === null) { + setNote( + "A go template reaches a list element or a non-identifier key through `index`, " + + "which is a call rather than a path — so there is nothing to insert.", + ); + return; + } + if (expression === "") { + setNote("The whole document has no name in this language — pick a key under it."); + return; + } + setNote(undefined); + onInsert(expression); + }; + + return ( +
+
+ { + const source = node.metadata?.node as JSONPathNode | undefined; + if (!source) return []; + return (await tree.loadChildren(source)).map(toGraphNode); + }} + empty="The document is empty." + /> +
+
+ {note ?? "Click a key to insert its path at the cursor."} +
+
+ ); +} + diff --git a/packages/expressions/src/playground/panels/ResultPanel.tsx b/packages/expressions/src/playground/panels/ResultPanel.tsx new file mode 100644 index 000000000..36fec17eb --- /dev/null +++ b/packages/expressions/src/playground/panels/ResultPanel.tsx @@ -0,0 +1,107 @@ +import { Badge, Button, CodeBlock, JsonView } from "@flanksource/clicky-ui"; +import type { EvalResponse } from "../api.ts"; +import { RUN_SHORTCUT_LABEL } from "../RunControls.tsx"; + +interface ResultPanelProps { + response: EvalResponse | null; + pending: boolean; + /** The shown result predates the current source or input. */ + stale: boolean; + onRun: () => void; +} + +export function ResultPanel({ response, pending, stale, onRun }: ResultPanelProps) { + if (!response) { + return ( +
+

+ {pending ? "Evaluating…" : "Type an expression, then run it."} +

+ {stale && !pending ? ( + + ) : null} +
+ ); + } + + if (response.error) { + return ( +
+
+

+ {response.error.line + ? `Error at line ${response.error.line}, column ${response.error.column}` + : "Error"} +

+ +
+
+ ); + } + + return ( +
+
+ {response.durationMs.toFixed(2)} ms + {response.type ? {response.type} : null} + {/* Without this, a result that no longer matches what is on screen is + indistinguishable from one that does. */} + {stale ? ( + + ) : null} +
+ +
+ {/* The rendered string is what a gomplate caller receives. A structured + result is more readable as a tree, so hand those to JsonView and + keep the raw rendering below it. */} + {isStructured(response.value) ? ( + <> + +
+ + Rendered string + +
+ +
+
+ + ) : ( + + )} +
+
+ ); +} + +function isStructured(value: unknown): boolean { + return typeof value === "object" && value !== null; +} + +/** + * Templates render whole documents, so highlight the output when it is + * recognisably YAML or JSON rather than showing a wall of grey. + */ +function languageOf(result: string): string | undefined { + const trimmed = result.trim(); + if (!trimmed) return undefined; + if (trimmed.startsWith("{") || trimmed.startsWith("[")) return "json"; + if (/^[A-Za-z_][\w.-]*:\s/m.test(trimmed)) return "yaml"; + return undefined; +} diff --git a/packages/expressions/src/playground/panels/SpecPanel.tsx b/packages/expressions/src/playground/panels/SpecPanel.tsx new file mode 100644 index 000000000..d2e53691a --- /dev/null +++ b/packages/expressions/src/playground/panels/SpecPanel.tsx @@ -0,0 +1,109 @@ +import { useMemo } from "react"; +import { Badge, DataTable } from "@flanksource/clicky-ui"; +import type { DataTableColumn } from "@flanksource/clicky-ui"; +import type { GomplateSpec, SpecFunction } from "../../lang/index.ts"; + +interface SpecPanelProps { + /** Which catalogue to browse. */ + flavour: "cel" | "gotemplate"; + /** The catalogue itself — the server's when it has one, gomplate's otherwise. */ + spec: GomplateSpec; +} + +interface FunctionRow extends Record { + name: string; + namespace: string; + signature: string; + kind: string; + doc: string; +} + +/** + * Browses the generated function catalogue. + * + * This is the first accurate reference that exists for this fork: CEL.md + * documents functions that live in `duty` and omits whole namespaces, while + * docs-src carries the upstream list. What is shown here is read out of the + * live registries, so it is what the evaluator will actually accept. + */ +export function SpecPanel({ flavour, spec }: SpecPanelProps) { + const rows = useMemo(() => { + const functions = flavour === "cel" ? spec.cel.functions : spec.gotemplate.functions; + return functions.map(toRow); + }, [flavour, spec]); + + const columns: DataTableColumn[] = [ + { + key: "name", + label: "Name", + sortable: true, + grow: true, + cellClassName: "font-mono", + }, + { + key: "namespace", + label: "Namespace", + sortable: true, + filterable: true, + shrink: true, + render: (value) => + value ? String(value) : , + }, + { + key: "kind", + label: "Call", + filterable: true, + shrink: true, + // `x.sum()` is legal where a bare `sum(x)` is not, and nothing else in + // the catalogue records that -- so it is worth a column of its own. + render: (value) => + value === "member" ? ( + member only + ) : ( + global + ), + }, + { + key: "signature", + label: "Signature", + grow: true, + cellClassName: "font-mono text-muted-foreground", + }, + { key: "doc", label: "Description", grow: true }, + ]; + + return ( + + ); +} + +function toRow(fn: SpecFunction): FunctionRow { + return { + name: fn.name, + namespace: fn.namespace ?? "", + signature: signatureOf(fn), + kind: fn.memberOnly ? "member" : "global", + doc: fn.doc ?? "", + }; +} + +function signatureOf(fn: SpecFunction): string { + if (fn.signature) return fn.signature; + + const [overload] = fn.overloads ?? []; + if (!overload) return ""; + + // A member overload carries its receiver as the first argument; an author + // writes it before the dot, not inside the parentheses. + const args = overload.member ? overload.args.slice(1) : overload.args; + return `(${args.join(", ")}) -> ${overload.result}`; +} diff --git a/packages/expressions/src/playground/panels/TokensPanel.tsx b/packages/expressions/src/playground/panels/TokensPanel.tsx new file mode 100644 index 000000000..308a4ab85 --- /dev/null +++ b/packages/expressions/src/playground/panels/TokensPanel.tsx @@ -0,0 +1,82 @@ +import { useMemo } from "react"; +import { DataTable } from "@flanksource/clicky-ui"; +import type { DataTableColumn } from "@flanksource/clicky-ui"; +import * as monaco from "monaco-editor"; + +interface TokensPanelProps { + source: string; + languageId: string; +} + +interface TokenRow extends Record { + line: number; + text: string; + token: string; +} + +/** + * Shows the token stream Monarch produces. + * + * This is the panel that makes a highlighting bug legible: colours tell you + * something is off, the token stream tells you which rule matched. It is also + * the fastest way to check a newly registered function is classified as + * `function` rather than falling through to `identifier`. + */ +export function TokensPanel({ source, languageId }: TokensPanelProps) { + const rows = useMemo(() => tokenize(source, languageId), [source, languageId]); + + const columns: DataTableColumn[] = [ + { key: "line", label: "Line", align: "right", shrink: true, sortable: true }, + { + key: "text", + label: "Text", + grow: true, + cellClassName: "font-mono whitespace-pre", + }, + { + key: "token", + label: "Token", + sortable: true, + filterable: true, + cellClassName: "font-mono", + // An `identifier` is the fallback every unmatched word lands on, so it is + // the one class worth de-emphasising: what stands out is what matched. + render: (value) => ( + + {String(value)} + + ), + }, + ]; + + return ( + + ); +} + +function tokenize(source: string, languageId: string): TokenRow[] { + if (!source.trim()) return []; + + const lines = monaco.editor.tokenize(source, languageId); + const rows: TokenRow[] = []; + + source.split(/\r\n|\r|\n/).forEach((line, index) => { + const tokens = lines[index] ?? []; + tokens.forEach((token, i) => { + // Monaco reports a start offset only; each token runs to the next start. + const end = i + 1 < tokens.length ? tokens[i + 1]!.offset : line.length; + const text = line.slice(token.offset, end); + if (text.trim() === "") return; + rows.push({ line: index + 1, text, token: token.type }); + }); + }); + return rows; +} diff --git a/packages/expressions/src/playground/panels/graphNodes.ts b/packages/expressions/src/playground/panels/graphNodes.ts new file mode 100644 index 000000000..899ac2b58 --- /dev/null +++ b/packages/expressions/src/playground/panels/graphNodes.ts @@ -0,0 +1,53 @@ +import type { ObjectGraphNode } from "@flanksource/clicky-ui/data"; +import { literalSegments } from "@flanksource/clicky-ui/components"; +import type { JSONPathNode } from "@flanksource/clicky-ui/components"; +import { kindOf } from "../../lang/index.ts"; + +/** An `ObjectGraphNode` that keeps the tree node it came from, for lazy loading. */ +export interface GraphNode extends ObjectGraphNode { + metadata?: { node: JSONPathNode }; +} + +/** + * Maps one lazy-tree node to a graph row. + * + * Two of the tree's fields do not mean what their names suggest here. `key` is a + * namespaced identity, not a display name, so the label comes off the end of the + * path instead. And `kind` is structural — object, array, scalar — so a scalar's + * own type is read from the value: `string` against `number` is the distinction + * that decides whether an expression needs quotes. + */ +export function toGraphNode(node: JSONPathNode): GraphNode { + const container = node.kind === "object" || node.kind === "array"; + const scalar = node.kind === "scalar"; + // Keys are omitted rather than set to undefined: `exactOptionalPropertyTypes` + // treats the two as different, and ObjectGraph's optional fields mean absent. + return { + id: node.key, + label: labelOf(node), + path: node.path, + kind: node.kind, + ...(scalar || container ? { type: kindOf(node.value) } : {}), + ...(scalar ? { value: scalarValue(node.value) } : {}), + ...(scalar ? {} : { raw: node.summary }), + expandable: container && node.childCount > 0, + metadata: { node }, + }; +} + +/** The last segment of the node's path — the root has none, and is `$`. */ +function labelOf(node: JSONPathNode): string { + if (node.kind === "more") return "…"; + const segments = literalSegments(node.path); + const last = segments?.[segments.length - 1]; + return last === undefined ? node.path : String(last); +} + +function scalarValue(value: unknown): string | number | boolean | null { + if (value === null || value === undefined) return null; + const type = typeof value; + if (type === "string" || type === "number" || type === "boolean") { + return value as string | number | boolean; + } + return String(value); +} diff --git a/packages/expressions/src/playground/runAction.ts b/packages/expressions/src/playground/runAction.ts new file mode 100644 index 000000000..2efc92488 --- /dev/null +++ b/packages/expressions/src/playground/runAction.ts @@ -0,0 +1,26 @@ +import type * as monacoEditor from "monaco-editor"; +import type { Monaco } from "@flanksource/clicky-ui/monaco"; + +/** + * Registers Cmd/Ctrl+Enter inside a Monaco editor. + * + * A window-level listener is not enough: Monaco captures keystrokes while it + * has focus, which is exactly where the reader is when they want to run. The + * action also puts "Run expression" in the editor's command palette, so the + * accelerator is discoverable rather than folklore. + * + * `run` is read through a ref by the caller, because the action is registered + * once per editor and must not capture the first render's closure. + */ +export function registerRunAction( + editor: monacoEditor.editor.IStandaloneCodeEditor, + monaco: Monaco, + run: () => void, +): monacoEditor.IDisposable { + return editor.addAction({ + id: "gomplate.run", + label: "Run expression", + keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter], + run, + }); +} diff --git a/packages/expressions/src/playground/useEditorTheme.ts b/packages/expressions/src/playground/useEditorTheme.ts new file mode 100644 index 000000000..a012d1b20 --- /dev/null +++ b/packages/expressions/src/playground/useEditorTheme.ts @@ -0,0 +1,39 @@ +import { useCallback, useEffect } from "react"; +import * as monaco from "monaco-editor"; +import { GOMPLATE_DARK_THEME, GOMPLATE_LIGHT_THEME } from "../lang/index.ts"; + +/** + * Applies the gomplate colour themes and keeps them in step with clicky-ui's + * theme switcher. + * + * `monaco.editor.setTheme` is global rather than per-editor, and + * `@monaco-editor/react` calls it whenever an editor mounts, using the `theme` + * prop clicky-ui hardcodes to `light`/`vs-dark`. So setting the theme once is + * not enough: every editor that mounts afterwards resets it. Re-asserting from + * each editor's `onMount` runs after that call and is deterministic, where a + * plain effect would race the editor's lazy mount. + * + * clicky-ui's `MonacoEditor` gaining a `theme` prop makes this unnecessary; + * until then this keeps the playground correct against the published package. + */ +export function useEditorTheme() { + const applyTheme = useCallback(() => { + const dark = document.documentElement.getAttribute("data-theme") === "dark"; + monaco.editor.setTheme(dark ? GOMPLATE_DARK_THEME : GOMPLATE_LIGHT_THEME); + }, []); + + useEffect(() => { + applyTheme(); + + // clicky-ui's ThemeProvider writes `data-theme` on and emits no + // event, so observe the attribute. + const observer = new MutationObserver(applyTheme); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["data-theme"], + }); + return () => observer.disconnect(); + }, [applyTheme]); + + return applyTheme; +} diff --git a/packages/expressions/src/playground/useEvaluator.ts b/packages/expressions/src/playground/useEvaluator.ts new file mode 100644 index 000000000..4763f88ad --- /dev/null +++ b/packages/expressions/src/playground/useEvaluator.ts @@ -0,0 +1,117 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { evaluate, type EvalRequest, type EvalResponse } from "./api.ts"; + +const AUTO_RUN_STORAGE_KEY = "expressions:auto-run"; + +/** How long typing settles before an automatic run fires. */ +const DEBOUNCE_MS = 250; + +export interface Evaluator { + response: EvalResponse | null; + /** A request is in flight or a debounce is pending. */ + pending: boolean; + /** The source or input has changed since the shown result was produced. */ + stale: boolean; + autoRun: boolean; + setAutoRun: (next: boolean) => void; + /** Evaluates now, skipping the debounce. */ + run: () => void; +} + +type Payload = Pick; + +/** + * Runs an expression against the Go evaluator. + * + * Automatic evaluation is convenient for a one-line expression and a nuisance + * for anything longer: a debounce fires mid-keystroke and reports errors for + * half-written input. So it is a toggle, and an explicit run is always + * available -- which is also the only way to re-run an expression whose value + * changes on its own (`time.Now()`, `uuid.V4()`, `random.*`). + */ +export function useEvaluator(apiBase: string, payload: Payload): Evaluator { + const [response, setResponse] = useState(null); + const [pending, setPending] = useState(false); + const [autoRun, setAutoRunState] = useState(readAutoRun); + const [evaluated, setEvaluated] = useState(null); + + const abortRef = useRef(undefined); + // The current payload, so `run` stays referentially stable: it is wired into + // a Monaco action registered once at mount, which would otherwise capture the + // payload from the first render forever. + const payloadRef = useRef(payload); + payloadRef.current = payload; + + const evaluateNow = useCallback(() => { + const current = payloadRef.current; + if (!current.source.trim()) { + abortRef.current?.abort(); + setResponse(null); + setEvaluated(current); + setPending(false); + return; + } + + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + + setPending(true); + evaluate(apiBase, current, controller.signal) + .then((next) => { + setResponse(next); + setEvaluated(current); + setPending(false); + }) + .catch(() => { + // Superseded by a newer run; that one owns the state. + }); + }, [apiBase]); + + useEffect(() => { + if (!autoRun) return; + const timer = setTimeout(evaluateNow, DEBOUNCE_MS); + return () => clearTimeout(timer); + }, [payload.language, payload.source, payload.input, autoRun, evaluateNow]); + + // Switching language changes what the source even means, so show nothing + // rather than the previous language's result. + useEffect(() => { + setResponse(null); + setEvaluated(null); + }, [payload.language]); + + const setAutoRun = useCallback( + (next: boolean) => { + setAutoRunState(next); + window.localStorage.setItem(AUTO_RUN_STORAGE_KEY, String(next)); + if (next) evaluateNow(); + }, + [evaluateNow], + ); + + return { + response, + pending, + stale: isStale(evaluated, payload), + autoRun, + setAutoRun, + run: evaluateNow, + }; +} + +function isStale(evaluated: Payload | null, current: Payload): boolean { + if (!current.source.trim()) return false; + if (!evaluated) return true; + return ( + evaluated.source !== current.source || + evaluated.input !== current.input || + evaluated.language !== current.language + ); +} + +function readAutoRun(): boolean { + if (typeof window === "undefined") return true; + // Default on: the playground should evaluate as soon as it opens. + return window.localStorage.getItem(AUTO_RUN_STORAGE_KEY) !== "false"; +} diff --git a/packages/expressions/src/playground/useParsedInput.ts b/packages/expressions/src/playground/useParsedInput.ts new file mode 100644 index 000000000..53818787f --- /dev/null +++ b/packages/expressions/src/playground/useParsedInput.ts @@ -0,0 +1,39 @@ +import { useMemo, useRef } from "react"; +import { parse } from "yaml"; + +export interface ParsedInput { + /** The last document that parsed, so editing one does not blank the other. */ + value: unknown; + /** The parse failure for the text as it stands, if there is one. */ + error: string | null; +} + +/** + * Parses the input pane on the client. + * + * The eval response carries only the result — it never echoes the environment + * back — and completion has to work while the document is still being typed, + * before any round trip. JSON is valid YAML, so one parser covers both, the same + * way the Go side's `parseInput` does. + * + * A half-typed document is a parse error on most keystrokes. Holding the last + * good value through those keeps the completion list and the shape tree from + * flickering out from under the reader. + */ +export function useParsedInput(input: string): ParsedInput { + const lastGood = useRef(undefined); + + return useMemo(() => { + if (input.trim() === "") { + lastGood.current = undefined; + return { value: undefined, error: null }; + } + try { + const value: unknown = parse(input); + lastGood.current = value; + return { value, error: null }; + } catch (error) { + return { value: lastGood.current, error: (error as Error).message }; + } + }, [input]); +} diff --git a/packages/expressions/test/setup.ts b/packages/expressions/test/setup.ts new file mode 100644 index 000000000..02615de7b --- /dev/null +++ b/packages/expressions/test/setup.ts @@ -0,0 +1,13 @@ +// jsdom has no matchMedia, and clicky-ui's theming reads it on import. +if (!window.matchMedia) { + window.matchMedia = ((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + })) as unknown as typeof window.matchMedia; +} diff --git a/packages/expressions/test/vendored.test.ts b/packages/expressions/test/vendored.test.ts new file mode 100644 index 000000000..d0a80fb9d --- /dev/null +++ b/packages/expressions/test/vendored.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import * as monaco from "monaco-editor/esm/vs/editor/editor.api"; + +import { LANGUAGE_IDS, mergeSpec, registerGomplateLanguages, spec } from "../src/index.ts"; +import { languageById, LANGUAGES } from "../src/playground.ts"; + +/** + * The vendored tree carries its own extensive suite in gomplate; this checks + * that what was copied here still works once wired to this package's entries — + * the failure mode a vendoring introduces is a broken copy, not broken logic. + */ +describe("the vendored language support", () => { + registerGomplateLanguages(monaco, { completions: false, hovers: false }); + + it("registers every generated language", () => { + const registered = new Set(monaco.languages.getLanguages().map((l) => l.id)); + for (const id of LANGUAGE_IDS) expect(registered).toContain(id); + }); + + it("tokenizes a namespaced CEL call", () => { + const [line] = monaco.editor.tokenize(`k8s.cpuAsMillicores("500m")`, "cel"); + expect((line ?? []).map((token) => token.type.replace(/\.cel$/, ""))).toContain("namespace"); + }); + + it("ships a catalogue big enough to be the real one", () => { + expect(spec.cel.functions.length).toBeGreaterThan(100); + expect(spec.gotemplate.functions.length).toBeGreaterThan(100); + }); + + it("folds a host's catalogue in", () => { + const merged = mergeSpec(spec, { + ...spec, + cel: { + ...spec.cel, + namespaces: [...spec.cel.namespaces, "catalog"], + functions: [...spec.cel.functions, { name: "catalog.query", namespace: "catalog" }], + }, + }); + expect(merged.cel.functions.map((fn) => fn.name)).toContain("catalog.query"); + }); +}); + +describe("the playground's language list", () => { + it("names an editor language this package registers", () => { + for (const language of LANGUAGES) { + if (language.editorLanguage === "javascript") continue; // Monaco's own + expect(LANGUAGE_IDS).toContain(language.editorLanguage); + } + }); + + it("falls back rather than throwing on an id it does not know", () => { + // The id arrives from a URL or a bookmark, so an unknown one must not take + // the page down with it. + expect(languageById("klingon").id).toBe(LANGUAGES[0]!.id); + }); + + it("throws only when offered nothing at all", () => { + expect(() => languageById("cel", [])).toThrow(/no playground languages/); + }); +}); diff --git a/packages/expressions/tsconfig.json b/packages/expressions/tsconfig.json new file mode 100644 index 000000000..92d07eb0d --- /dev/null +++ b/packages/expressions/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["vite/client"], + "outDir": "dist", + "rootDir": "src", + "declaration": true, + "declarationMap": true, + "emitDeclarationOnly": true, + "noEmit": false, + "composite": true + }, + // The generated JSON is imported directly, and a composite project has to + // list every file it compiles. + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.json"], + "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx", "dist"] +} diff --git a/packages/expressions/vite.config.ts b/packages/expressions/vite.config.ts new file mode 100644 index 000000000..dbaf64a9b --- /dev/null +++ b/packages/expressions/vite.config.ts @@ -0,0 +1,49 @@ +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; +import dts from "vite-plugin-dts"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +// Two entries: the language support alone, and the playground UI over it. A +// host that only wants highlighting in its own editor should not pull React, +// clicky-ui and the Monaco editor in with it. +const entry = { + index: resolve(__dirname, "src/index.ts"), + playground: resolve(__dirname, "src/playground.ts"), +}; + +export default defineConfig(({ mode }) => { + const isCjs = mode === "cjs"; + const jsExt = isCjs ? "cjs" : "js"; + + return { + plugins: isCjs + ? [] + : [ + react(), + dts({ tsconfigPath: "./tsconfig.json", include: ["src"], rollupTypes: false }), + ], + build: { + emptyOutDir: !isCjs, + lib: { + entry, + formats: [isCjs ? "cjs" : "es"], + fileName: (_format, name) => `${name}.${jsExt}`, + }, + rollupOptions: { + external: [ + "react", + "react-dom", + "react/jsx-runtime", + "monaco-editor", + /^monaco-editor\//, + /^@flanksource\/clicky-ui/, + "yaml", + ], + output: { preserveModules: false }, + }, + }, + }; +}); diff --git a/packages/expressions/vitest.config.ts b/packages/expressions/vitest.config.ts new file mode 100644 index 000000000..f62f31590 --- /dev/null +++ b/packages/expressions/vitest.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + resolve: { + alias: [ + { + // The package root is the browser bundle and will not load headlessly; + // the editor API entry is the same surface without the DOM bootstrap. + // Anchored, or it would also rewrite the deep path it maps to. + find: /^monaco-editor$/, + replacement: "monaco-editor/esm/vs/editor/editor.api", + }, + ], + }, + test: { + environment: "jsdom", + setupFiles: ["./test/setup.ts"], + include: ["test/**/*.test.ts", "test/**/*.test.tsx"], + }, +}); diff --git a/packages/ui/README.md b/packages/ui/README.md index dfa853b7b..6fa520dea 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -85,13 +85,20 @@ Markdown and code highlighting use optional peer dependencies (`marked`, `shiki` ## Tailwind preset +Tailwind CSS v4 consumers can load the preset through its compatibility directive: + +```css +/* src/styles.css */ +@config "../tailwind.config.ts"; +@import "tailwindcss"; +``` + ```ts // tailwind.config.ts import preset from "@flanksource/clicky-ui/tailwind-preset"; export default { presets: [preset], - content: ["./src/**/*.{ts,tsx}"], }; ``` diff --git a/packages/ui/icons/icon-selections.json b/packages/ui/icons/icon-selections.json index 6de83dac2..c0bf1f50f 100644 --- a/packages/ui/icons/icon-selections.json +++ b/packages/ui/icons/icon-selections.json @@ -2891,6 +2891,62 @@ "outline": "ph:cloud-arrow-up-light", "filled": "ph:cloud-arrow-up-fill", "note": "Upstream write to an external system" + }, + { + "consumerName": "users-four", + "group": "people-orgs", + "status": "NEW", + "outline": "ph:users-four-light", + "filled": "ph:users-four-fill", + "note": "A group/collective distinct from users-three (a team vs a wider membership)." + }, + { + "consumerName": "user-gear", + "group": "people-orgs", + "status": "NEW", + "outline": "ph:user-gear-light", + "filled": "ph:user-gear-fill", + "note": "An operator/administrator account, as opposed to a subject person (user)." + }, + { + "consumerName": "identification-card", + "group": "people-orgs", + "status": "NEW", + "outline": "ph:identification-card-light", + "filled": "ph:identification-card-fill", + "note": "A role or party assignment held by a person on a record." + }, + { + "consumerName": "clipboard-text", + "group": "forms-editing", + "status": "NEW", + "outline": "ph:clipboard-text-light", + "filled": "ph:clipboard-text-fill", + "note": "A reusable intake/ingest profile: the named form a bulk load is bound to." + }, + { + "consumerName": "lock-key", + "group": "security-auth", + "status": "NEW", + "outline": "ph:lock-key-light", + "filled": "ph:lock-key-fill", + "note": "A security group / permission set, distinct from a plain locked state (lock)." + }, + { + "consumerName": "arrows-counter-clockwise", + "group": "actions-tools", + "status": "NEW", + "outline": "ph:arrows-counter-clockwise-light", + "filled": "ph:arrows-counter-clockwise-fill", + "note": "Undo/restore a bulk state change. The counterpart to refresh (arrows-clockwise), which re-reads." + }, + { + "consumerName": "steps", + "group": "playbooks-workflows", + "status": "NEW", + "outline": "ph:steps-light", + "filled": "ph:steps-fill", + "note": "An ordered stage within a pipeline that must run in sequence." } ] } diff --git a/packages/ui/package.json b/packages/ui/package.json index 4a17b49a0..30e05fc5c 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", @@ -143,7 +148,7 @@ "dompurify": "^3.4.11", "jotai": "catalog:", "monaco-yaml": "catalog:", - "tailwind-merge": "^2.6.0", + "tailwind-merge": "catalog:", "yaml": "catalog:" }, "devDependencies": { @@ -151,7 +156,7 @@ "@mdxeditor/editor": "catalog:", "@oxlint/plugins": "catalog:", "@storybook/react-vite": "10.3.5", - "@tailwindcss/cli": "^4.2.4", + "@tailwindcss/cli": "^4.3.3", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.1.0", "@types/react": "^18.3.12", @@ -165,7 +170,7 @@ "recharts": "^3.8.1", "streamdown": "^2.5.0", "svgo": "^3.2.0", - "tailwindcss": "^4.2.4", + "tailwindcss": "^4.3.3", "tsx": "^4.7.0", "typescript": "^5.7.2", "vite": "^6.4.3", @@ -187,7 +192,7 @@ "recharts": "^3.0.0", "shiki": "^1.24.0", "streamdown": "^2.5.0", - "tailwindcss": "^3.4.0 || ^4.0.0" + "tailwindcss": "^4.0.0" }, "peerDependenciesMeta": { "streamdown": { diff --git a/packages/ui/src/components.ts b/packages/ui/src/components.ts index abc5e4b02..3fc9631d0 100644 --- a/packages/ui/src/components.ts +++ b/packages/ui/src/components.ts @@ -1,3 +1,9 @@ +export { + AccordionList, + type AccordionListItemContext, + type AccordionListProps, +} from "./components/AccordionList"; +export { ItemActions, type ItemActionsProps } from "./components/ItemActions"; export { Button, type ButtonProps } from "./components/button"; export { ErrorWrapper, @@ -30,6 +36,7 @@ export { buildJSONPathNode, createLazyJSONPathTree, literalSegments, + type JSONPathOrigin, type LazyJSONPathTree, type LazyJSONPathTreeOptions, } from "./components/jsonPathTree"; @@ -98,6 +105,7 @@ export { type FilterBarSearchProps, type FilterBarSelectMultiFilter, type FilterBarTextFilter, + type FilterBarWorkloadFilter, TriStateMultiSelect, type TriStateMultiSelectProps, } from "./components/FilterBar"; @@ -236,6 +244,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, @@ -281,6 +295,7 @@ export type { FieldOption, EnumDisplay, ArrayDisplay, + ArrayItemAction, ArrayItemSpec, ArrayItemSummary, ArrayItemSummaryPart, diff --git a/packages/ui/src/components/AccordionList.stories.tsx b/packages/ui/src/components/AccordionList.stories.tsx new file mode 100644 index 000000000..edf094f9c --- /dev/null +++ b/packages/ui/src/components/AccordionList.stories.tsx @@ -0,0 +1,132 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, userEvent, within } from "storybook/test"; +import { AccordionList } from "./AccordionList"; + +const meta: Meta = { + title: "Components/AccordionList", + component: AccordionList, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: + "A list of items collapsed to one row each, expanding one at a time into that item's own editor. `renderHeader` and `renderBody` are the only content the consumer supplies; the disclosure, aria pairing, arrow-key roving focus, action buttons and add row belong to the list. Every editing capability is opt-in (`allowReorder`, `allowDuplicate`, `allowRemove`, `onCreate`), so the same component serves a read-only summary list and a full editor.", + }, + }, + }, +}; + +export default meta; +type Story = StoryObj; + +type Route = { path: string; method: string; upstream: string }; + +const ROUTES: Route[] = [ + { path: "/api/v1/users", method: "GET", upstream: "users-svc:8080" }, + { path: "/api/v1/events", method: "POST", upstream: "events-svc:8080" }, +]; + +function RouteList(props: { + allowReorder?: boolean; + allowDuplicate?: boolean; + allowRemove?: boolean; + addable?: boolean; + readOnly?: boolean; + initial?: Route[]; +}) { + const { addable = true, initial = ROUTES, ...caps } = props; + const [routes, setRoutes] = useState(initial); + return ( +
+ + items={routes} + onChange={setRoutes} + summary={routes.length === 1 ? "1 route" : `${routes.length} routes`} + itemLabel={({ item }) => item.path} + addLabel="Add route" + addDescription="A route forwards one path to one upstream service." + {...(addable ? { onCreate: () => ({ path: "", method: "GET", upstream: "" }) } : {})} + {...caps} + renderHeader={({ item, index }) => ( + <> + + {item.path || `Route ${index + 1}`} + + + {item.method} · {item.upstream} + + + )} + renderBody={({ item, onChange }) => ( +
+ + +
+ )} + /> +
+ ); +} + +export const Default: Story = { + render: () => , +}; + +export const WithActions: Story = { + render: () => , +}; + +export const ReorderOnly: Story = { + render: () => , +}; + +export const ReadOnly: Story = { + render: () => , +}; + +export const Empty: Story = { + render: () => , +}; + +export const AddsAndRemoves: Story = { + render: () => , + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + const rows = () => canvas.getAllByRole("button", { expanded: false }); + + await step("starts with the two seeded routes", async () => { + await expect(canvas.getAllByRole("button", { name: /^Remove/ })).toHaveLength(2); + }); + + await step("the add row appends a route and opens it", async () => { + await userEvent.click(canvas.getByRole("button", { name: /Add route/ })); + await expect(canvas.getByRole("button", { expanded: true })).toBeInTheDocument(); + await expect(canvas.getAllByRole("button", { name: /^Remove/ })).toHaveLength(3); + }); + + await step("removing takes the named route out", async () => { + await userEvent.click(canvas.getByRole("button", { name: "Remove /api/v1/users" })); + await expect(canvas.getAllByRole("button", { name: /^Remove/ })).toHaveLength(2); + }); + + await step("only one row opens at a time", async () => { + await userEvent.click(rows()[0]!); + await expect(canvas.getAllByRole("button", { expanded: true })).toHaveLength(1); + }); + }, +}; diff --git a/packages/ui/src/components/AccordionList.test.tsx b/packages/ui/src/components/AccordionList.test.tsx new file mode 100644 index 000000000..fdef7dd50 --- /dev/null +++ b/packages/ui/src/components/AccordionList.test.tsx @@ -0,0 +1,272 @@ +import { useState } from "react"; +import { fireEvent, render, screen, within } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { AccordionList, type AccordionListProps } from "./AccordionList"; + +type Route = { path: string; upstream: string }; + +const SAMPLE: Route[] = [ + { path: "/users", upstream: "users-svc" }, + { path: "/events", upstream: "events-svc" }, +]; + +// A stateful host, because expansion is keyed by index: proving that an open row +// follows its item through a move requires the list to actually reorder, which a +// stateless render can never show. +function Harness({ + initial = SAMPLE, + onChange, + ...rest +}: Partial> & { + initial?: Route[]; + onChange?: (next: Route[]) => void; +}) { + const [items, setItems] = useState(initial); + return ( + + items={items} + onChange={(next) => { + setItems(next); + onChange?.(next); + }} + itemLabel={({ item }) => item.path} + renderHeader={({ item }) => {item.path}} + renderBody={({ item, onChange: onItem }) => ( + onItem({ ...item, upstream: e.target.value })} + /> + )} + {...(rest as Partial>)} + /> + ); +} + +function headers(): HTMLElement[] { + return screen.getAllByRole("button").filter((b) => b.hasAttribute("aria-expanded")); +} + +function headerFor(path: string): HTMLElement { + const found = headers().find((h) => h.textContent?.includes(path)); + if (!found) throw new Error(`no accordion header for "${path}"`); + return found; +} + +describe("AccordionList", () => { + it("does not auto-expand the first row", () => { + render(); + expect(headers()).toHaveLength(2); + expect(headers().map((header) => header.getAttribute("aria-expanded"))).toEqual([ + "false", + "false", + ]); + expect(screen.queryByLabelText("Upstream for /users")).toBeNull(); + }); + + it("alternates the row backgrounds", () => { + const { container } = render(); + const rows = container.querySelectorAll("[data-accordion-row]"); + + expect(rows).toHaveLength(2); + expect(rows[0]).toHaveClass("bg-card"); + expect(rows[1]).toHaveClass("bg-muted/20"); + }); + + it("expands one row at a time", () => { + render(); + fireEvent.click(headerFor("/users")); + expect(screen.getByLabelText("Upstream for /users")).toBeInTheDocument(); + + fireEvent.click(headerFor("/events")); + expect(screen.queryByLabelText("Upstream for /users")).toBeNull(); + expect(screen.getByLabelText("Upstream for /events")).toBeInTheDocument(); + }); + + it("closes an open row on its own header", () => { + render(); + fireEvent.click(headerFor("/users")); + fireEvent.click(headerFor("/users")); + expect(headerFor("/users")).toHaveAttribute("aria-expanded", "false"); + expect(screen.queryByLabelText("Upstream for /users")).toBeNull(); + }); + + it("pairs each header with the panel it controls", () => { + render(); + const header = headerFor("/users"); + fireEvent.click(header); + const panel = document.getElementById(header.getAttribute("aria-controls")!); + expect(panel).toHaveAttribute("aria-labelledby", header.id); + }); + + it("offers no actions until asked", () => { + render(); + expect(screen.queryByRole("button", { name: /^(Move|Duplicate|Remove) / })).toBeNull(); + expect(screen.queryByRole("button", { name: /^Add/ })).toBeNull(); + }); + + it("offers only the actions each allow flag turns on", () => { + render(); + expect(screen.getByRole("button", { name: "Remove /users" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Move /users down" })).toBeNull(); + expect(screen.queryByRole("button", { name: "Duplicate /users" })).toBeNull(); + }); + + it("never nests the row actions inside the disclosure button", () => { + // Interactive content inside a + )} + />, + ); + expect(screen.getByRole("button", { name: "Test /users" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Remove /users" })).toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/components/AccordionList.tsx b/packages/ui/src/components/AccordionList.tsx new file mode 100644 index 000000000..97e7edf92 --- /dev/null +++ b/packages/ui/src/components/AccordionList.tsx @@ -0,0 +1,464 @@ +import { + useEffect, + useRef, + useState, + type KeyboardEvent, + type MutableRefObject, + type ReactNode, +} from "react"; +import { duplicateIndex, moveItem, removeIndex, setIndex } from "../lib/collections"; +import type { SizeToken } from "../lib/size"; +import { cn } from "../lib/utils"; +import { Icon } from "../data/Icon"; +import { UiAdd, UiChevronDown, UiChevronRight, UiDotsVertical } from "../icons"; +import { ItemActions } from "./ItemActions"; +import { labelSizeClass } from "./json-schema-form-size"; + +// AccordionList renders a list of items as one-line rows, expanding one at a +// time into that item's own editor. It exists because a ten-field item stacked +// in full costs ~700px of screen each, and a column of them says nothing about +// which item is which. +// +// It knows nothing about what an item IS: `renderHeader` fills the collapsed +// row, `renderBody` fills the open panel, and everything structural — the +// disclosure, the aria pairing, roving arrow-key focus, the mutation buttons, +// the add row — belongs to the list. Every editing capability is opt-in, so the +// same component serves a read-only summary list and a full editor. +// +// "Add" is the list's own last row rather than a button beside it: the +// affordance sits exactly where the next item will appear, and at zero items +// that same row IS the empty state — one control, no separate screen. + +/** Handed to every slot, describing one row. */ +export interface AccordionListItemContext { + item: T; + index: number; + open: boolean; + /** Replaces this item in place, committing the whole list through `onChange`. */ + onChange: (next: T) => void; + /** The ids the disclosure/panel pair is wired with. */ + headerId: string; + panelId: string; +} + +export interface AccordionListProps { + items: T[]; + /** Receives the whole next list for every add, remove, duplicate, move or item edit. */ + onChange?: (next: T[]) => void; + + /** Row content, rendered inside the disclosure button after the chevron. */ + renderHeader: (ctx: AccordionListItemContext) => ReactNode; + /** Panel content. Rendered only while the row is open — never hidden with CSS. */ + renderBody: (ctx: AccordionListItemContext) => ReactNode; + /** Extra per-row actions, rendered before the built-in ones. */ + renderActions?: (ctx: AccordionListItemContext) => ReactNode; + /** Content above the list — typically the item count. */ + summary?: ReactNode; + /** Names an item in its actions' accessible labels. Defaults to `Item `. */ + itemLabel?: (ctx: { item: T; index: number }) => string; + + /** Offer the up/down reorder buttons. */ + allowReorder?: boolean; + /** + * Offer a grab handle at the head of every row, and drag-to-reorder across + * the list. Arrow keys on a focused handle move the row too, so the gesture + * is not mouse-only. + */ + allowDrag?: boolean; + /** + * Whether a row takes part in dragging — as a source and as a destination. + * Defaults to every row. A list holding rows that have no position of their + * own (a filtered-out item, a soft-deleted one) says so here. + */ + canDrag?: (ctx: { item: T; index: number }) => boolean; + /** + * Commits a completed drag or handle keypress. Defaults to the same move the + * reorder buttons perform; a list whose order lives outside `items` (a + * filtered view of a longer list) reorders the source through this instead. + */ + onReorder?: (from: number, to: number) => void; + /** Offer the duplicate button. */ + allowDuplicate?: boolean; + /** Offer the remove button. */ + allowRemove?: boolean; + /** Copy override for duplicate. Defaults to a one-level clone. */ + cloneItem?: (item: T) => T; + /** + * Reveal the per-row actions on hover/focus (the default). Pass false where an + * action carries state the row has to show at rest — a visibility toggle whose + * glyph says whether the item is hidden answers a question, not just offers a + * click. + */ + revealActions?: boolean; + + /** Seeds a new item. Supplying it is what adds the trailing add row. */ + onCreate?: () => T; + /** Add-row text. Defaults to "Add item". */ + addLabel?: string; + /** Add-row copy; grows into the empty state at zero items. */ + addDescription?: string; + + /** Controlled open row — an index, or null for none. */ + expanded?: number | null; + /** Initially open row for uncontrolled usage. Defaults to none. */ + defaultExpanded?: number | null; + onExpandedChange?: (index: number | null) => void; + + /** Hides the add row and every per-row action. Rows still open. */ + readOnly?: boolean; + size?: SizeToken; + /** Stem for the generated header/panel ids. */ + idPrefix?: string; + /** Overrides the per-row id stem (e.g. with a JSON-Pointer instance path). */ + itemId?: (index: number) => string; + /** Selector focused inside a freshly added row's panel. */ + focusSelector?: string; + className?: string; + listClassName?: string; + rowClassName?: string; + bodyClassName?: string; +} + +const DEFAULT_FOCUS_SELECTOR = "[data-autofocus], input, select, textarea"; + +export function AccordionList({ + items, + onChange, + renderHeader, + renderBody, + renderActions, + summary, + itemLabel, + allowReorder = false, + allowDrag = false, + canDrag, + onReorder, + allowDuplicate = false, + allowRemove = false, + revealActions = true, + cloneItem, + onCreate, + addLabel = "Add item", + addDescription, + expanded: expandedProp, + defaultExpanded = null, + onExpandedChange, + readOnly = false, + size = "md", + idPrefix = "accordion", + itemId, + focusSelector = DEFAULT_FOCUS_SELECTOR, + className, + listClassName, + rowClassName, + bodyClassName, +}: AccordionListProps) { + // Which row is open is transient view state, deliberately local and never + // persisted. The INDEX is the key, not the item: an editor typically replaces + // the item object on every keystroke, so any identity-based map would die on + // the first character typed. That makes it the mutators' job to keep this + // pointing at the same row. + const isControlled = expandedProp !== undefined; + const [innerExpanded, setInnerExpanded] = useState(defaultExpanded); + const expanded = isControlled ? expandedProp : innerExpanded; + + // The in-flight drag: the row being dragged and the row the pointer is over. + const [dragFrom, setDragFrom] = useState(null); + const [dragOver, setDragOver] = useState(null); + + const rowRefs = useRef>([]); + const addRef = useRef(null); + const pendingFocus = useRef<{ kind: "panel" | "row" | "add"; index: number } | null>(null); + + useEffect(() => { + const pending = pendingFocus.current; + if (!pending) return; + pendingFocus.current = null; + if (pending.kind === "add") { + addRef.current?.focus(); + return; + } + const row = rowRefs.current[pending.index]; + if (pending.kind === "row") { + row?.focus(); + return; + } + // Land in the new item's first editable control, so adding an item leaves + // the caret where the author is about to type. + const panel = document.getElementById(panelId(pending.index)); + (panel?.querySelector(focusSelector) ?? row)?.focus(); + }); + + function rowId(index: number): string { + return itemId ? itemId(index) : `${idPrefix}-${index}`; + } + function panelId(index: number): string { + return `${rowId(index)}-panel`; + } + function headerId(index: number): string { + return `${rowId(index)}-header`; + } + + function expand(next: number | null) { + if (!isControlled) setInnerExpanded(next); + onExpandedChange?.(next); + } + // A mutator moves the open row without the caller having asked to expand + // anything, so it maps the current index rather than setting an absolute one. + function reindexExpanded(map: (open: number | null) => number | null) { + const next = map(expanded); + if (next !== expanded) expand(next); + } + + function commit(next: T[]) { + onChange?.(next); + } + + function add() { + if (!onCreate) return; + pendingFocus.current = { kind: "panel", index: items.length }; + expand(items.length); + commit([...items, onCreate()]); + } + + function remove(index: number) { + reindexExpanded((open) => + open === index ? null : open !== null && open > index ? open - 1 : open, + ); + pendingFocus.current = + items.length === 1 + ? { kind: "add", index: 0 } + : { kind: "row", index: Math.max(index - 1, 0) }; + commit(removeIndex(items, index)); + } + + function duplicate(index: number) { + reindexExpanded((open) => (open !== null && open > index ? open + 1 : open)); + pendingFocus.current = { kind: "row", index: index + 1 }; + commit(duplicateIndex(items, index, cloneItem)); + } + + /** Where a drag would land, so the row can draw the insertion line. */ + function dropEdge(index: number): "top" | "bottom" | null { + if (dragFrom === null || dragOver !== index || dragFrom === index) return null; + return dragFrom < index ? "bottom" : "top"; + } + + function endDrag() { + setDragFrom(null); + setDragOver(null); + } + + function reorder(from: number, to: number) { + if (from === to) return; + if (onReorder) onReorder(from, to); + else move(from, to); + } + + function move(index: number, to: number) { + if (to < 0 || to >= items.length) return; + // Follow the item, not the slot — otherwise moving an open row silently + // expands whichever row took its place. + reindexExpanded((open) => (open === index ? to : open === to ? index : open)); + pendingFocus.current = { kind: "row", index: to }; + commit(moveItem(items, index, to)); + } + + // Roving focus across the headers, with the add row as the final stop. + function handleKeyDown(e: KeyboardEvent, index: number) { + const stops = rowRefs.current.length; + const focusAt = (i: number) => { + e.preventDefault(); + (i >= stops ? addRef.current : rowRefs.current[i])?.focus(); + }; + if (e.key === "ArrowDown") focusAt(Math.min(index + 1, stops)); + else if (e.key === "ArrowUp" && index > 0) focusAt(index - 1); + else if (e.key === "Home") focusAt(0); + else if (e.key === "End") focusAt(stops); + } + + return ( +
+ {summary !== undefined && ( +

{summary}

+ )} +
+ {items.map((item, i) => { + const open = expanded === i; + const slotCtx: AccordionListItemContext = { + item, + index: i, + open, + onChange: (next) => commit(setIndex(items, i, next)), + headerId: headerId(i), + panelId: panelId(i), + }; + const label = itemLabel?.({ item, index: i }) ?? `Item ${i + 1}`; + const actions = renderActions?.(slotCtx); + const dragEnabled = allowDrag && !readOnly && (canDrag?.({ item, index: i }) ?? true); + const droppable = dragEnabled && dragFrom !== null && dragFrom !== i; + const edge = droppable ? dropEdge(i) : null; + return ( +
+ {/* The row is a plain element, NOT a button: the actions beside it + are buttons, and nesting interactive content is invalid DOM + with undefined click targeting. Only the disclosure toggles. */} +
{ + if (!droppable) return; + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + setDragOver(i); + }} + onDrop={(e) => { + if (!droppable || dragFrom === null) return; + e.preventDefault(); + reorder(dragFrom, i); + endDrag(); + }} + className={cn( + "flex w-full items-center gap-2 px-3 py-2 hover:bg-accent/40", + i % 2 === 0 ? "bg-card" : "bg-muted/20", + open && "bg-accent/30", + dragFrom === i && "opacity-40", + edge === "top" && "shadow-[inset_0_2px_0_0_var(--color-primary)]", + edge === "bottom" && "shadow-[inset_0_-2px_0_0_var(--color-primary)]", + rowClassName, + )} + > + {allowDrag && !readOnly && ( + + )} + + {!readOnly && (allowReorder || allowDuplicate || allowRemove || actions) && ( + move(i, to) } : {})} + {...(allowDuplicate ? { onDuplicate: () => duplicate(i) } : {})} + {...(allowRemove ? { onRemove: () => remove(i) } : {})} + /> + )} +
+ {open && ( +
+ {renderBody(slotCtx)} +
+ )} +
+ ); + })} + {!readOnly && onCreate && ( + handleKeyDown(e, items.length)} + {...(addDescription ? { copy: addDescription } : {})} + /> + )} +
+
+ ); +} + +// AddItemRow is the list's own final row. At zero items it grows to carry the +// explanation of what an item is — so the empty state is the same control the +// author will use, not a separate screen they have to leave. +function AddItemRow({ + label, + copy, + empty, + onAdd, + onKeyDown, + buttonRef, +}: { + label: string; + copy?: string; + empty: boolean; + onAdd: () => void; + onKeyDown: (e: KeyboardEvent) => void; + buttonRef: MutableRefObject; +}) { + return ( + + ); +} diff --git a/packages/ui/src/components/Combobox.stories.tsx b/packages/ui/src/components/Combobox.stories.tsx index 1e7c97e2a..5bbd729ae 100644 --- a/packages/ui/src/components/Combobox.stories.tsx +++ b/packages/ui/src/components/Combobox.stories.tsx @@ -1,6 +1,10 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { useState } from "react"; -import { Combobox, type ComboboxProps } from "./Combobox"; +import { + Combobox, + type ComboboxSingleProps, + type ComboboxTriStateMode, +} from "./Combobox"; import { Modal } from "../overlay/Modal"; const DATABASE_OPTIONS = [ @@ -19,11 +23,42 @@ function ComboboxShowcase() { ); } -function ComboboxPlayground({ value: initialValue, ...args }: ComboboxProps) { - const [value, setValue] = useState(initialValue); +// The playground drives every mode from one panel, so the value has to change +// SHAPE with the mode: a string, a list of strings, or a record of include / +// exclude modes. One shared `value` is what used to break this story — flipping +// `multiple` on left the string behind and the closed label mapped over it. +type PlaygroundArgs = Omit< + ComboboxSingleProps, + "value" | "onChange" | "multiple" | "tristate" +> & { + multiple?: boolean; + tristate?: boolean; + variant?: "default" | "tags"; +}; + +function ComboboxPlayground({ + multiple, + tristate, + variant, + ...args +}: PlaygroundArgs) { + const [single, setSingle] = useState(""); + const [values, setValues] = useState([]); + const [modes, setModes] = useState>({}); + // Tristate is inherently multi-valued, so it carries `multiple` along rather + // than rendering an impossible combination. + const value = tristate ? modes : multiple ? values : single; + const variantProp = variant ? { variant } : {}; + return ( -
- +
+ {tristate ? ( + + ) : multiple ? ( + + ) : ( + + )}
value={JSON.stringify(value)}
@@ -45,6 +80,15 @@ const meta = { }, argTypes: { placeholder: { control: "text", table: { category: "Appearance" } }, + // The mode props, so the Playground can be driven from the panel (and from + // the URL) instead of guessing at undeclared args. + multiple: { control: "boolean", table: { category: "Mode" } }, + tristate: { control: "boolean", table: { category: "Mode" } }, + variant: { + control: "inline-radio", + options: ["default", "tags"], + table: { category: "Mode" }, + }, disabled: { control: "boolean", table: { category: "Behavior" } }, required: { control: "boolean", table: { category: "Behavior" } }, loading: { control: "boolean", table: { category: "Behavior" } }, @@ -60,6 +104,14 @@ type Story = StoryObj; export const Default: Story = {}; export const Playground: Story = { + parameters: { + docs: { + description: { + story: + "Every mode from one panel: **Mode → multiple / tristate / variant**. The value shape follows the mode — a string, a `string[]`, or a `Record` — and the box below shows what the control actually commits.", + }, + }, + }, render: (args) => , }; @@ -261,6 +313,40 @@ export const Tags: Story = { }, }; +export const TriStateTags: Story = { + parameters: { + docs: { + description: { + story: + "Tristate keeps a `Record` rather than a list, and `variant: \"tags\"` shows that record in the field: one pill per value, coloured by its mode. Clicking a pill flips include ↔ exclude; its close button returns the value to neutral (dropping it from the record). Without the variant the same control collapses to a `+n -n` summary — what the FilterBar's multi filter uses.", + }, + }, + }, + render: () => { + const [modes, setModes] = useState>({ + PrimaryDB: "include", + ArchiveDB: "exclude", + }); + return ( +
+ +
+ value={JSON.stringify(modes)} +
+
+ ); + }, +}; + const CLOUD_OPTIONS = [ { value: "aws", label: "AWS", icon: 🟧 }, { value: "gcp", label: "Google Cloud", icon: 🔵 }, diff --git a/packages/ui/src/components/Combobox.tags.test.tsx b/packages/ui/src/components/Combobox.tags.test.tsx index 5d33851b0..f183ab638 100644 --- a/packages/ui/src/components/Combobox.tags.test.tsx +++ b/packages/ui/src/components/Combobox.tags.test.tsx @@ -1,6 +1,6 @@ import { fireEvent, render, screen } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; -import { Combobox } from "./Combobox"; +import { Combobox, type ComboboxTriStateMode } from "./Combobox"; const OPTIONS = [ { value: "PrimaryDB", label: "PrimaryDB" }, @@ -97,3 +97,135 @@ describe("Combobox tags variant", () => { ).not.toBeInTheDocument(); }); }); + +// `separators` is what lets a tag list be typed or pasted the way people write +// one — comma-separated — instead of one Enter at a time. +describe("Combobox tags variant, separators", () => { + function renderWithSeparators(value: string[] = []) { + const onChange = vi.fn(); + render( + , + ); + return { onChange, input: screen.getByRole("combobox") }; + } + + it("commits the typed text on a separator key", () => { + const { onChange, input } = renderWithSeparators(["PrimaryDB"]); + fireEvent.focus(input); + fireEvent.change(input, { target: { value: "custom-db" } }); + fireEvent.keyDown(input, { key: "," }); + expect(onChange).toHaveBeenCalledWith(["PrimaryDB", "custom-db"]); + expect(input).toHaveValue(""); + }); + + it("splits pasted text on separators and newlines, in one commit", () => { + const { onChange, input } = renderWithSeparators(); + fireEvent.paste(input, { + clipboardData: { getData: () => "IVS, ArchiveDB\ncustom-db" }, + }); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith(["IVS", "ArchiveDB", "custom-db"]); + }); + + it("drops blanks and values already selected", () => { + const { onChange, input } = renderWithSeparators(["IVS"]); + fireEvent.paste(input, { + clipboardData: { getData: () => "IVS,, ArchiveDB ,ArchiveDB," }, + }); + expect(onChange).toHaveBeenCalledWith(["IVS", "ArchiveDB"]); + }); + + it("leaves an ordinary paste alone", () => { + const { onChange, input } = renderWithSeparators(); + fireEvent.paste(input, { clipboardData: { getData: () => "ArchiveDB" } }); + expect(onChange).not.toHaveBeenCalled(); + }); + + it("ignores separators when custom values are not allowed", () => { + const onChange = vi.fn(); + render( + , + ); + const input = screen.getByRole("combobox"); + fireEvent.focus(input); + fireEvent.change(input, { target: { value: "nope" } }); + fireEvent.keyDown(input, { key: "," }); + expect(onChange).not.toHaveBeenCalled(); + }); +}); + +// Tristate keeps a mode per value rather than a list, so its pills have to say +// WHICH mode — and removing one means "back to neutral", not "splice a list". +describe("Combobox tags variant, tristate", () => { + const MODES: Record = { + PrimaryDB: "include", + ArchiveDB: "exclude", + }; + + function renderTriState(value = MODES) { + const onChange = vi.fn(); + render( + , + ); + return { onChange }; + } + + it("renders one pill per value, marked with its mode", () => { + renderTriState(); + expect(screen.getByTitle("PrimaryDB included")).toBeInTheDocument(); + expect(screen.getByTitle("ArchiveDB excluded")).toBeInTheDocument(); + // The summary the non-tags tristate shows instead. + expect(screen.queryByDisplayValue("+1 -1")).not.toBeInTheDocument(); + }); + + it("flips a pill between include and exclude", () => { + const { onChange } = renderTriState(); + fireEvent.click(screen.getByTitle("PrimaryDB included")); + expect(onChange).toHaveBeenCalledWith({ + PrimaryDB: "exclude", + ArchiveDB: "exclude", + }); + }); + + it("returns a value to neutral when its pill is removed", () => { + const { onChange } = renderTriState(); + fireEvent.click(screen.getByRole("button", { name: "Remove ArchiveDB" })); + expect(onChange).toHaveBeenCalledWith({ PrimaryDB: "include" }); + }); + + it("drops the last mode with Backspace on an empty query", () => { + const { onChange } = renderTriState(); + const input = screen.getByRole("combobox"); + fireEvent.focus(input); + fireEvent.keyDown(input, { key: "Backspace" }); + expect(onChange).toHaveBeenCalledWith({ PrimaryDB: "include" }); + }); + + it("clears every mode at once", () => { + const { onChange } = renderTriState(); + fireEvent.click(screen.getByRole("button", { name: "Clear" })); + expect(onChange).toHaveBeenCalledWith({}); + }); +}); diff --git a/packages/ui/src/components/Combobox.tsx b/packages/ui/src/components/Combobox.tsx index a643722af..82f2a1c36 100644 --- a/packages/ui/src/components/Combobox.tsx +++ b/packages/ui/src/components/Combobox.tsx @@ -3,6 +3,7 @@ import { useMemo, useRef, useState, + type ClipboardEvent, type KeyboardEvent, } from "react"; import { cn } from "../lib/utils"; @@ -22,6 +23,7 @@ import type { import { createComboboxCustomEntry, multipleComboboxLabel, + splitOnComboboxSeparators, withSelectedComboboxOptions, } from "./combobox-utils"; @@ -60,10 +62,11 @@ export function Combobox(props: ComboboxProps) { } = props; const multiple = props.multiple === true; const tristate = props.multiple === true && props.tristate === true; - const tags = - props.multiple === true && - props.tristate !== true && - props.variant === "tags"; + const tags = props.multiple === true && props.variant === "tags"; + const separators = + props.multiple === true && props.tristate !== true + ? (props.separators ?? []) + : []; const modes = useMemo>( () => props.multiple === true && props.tristate === true ? props.value : {}, @@ -252,6 +255,35 @@ export function Combobox(props: ComboboxProps) { emit([value]); } + // Adds several values at once (a separator key commits one, a paste commits + // many). Batched deliberately: emitCustomValue per token would each read the + // same stale selectedValues, so only the last would survive. + function addValues(raw: string[]) { + const next = [...selectedValues]; + for (const entry of raw) { + const trimmed = entry.trim(); + if (!trimmed || next.includes(trimmed)) continue; + const existing = options.find((option) => option.value === trimmed); + const candidate = existing ?? onNew?.(trimmed) ?? { value: trimmed, label: trimmed }; + const created = existing ? existing.value : createOptionValue(candidate); + if (created !== null && !next.includes(created)) next.push(created); + } + if (next.length !== selectedValues.length) emit(next); + setQuery(""); + setHighlighted(-1); + } + + // A pasted list arrives as one string; without this it would land as a single + // nonsense tag reading "80, 443". + function onPaste(e: ClipboardEvent) { + if (!tags || !allowCustomValue || separators.length === 0) return; + const text = e.clipboardData.getData("text"); + const parts = splitOnComboboxSeparators(text, separators); + if (parts.length < 2) return; + e.preventDefault(); + addValues(parts); + } + function commitAndClose() { const trimmed = query.trim(); if (customEntry) { @@ -327,8 +359,11 @@ export function Combobox(props: ComboboxProps) { inputRef.current?.focus(); } + // A tristate pill is removed by returning its value to neutral — there is no + // separate "selected" list to splice, the mode record IS the selection. function removeTag(value: string) { - emit(selectedValues.filter((selected) => selected !== value)); + if (tristate) setMode(value, "neutral"); + else emit(selectedValues.filter((selected) => selected !== value)); inputRef.current?.focus(); } @@ -340,6 +375,11 @@ export function Combobox(props: ComboboxProps) { } function onKeyDown(e: KeyboardEvent) { + if (tags && allowCustomValue && separators.includes(e.key)) { + e.preventDefault(); + addValues([query]); + return; + } if ( tags && e.key === "Backspace" && @@ -347,7 +387,12 @@ export function Combobox(props: ComboboxProps) { selectedValues.length > 0 ) { e.preventDefault(); - emit(selectedValues.slice(0, -1)); + const last = selectedValues[selectedValues.length - 1]; + if (tristate) { + if (last) setMode(last, "neutral"); + } else { + emit(selectedValues.slice(0, -1)); + } } else if (e.key === "ArrowDown") { e.preventDefault(); if (!open) { @@ -426,6 +471,7 @@ export function Combobox(props: ComboboxProps) { }} onKeyDown={onKeyDown} onOpen={openMenu} + onPaste={onPaste} onRemoveTag={removeTag} onToggle={() => { if (open) { @@ -441,6 +487,8 @@ export function Combobox(props: ComboboxProps) { showClear={showClear} size={size} suffix={suffix} + tagModes={tristate ? modes : undefined} + onSetTagMode={setMode} tagValues={selectedValues} tags={tags} /> @@ -450,6 +498,7 @@ export function Combobox(props: ComboboxProps) { filtered={filtered} floatingZ={floatingZ} footer={footer} + hasOptions={options.length > 0} highlighted={highlighted} isSelected={isSelected} listId={listId} diff --git a/packages/ui/src/components/ComboboxControl.tsx b/packages/ui/src/components/ComboboxControl.tsx index 8a0f08ab7..25bb14f52 100644 --- a/packages/ui/src/components/ComboboxControl.tsx +++ b/packages/ui/src/components/ComboboxControl.tsx @@ -1,4 +1,9 @@ -import type { KeyboardEvent, ReactNode, RefObject } from "react"; +import type { + ClipboardEvent, + KeyboardEvent, + ReactNode, + RefObject, +} from "react"; import { cn } from "../lib/utils"; import { controlMinHeightClass, @@ -6,7 +11,7 @@ import { labelSizeClass, type FormSize, } from "./json-schema-form-size"; -import type { ComboboxOption } from "./combobox-types"; +import type { ComboboxOption, ComboboxTriStateMode } from "./combobox-types"; import { comboboxLabelPadding } from "./combobox-utils"; import { ComboboxActions } from "./ComboboxActions"; import { ComboboxTags } from "./ComboboxTags"; @@ -33,7 +38,9 @@ export function ComboboxControl({ onInput, onKeyDown, onOpen, + onPaste, onRemoveTag, + onSetTagMode, onToggle, open, options, @@ -41,6 +48,7 @@ export function ComboboxControl({ showClear, size, suffix, + tagModes, tagValues, tags, }: { @@ -65,7 +73,9 @@ export function ComboboxControl({ onInput: (value: string) => void; onKeyDown: (event: KeyboardEvent) => void; onOpen: () => void; + onPaste: (event: ClipboardEvent) => void; onRemoveTag: (value: string) => void; + onSetTagMode: (value: string, mode: string) => void; onToggle: () => void; open: boolean; options: ComboboxOption[]; @@ -73,6 +83,8 @@ export function ComboboxControl({ showClear: boolean; size: FormSize | undefined; suffix: ReactNode; + /** Per-value include/exclude modes; set only in tristate mode. */ + tagModes: Record | undefined; tagValues: string[]; tags: boolean; }) { @@ -118,7 +130,9 @@ export function ComboboxControl({ {tags && ( @@ -146,6 +160,7 @@ export function ComboboxControl({ onFocus={onOpen} onClick={onOpen} onKeyDown={onKeyDown} + onPaste={onPaste} className={cn( tags ? "min-w-28 flex-1 bg-transparent px-1 py-1 text-foreground outline-none" diff --git a/packages/ui/src/components/ComboboxMenu.tsx b/packages/ui/src/components/ComboboxMenu.tsx index 2b96c3789..30eb17a61 100644 --- a/packages/ui/src/components/ComboboxMenu.tsx +++ b/packages/ui/src/components/ComboboxMenu.tsx @@ -15,6 +15,7 @@ export function ComboboxMenu({ filtered, floatingZ, footer, + hasOptions, highlighted, isSelected, listId, @@ -32,6 +33,8 @@ export function ComboboxMenu({ filtered: ComboboxOption[]; floatingZ: number; footer: ReactNode; + /** Whether the control has an option set at all (before filtering). */ + hasOptions: boolean; highlighted: number; isSelected: (value: string) => boolean; listId: string | undefined; @@ -69,7 +72,10 @@ export function ComboboxMenu({ Loading…
)} - {!loading && filtered.length === 0 && !customEntry && ( + {/* "No results" answers "did my query match anything?" — a control with + no option set at all (a free tag list) was never asked the question, so + it says nothing rather than reporting an empty search. */} + {!loading && filtered.length === 0 && !customEntry && hasOptions && (
No results
diff --git a/packages/ui/src/components/ComboboxTags.tsx b/packages/ui/src/components/ComboboxTags.tsx index 74e78c133..6397dafe4 100644 --- a/packages/ui/src/components/ComboboxTags.tsx +++ b/packages/ui/src/components/ComboboxTags.tsx @@ -1,21 +1,68 @@ import { Icon } from "../data/Icon"; +import { FilterPill } from "../data/FilterPill"; import { UiClose } from "../icons"; -import type { ComboboxOption } from "./combobox-types"; +import type { ComboboxOption, ComboboxTriStateMode } from "./combobox-types"; export function ComboboxTags({ disabled, + modes, onRemove, + onSetMode, options, values, }: { disabled: boolean | undefined; + /** Per-value include/exclude modes; set only in tristate mode. */ + modes: Record | undefined; onRemove: (value: string) => void; + onSetMode: (value: string, mode: string) => void; options: ComboboxOption[]; values: string[]; }) { return values.map((value, index) => { const option = options.find((entry) => entry.value === value); const label = option?.selectedLabel ?? option?.label ?? value; + const mode = modes?.[value]; + const remove = !disabled && ( + + ); + + // A tristate pill carries its mode in the same colours the menu rows use. + // The pill body flips include ↔ exclude; returning to neutral is what the + // close button means, so the two affordances never overlap. + if (mode) { + return ( + + + onSetMode(value, mode === "include" ? "exclude" : "include"), + })} + /> + {remove} + + ); + } + return ( {label} - {!disabled && ( - - )} + {remove} ); }); diff --git a/packages/ui/src/components/DaemonSetIcon.tsx b/packages/ui/src/components/DaemonSetIcon.tsx new file mode 100644 index 000000000..d1c499475 --- /dev/null +++ b/packages/ui/src/components/DaemonSetIcon.tsx @@ -0,0 +1,21 @@ +import { UiStack } from "../icons"; + +type DaemonSetIconProps = { + className?: string; + title?: string; + "aria-label"?: string; +}; + +export function DaemonSetIcon({ + className, + title, + "aria-label": ariaLabel, +}: DaemonSetIconProps) { + return ( + + ); +} diff --git a/packages/ui/src/components/DateField.tsx b/packages/ui/src/components/DateField.tsx index 62f16d319..dea313b08 100644 --- a/packages/ui/src/components/DateField.tsx +++ b/packages/ui/src/components/DateField.tsx @@ -1,4 +1,4 @@ -import { forwardRef, type InputHTMLAttributes } from "react"; +import { forwardRef, type InputHTMLAttributes, type ReactNode } from "react"; import { DatePicker } from "./DatePicker"; import { DateTimePicker } from "./DateTimePicker"; @@ -6,7 +6,9 @@ export type DateFieldMode = "date" | "datetime"; export type DateFieldProps = Omit< InputHTMLAttributes, - "type" | "value" | "onChange" + // `prefix` is a global HTML attribute typed as string; omit it so our + // ReactNode adornment prop below is not intersected down to `string & ReactNode`. + "type" | "value" | "onChange" | "prefix" > & { /** Selects native date input or date-time input behavior. */ mode?: DateFieldMode; @@ -22,6 +24,10 @@ export type DateFieldProps = Omit< buttonClassName?: string; /** Accessible label for the calendar/open button. */ openButtonLabel?: string; + /** Trailing in-field adornment, rendered left of the calendar button. */ + suffix?: ReactNode; + /** Leading in-field adornment, rendered at the left edge of the input. */ + prefix?: ReactNode; }; export const DateField = forwardRef( diff --git a/packages/ui/src/components/DatePicker.test.tsx b/packages/ui/src/components/DatePicker.test.tsx index fcf17f9e5..1b45b53aa 100644 --- a/packages/ui/src/components/DatePicker.test.tsx +++ b/packages/ui/src/components/DatePicker.test.tsx @@ -39,6 +39,37 @@ describe("DatePicker", () => { expect(onChange).toHaveBeenCalledWith("2026-04-21"); }); + // The adornment slots `DateTimePicker` already offers: a consumer owns the + // node, the picker only positions it — left of the calendar button, so the + // two never sit on top of each other. + it("renders a trailing adornment inside the control, clear of the calendar button", () => { + render( + Mark} + />, + ); + + const adornment = screen.getByRole("button", { name: "Mark" }).parentElement; + expect(adornment?.className).toContain("right-7"); + expect(adornment?.closest("[data-jsf-control]")).not.toBeNull(); + }); + + it("reserves room for a leading adornment so it cannot overlap the date", () => { + render( + UTC} + />, + ); + + expect(screen.getByLabelText("Selected date").className).toContain("pl-8"); + }); + it("opens the native picker affordance", () => { render(); diff --git a/packages/ui/src/components/DatePicker.tsx b/packages/ui/src/components/DatePicker.tsx index fb7cd8869..a979ccd9e 100644 --- a/packages/ui/src/components/DatePicker.tsx +++ b/packages/ui/src/components/DatePicker.tsx @@ -1,11 +1,13 @@ -import { forwardRef, useRef, type InputHTMLAttributes } from "react"; +import { forwardRef, useRef, type InputHTMLAttributes, type ReactNode } from "react"; import { Icon } from "../data/Icon"; import { UiCalendar } from "../icons"; import { cn } from "../lib/utils"; export type DatePickerProps = Omit< InputHTMLAttributes, - "type" | "value" | "onChange" + // `prefix` is a global HTML attribute typed as string; omit it so our + // ReactNode adornment prop below is not intersected down to `string & ReactNode`. + "type" | "value" | "onChange" | "prefix" > & { value?: string; onChange?: (value: string) => void; @@ -13,6 +15,10 @@ export type DatePickerProps = Omit< inputClassName?: string; buttonClassName?: string; openButtonLabel?: string; + /** Trailing in-field adornment, rendered left of the calendar button. */ + suffix?: ReactNode; + /** Leading in-field adornment, rendered at the left edge of the input. */ + prefix?: ReactNode; }; export const DatePicker = forwardRef( @@ -24,6 +30,8 @@ export const DatePicker = forwardRef( inputClassName, buttonClassName, openButtonLabel = "Open date picker", + suffix, + prefix, ...props }, ref, @@ -40,7 +48,8 @@ export const DatePicker = forwardRef( } return ( -
+
+ {prefix &&
{prefix}
} ( className={cn( "h-control-h w-full rounded-md border border-input bg-background px-control-px pr-8 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring", "[&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none", + prefix && "pl-8", inputClassName, )} onChange={(event) => onChange?.(event.target.value)} /> + {suffix && ( +
{suffix}
+ )} void; + loadWorkloads: ( + kinds: WorkloadKind[], + namespace?: string, + ) => Promise>>; + kinds?: WorkloadKind[]; + /** + * Render the sole loaded workload as plain text rather than a picker. See + * {@link WorkloadPickerProps.collapseSingleOption}. + */ + collapseSingleOption?: boolean; + disabled?: boolean; + className?: string; +}; + export type FilterBarSelectMultiFilter = { key: string; /** Renders a compact multi-select dropdown. */ @@ -295,6 +320,7 @@ export type FilterBarFilter = | FilterBarLookupMultiFilter | FilterBarMultiFilter | FilterBarNestedMultiFilter + | FilterBarWorkloadFilter | FilterBarSelectMultiFilter | FilterBarNumberFilter | FilterBarEnumFilter @@ -634,6 +660,7 @@ function FilterBarFilterPanelContent({ {filter.kind === "lookup-multi" && } {filter.kind === "multi" && } {filter.kind === "nested-multi" && } + {filter.kind === "workload" && } {filter.kind === "select-multi" && } {filter.kind === "number" && } {filter.kind === "enum" && } @@ -682,6 +709,10 @@ function renderFilterField(filter: FilterBarFilter, grow: boolean) { return ; } + if (filter.kind === "workload") { + return ; + } + if (filter.kind === "select-multi") { return ; } @@ -945,6 +976,9 @@ function FilterBarKeyValueControl({ filter }: { filter: FilterBarFilter }) { if (filter.kind === "nested-multi") { return ; } + if (filter.kind === "workload") { + return ; + } if (filter.kind === "tristate") { return ; } @@ -963,6 +997,30 @@ function lookupOptionsToCombobox(options: FilterBarLookupOption[]): ComboboxOpti })); } +function WorkloadFilterField({ + filter, + grow, +}: { + filter: FilterBarWorkloadFilter; + grow: boolean; +}) { + return ( +
+ +
+ ); +} + function valueInputClassName(disabled?: boolean) { return cn( "h-8 w-full rounded-md border border-input bg-background px-2 text-sm text-foreground outline-none placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring", @@ -2420,6 +2478,13 @@ function filterWithStagedValue( onChange: (next: string) => onChange(next), }; } + if (filter.kind === "workload") { + return { + ...filter, + value: String(value ?? ""), + onChange: (next: string) => onChange(next), + }; + } if (filter.kind === "lookup-multi") { return { ...filter, @@ -2494,7 +2559,12 @@ function applyStagedFilterValues( function applyFilterBarValue(filter: FilterBarFilter, value: FilterBarValue) { if (!isStageableFilter(filter)) return; - if (filter.kind === "text" || filter.kind === "lookup" || filter.kind === "enum") { + if ( + filter.kind === "text" || + filter.kind === "lookup" || + filter.kind === "workload" || + filter.kind === "enum" + ) { filter.onChange(String(value ?? "")); return; } @@ -2558,6 +2628,7 @@ function estimateFilterWidth(filter: FilterBarFilter) { // its compact trigger button. if (filter.kind === "multi") return 176; if (filter.kind === "nested-multi") return 136; + if (filter.kind === "workload") return 224; if (filter.kind === "number") return 152; return Math.max(144, filter.label.length * 8 + 96); } diff --git a/packages/ui/src/components/ItemActions.tsx b/packages/ui/src/components/ItemActions.tsx new file mode 100644 index 000000000..b13f19c57 --- /dev/null +++ b/packages/ui/src/components/ItemActions.tsx @@ -0,0 +1,113 @@ +import type { ReactNode } from "react"; +import { cn } from "../lib/utils"; +import type { SizeToken } from "../lib/size"; +import { Icon } from "../data/Icon"; +import { UiChevronDown, UiChevronUp, UiCopy, UiTrash } from "../icons"; +import { controlHeightClass } from "./json-schema-form-size"; + +// The per-row action cluster shared by every editable-list surface: AccordionList +// and the JsonSchemaForm array displays. Each action is offered ONLY when its +// handler is supplied, which is how a list opts into reorder without opting into +// delete — there is no separate flag to keep in sync with the callback. +export interface ItemActionsProps { + /** Names the item in every action's accessible label ("Move Routes up"). */ + label: string; + /** This row's position, used for the reorder targets and the end-disabling. */ + index: number; + /** Total rows, so the last row's "down" disables itself. */ + count: number; + /** + * Offers both reorder buttons, called with the destination index. The button + * at either end of the list renders disabled rather than missing, so the + * cluster keeps a constant width down a long list. + */ + onMove?: (to: number) => void; + /** Offers the duplicate button. */ + onDuplicate?: () => void; + /** Offers the remove button. */ + onRemove?: () => void; + /** Consumer actions, rendered before the built-in ones. */ + leading?: ReactNode; + /** + * Reveal on row hover / focus-within. Defaults to true, which requires + * `group` on the row container — a long list is then not a wall of + * permanently dim icons, while keyboard users still see the actions the + * moment they arrive. Pass false for a row that must always show them. + */ + reveal?: boolean; + size?: SizeToken; + className?: string; +} + +export function ItemActions({ + label, + index, + count, + onMove, + onDuplicate, + onRemove, + leading, + reveal = true, + size = "md", + className, +}: ItemActionsProps) { + 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 ( +
+ {leading} + {onMove && ( + <> + + + + )} + {onDuplicate && ( + + )} + {onRemove && ( + + )} +
+ ); +} diff --git a/packages/ui/src/components/JsonSchemaForm.errors.test.tsx b/packages/ui/src/components/JsonSchemaForm.errors.test.tsx index 1dda5dbc5..5bbff7646 100644 --- a/packages/ui/src/components/JsonSchemaForm.errors.test.tsx +++ b/packages/ui/src/components/JsonSchemaForm.errors.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, within } from "@testing-library/react"; +import { fireEvent, render, screen, within } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import { JsonSchemaForm } from "./JsonSchemaForm"; import type { JsonSchemaFormError } from "./json-schema-form-error-types"; @@ -169,6 +169,11 @@ describe("JsonSchemaForm authoritative errors", () => { [{ instancePath: "/lines/0/account", message: "Select an account" }] ); + // Object items collapse to summary rows by default, so the row reports the + // error it is hiding and the message itself waits inside. + expect(screen.getByTitle("1 error")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { expanded: false })); + const account = screen.getByRole("textbox", { name: "Account" }); expect(account.nextElementSibling).toHaveTextContent("Select an account"); }); diff --git a/packages/ui/src/components/JsonSchemaForm.size.test.tsx b/packages/ui/src/components/JsonSchemaForm.size.test.tsx index 16d61b76c..2ad9d9b68 100644 --- a/packages/ui/src/components/JsonSchemaForm.size.test.tsx +++ b/packages/ui/src/components/JsonSchemaForm.size.test.tsx @@ -99,10 +99,12 @@ describe("JsonSchemaForm size", () => { }); it("sizes array action buttons from the form size", () => { + // Stacked: the per-item reorder/remove column only exists on that opt-out — + // a scalar list is otherwise one tag combobox with no per-item buttons. const arraySchema: JsonSchemaObject = { type: "object", properties: { - ports: { type: "array", items: { type: "number" } }, + ports: { type: "array", "x-array-display": "stacked", items: { type: "number" } }, }, }; render( diff --git a/packages/ui/src/components/JsonSchemaForm.stories.tsx b/packages/ui/src/components/JsonSchemaForm.stories.tsx index f931a09c9..aa50c38ed 100644 --- a/packages/ui/src/components/JsonSchemaForm.stories.tsx +++ b/packages/ui/src/components/JsonSchemaForm.stories.tsx @@ -909,12 +909,167 @@ export const ArrayOfObjects: Story = { docs: { description: { story: - "When an array's items are objects, each item renders as its own sub-form (labelled *Item N*) with add / remove / reorder controls. Required and range hints apply per item. Plain string arrays still use the compact tag input — see **ScalarArrayTags**.", + "When an array's items are objects, each item collapses to one summary row and opens on click — the accordion is the default, with no schema hint required. The row identifies its item from conventional keys (`title`, `name`, `label`, `id`, `key`), falling back to *Item N*; `x-item` says it explicitly (see **ObjectArrayAccordion**). Plain string arrays still use the compact tag input — see **ScalarArrayTags**, and **ArrayOfObjectsStacked** for the full-sub-form opt-out.", + }, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + // Collapsed by default: the item's own fields are not on screen. + await expect(canvas.getByText("api")).toBeInTheDocument(); + await expect(canvas.queryByLabelText(/^Port/)).toBeNull(); + // `expanded` picks the disclosure out of the row's reorder/remove buttons, + // which carry the same item title in their labels. + await userEvent.click(canvas.getByRole("button", { name: /api/, expanded: false })); + await expect(canvas.getByLabelText(/^Port/)).toHaveValue("8080"); + }, +}; + +export const ArrayOfObjectsStacked: Story = { + args: { + schema: { + type: "object", + properties: { + servers: { + ...arrayOfObjectsSchema.properties!.servers, + "x-array-display": "stacked", + }, + }, + } as JsonSchemaObject, + value: { servers: [{ name: "api", port: 8080, tls: true }, { name: "worker", port: 0, tls: false }] }, + title: "Cluster", + }, + parameters: { + docs: { + description: { + story: + "`x-array-display: \"stacked\"` opts out of the accordion default: every item renders as its own open sub-form (labelled *Item N*) with add / remove / reorder controls. Worth it for a short list of two- or three-property items, where a collapsed row would hide as much as it saves.", + }, + }, + }, +}; + +// Cards keep every item open under a header that says what the item IS — the +// everything-visible counterpart to `x-array-display: "accordion"`. Both read +// `x-item`, so switching between them is a one-key change. +const objectArrayCardsSchema: JsonSchemaObject = { + type: "object", + properties: { + routes: { + type: "array", + title: "Routes", + "x-array-display": "cards", + "x-item": { + title: ["path"], + fallback: "New route", + summary: [{ property: "method" }, { property: "upstream" }], + glyph: "method", + flag: "auth", + noun: "route", + nounPlural: "routes", + }, + items: { + type: "object", + required: ["path"], + properties: { + path: { type: "string", title: "Path" }, + method: { + type: "string", + title: "Method", + enum: ["GET", "POST", "DELETE"], + "x-enum-tones": { GET: "teal", POST: "violet", DELETE: "rose" }, + "x-enum-display": "combobox", + }, + upstream: { type: "string", title: "Upstream" }, + auth: { type: "boolean", title: "Requires auth" }, + }, }, }, }, }; +export const ObjectArrayCards: Story = { + args: { + schema: objectArrayCardsSchema, + value: { + routes: [ + { path: "/api/v1/users", method: "GET", upstream: "users-svc:8080", auth: true }, + { path: "/api/v1/events", method: "POST", upstream: "events-svc:8080", auth: false }, + ], + }, + title: "Gateway", + }, + parameters: { + docs: { + description: { + story: + "`x-array-display: \"cards\"` renders object items as a stack of titled cards, each headed by the item's own summary (from `x-item`) and edged with the tone its glyph property resolves to. Every item stays open — the everything-visible counterpart to the accordion in **ObjectArrayAccordion**, which reads the same `x-item` but collapses each item to one line.", + }, + }, + }, + play: async ({ canvasElement }) => { + 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()); + }, +}; + +// The same routes, one line each. `x-item` is what makes a collapsed row worth +// reading: which property titles it, which ones summarize it, which colours its +// glyph — and the nouns the count, add row and empty state speak in. +const objectArrayAccordionSchema: JsonSchemaObject = { + type: "object", + properties: { + routes: { + ...objectArrayCardsSchema.properties!.routes, + "x-array-display": "accordion", + }, + }, +} as JsonSchemaObject; + +export const ObjectArrayAccordion: Story = { + args: { + schema: objectArrayAccordionSchema, + value: { + routes: [ + { path: "/api/v1/users", method: "GET", upstream: "users-svc:8080", auth: true }, + { path: "/api/v1/events", method: "POST", upstream: "events-svc:8080", auth: false }, + ], + }, + title: "Gateway", + }, + parameters: { + docs: { + description: { + story: + "The accordion an object array uses by default, told how to summarize its items. `x-item` names the property that titles the row (`path`), the ones that trail it, the enum whose `x-enum-tones`/`x-enum-icons` colour the glyph, the boolean that flags it, and the noun the count, **Add route** row and empty state speak in. Without `x-item` the same rows fall back to conventional keys and *Item N* — see **ArrayOfObjects**.", + }, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("2 routes")).toBeInTheDocument(); + // One line per route: the item's own fields wait behind the disclosure. + await expect(canvas.queryByLabelText(/^Upstream/)).toBeNull(); + await userEvent.click( + canvas.getByRole("button", { name: /\/api\/v1\/events/, expanded: false }), + ); + await expect(canvas.getByLabelText(/^Upstream/)).toHaveValue("events-svc:8080"); + }, +}; + const nestedObjectSchema: JsonSchemaObject = { type: "object", properties: { @@ -991,10 +1146,23 @@ export const DeepRecursion: Story = { docs: { description: { story: - "Array → object → (map + number array). The renderer follows the schema all the way down: editing a port two levels deep, adding an env key, or reordering a service all round-trip through the live JSON below.", + "Array → object → (map + number array). The renderer follows the schema all the way down: adding a port two levels deep, adding an env key, or reordering a service all round-trip through the live JSON below. **Ports** is a tag list like any other scalar array, and its `integer` items commit as numbers — `8080`, not `\"8080\"`.", }, }, }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("button", { name: /web/, expanded: false })); + + // Ports is one field, and what it commits is typed by the item schema. + const ports = canvas.getByRole("combobox"); + await userEvent.type(ports, "8080{Enter}"); + await waitFor(() => + expect(canvas.getByRole("button", { name: "Remove 8080" })).toBeInTheDocument(), + ); + // A quoted 8080 in the JSON below would mean the tag list committed text. + await expect(document.body.textContent).not.toContain('"8080"'); + }, }; // A post-extension that targets a field by its leaf key wherever it appears — @@ -1066,7 +1234,7 @@ export const ScalarArrayTags: Story = { docs: { description: { story: - "Plain string arrays keep the compact tag editor: type and press Enter or comma to add, Backspace on an empty input to remove the last. This fast-path is chosen only when the item schema is a bare string.", + "A list of scalars is the compact tag editor: type and press Enter or comma to add, paste a comma- or newline-separated list to add several at once, Backspace on an empty input to remove the last. Same control as **EnumArray** — it just has no options to offer, so typing is the only way in. Numeric items commit numbers (see **DeepRecursion**).", }, }, }, @@ -1087,10 +1255,23 @@ export const EnumArray: Story = { docs: { description: { story: - "An array whose items carry an `enum` is NOT a tag list — each item gets its own Combobox so values stay constrained to (and discoverable from) the option set, with the usual add / remove / reorder controls.", + "An array whose items carry an `enum` is a list of *choices*, so it renders as **one** Combobox in the tags variant rather than a stack of them: every committed value is a removable pill and the whole option set is one dropdown away. Unlike **ScalarArrayTags** the values stay constrained — text matching no option is discarded. `x-array-display: \"filter-pills\"` swaps the field for always-visible toggles; `\"stacked\"` restores a Combobox per item.", }, }, }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + // One control for the whole array — no per-item rows, no Add item. + await expect(canvas.getAllByRole("combobox")).toHaveLength(1); + await expect(canvas.queryByRole("button", { name: /add item/i })).toBeNull(); + await expect(canvas.getByRole("button", { name: "Remove viewer" })).toBeInTheDocument(); + + // The option list is portaled to the body, so it is queried from there. + await userEvent.click(canvas.getByRole("button", { name: "Toggle options" })); + const body = within(document.body); + await userEvent.click(await body.findByRole("option", { name: "editor" })); + await waitFor(() => expect(canvas.getByRole("button", { name: "Remove editor" })).toBeInTheDocument()); + }, }; // A keyed map whose KEYS are constrained by `propertyNames.enum` (a strict @@ -1192,7 +1373,7 @@ export const TableLayout: Story = { docs: { description: { story: - "`x-layout: \"table\"` on an array of objects renders it as a table — a header row of the item's property names and one compact row per item, with a per-row remove and an **Add item** button. Compare with **ArrayOfObjects**, which renders the same data as taller per-item sub-forms. Absent the hint, the stacked form is still the default.", + "`x-layout: \"table\"` on an array of objects renders it as a table — a header row of the item's property names and one compact row per item, with a per-row remove and an **Add item** button. Denser still than the summary rows an object array shows by default (**ArrayOfObjects**), at the cost of a column per property.", }, }, }, diff --git a/packages/ui/src/components/JsonSchemaForm.test.tsx b/packages/ui/src/components/JsonSchemaForm.test.tsx index 70fea4d1e..99fb77cef 100644 --- a/packages/ui/src/components/JsonSchemaForm.test.tsx +++ b/packages/ui/src/components/JsonSchemaForm.test.tsx @@ -186,6 +186,9 @@ describe("JsonSchemaForm extension pipeline", () => { properties: { endpoints: { type: "array", + // Stacked, so every item's fields render at once: a collapsed + // accordion row never reaches the extension for item 2. + "x-array-display": "stacked", items: { type: "object", properties: { url: { type: "string" } } }, }, }, @@ -552,6 +555,60 @@ describe("JsonSchemaForm array of objects", () => { }, }; + // Object items collapse to one summary row each without any schema hint: + // a full sub-form per item costs hundreds of pixels and says nothing about + // which item is which. + it("renders each item as a collapsed summary row by default", () => { + render( + , + ); + expect(screen.getByText("2 items")).toBeInTheDocument(); + expect(screen.getByText("api")).toBeInTheDocument(); + expect(screen.getByText("worker")).toBeInTheDocument(); + expect(screen.queryByRole("textbox")).toBeNull(); + }); + + it("edits an item's field through the expanded row", () => { + const onChange = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByRole("button", { name: /^a$/ })); + const nameInput = screen.getAllByRole("textbox").find((el) => (el as HTMLInputElement).value === "a"); + fireEvent.change(nameInput as HTMLElement, { target: { value: "b" } }); + expect(onChange).toHaveBeenCalledWith({ servers: [{ name: "b" }] }); + }); + + it("adds a seeded object item", () => { + const onChange = vi.fn(); + render(); + fireEvent.click(screen.getByRole("button", { name: /add item/i })); + expect(onChange).toHaveBeenCalledWith({ servers: [{}] }); + }); +}); + +// `x-array-display: "stacked"` is the opt-out from that default: one full +// sub-form per item, labelled *Item N*, with its own reorder/remove column. +describe("JsonSchemaForm array of objects (stacked)", () => { + const schema: JsonSchemaObject = { + type: "object", + properties: { + servers: { + type: "array", + "x-array-display": "stacked", + items: { + type: "object", + properties: { name: { type: "string" }, port: { type: "integer" } }, + required: ["name"], + }, + }, + }, + }; + it("adds a seeded object item", () => { const onChange = vi.fn(); render(); @@ -683,6 +740,9 @@ describe("JsonSchemaForm deep recursion", () => { properties: { services: { type: "array", + // Stacked, so the nested array is on screen without expanding a row — + // the accordion's own recursion is covered in its test file. + "x-array-display": "stacked", items: { type: "object", properties: { @@ -694,7 +754,7 @@ describe("JsonSchemaForm deep recursion", () => { }, }; - it("edits a value two levels deep and rebuilds the full nested structure", () => { + it("commits a value two levels deep and rebuilds the full nested structure", () => { const onChange = vi.fn(); render( { onChange={onChange} />, ); - const portInput = screen.getAllByRole("textbox").find((el) => (el as HTMLInputElement).value === "80"); - fireEvent.change(portInput as HTMLElement, { target: { value: "8080" } }); - expect(onChange).toHaveBeenCalledWith({ services: [{ name: "web", ports: [8080] }] }); + // The nested port list is the only combobox on screen (`name` is a textbox). + const ports = screen.getByRole("combobox"); + fireEvent.change(ports, { target: { value: "8080" } }); + fireEvent.keyDown(ports, { key: "Enter" }); + expect(onChange).toHaveBeenCalledWith({ services: [{ name: "web", ports: [80, 8080] }] }); }); }); @@ -755,15 +817,36 @@ describe("JsonSchemaForm array item kinds", () => { onChange={onChange} />, ); - // tag UI has no "Add item" button; it uses a free-text input committed on Enter + // The tag UI has no "Add item" button; typing and pressing Enter adds one. expect(screen.queryByRole("button", { name: /add item/i })).not.toBeInTheDocument(); - const input = screen.getByRole("textbox"); + const input = screen.getByRole("combobox"); fireEvent.change(input, { target: { value: "b" } }); fireEvent.keyDown(input, { key: "Enter" }); expect(onChange).toHaveBeenCalledWith({ tags: ["a", "b"] }); }); - it("renders a combobox per item for enum items (not tags)", () => { + // A list of numbers is the same gesture as a list of strings, and must land in + // the value as numbers — a tag editor that only spoke strings could not. + it("commits numbers from an integer item list", () => { + const onChange = vi.fn(); + render( + , + ); + expect(screen.getByRole("button", { name: "Remove 80" })).toBeInTheDocument(); + const input = screen.getByRole("combobox"); + fireEvent.change(input, { target: { value: "443" } }); + fireEvent.keyDown(input, { key: "Enter" }); + expect(onChange).toHaveBeenCalledWith({ ports: [80, 443] }); + }); + + // An enum item schema makes the array a list of choices: ONE combobox whose + // committed values are pills, not a stack of comboboxes with an add button. + it("renders enum items as a single tags combobox", () => { + const onChange = vi.fn(); render( { properties: { roles: { type: "array", items: { type: "string", enum: ["admin", "viewer"] } } }, }} value={{ roles: ["admin"] }} + onChange={onChange} + />, + ); + expect(screen.getAllByRole("combobox")).toHaveLength(1); + expect(screen.queryByRole("button", { name: /add item/i })).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Toggle options" })); + // The menu commits on mousedown, before the input can lose focus. + fireEvent.mouseDown(screen.getByRole("option", { name: "viewer" })); + expect(onChange).toHaveBeenCalledWith({ roles: ["admin", "viewer"] }); + }); + + it("keeps enum items constrained to the option set", () => { + const onChange = vi.fn(); + render( + , + ); + const input = screen.getByRole("combobox"); + fireEvent.focus(input); + fireEvent.change(input, { target: { value: "root" } }); + fireEvent.keyDown(input, { key: "Enter" }); + expect(onChange).not.toHaveBeenCalled(); + }); + + it("renders a combobox per item when the schema opts out with stacked", () => { + render( + , ); diff --git a/packages/ui/src/components/JsonSchemaFormReference.stories.tsx b/packages/ui/src/components/JsonSchemaFormReference.stories.tsx index f49a43af0..d02f04351 100644 --- a/packages/ui/src/components/JsonSchemaFormReference.stories.tsx +++ b/packages/ui/src/components/JsonSchemaFormReference.stories.tsx @@ -508,3 +508,47 @@ export const HierarchicalLookups: Story = { ).toBeInTheDocument(); }, }; + +// The same multi lookup WITHOUT `hierarchy`: a flat option list, so it takes the +// tags combobox — the control an enum-item array uses, with the option set +// fetched instead of declared. +const flatLookupSchema: JsonSchemaObject = { + type: "object", + properties: { + imports: { + type: "array", + title: "Imports", + description: "Multi select: every committed value stays as a pill.", + items: { type: "string" }, + "x-clicky-lookup": { + url: "/api/v1/profiles", + filter: "profile", + multi: true, + }, + }, + }, +}; + +export const MultiValueLookup: Story = { + render: () => ( + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + // The committed value renders before any option has been fetched. + await expect(canvas.getByText("jms")).toBeInTheDocument(); + + await userEvent.click(canvas.getByRole("combobox")); + const body = within(document.body); + await userEvent.click(await body.findByRole("option", { name: "logs.api" })); + await expect( + canvas.getByRole("button", { name: "Remove logs.api" }), + ).toBeInTheDocument(); + }, +}; diff --git a/packages/ui/src/components/NamespacePicker.tsx b/packages/ui/src/components/NamespacePicker.tsx index 1145ebf1f..6d76ebc83 100644 --- a/packages/ui/src/components/NamespacePicker.tsx +++ b/packages/ui/src/components/NamespacePicker.tsx @@ -66,6 +66,7 @@ export function NamespacePicker({ options={options} value={value} onChange={onChange} + ariaLabel="Namespace" allowCustomValue loading={loading} invalid={invalid} diff --git a/packages/ui/src/components/UnitStepControls.tsx b/packages/ui/src/components/UnitStepControls.tsx new file mode 100644 index 000000000..2c0d648e8 --- /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/WorkloadPicker.stories.tsx b/packages/ui/src/components/WorkloadPicker.stories.tsx index f2193788e..c892f7a19 100644 --- a/packages/ui/src/components/WorkloadPicker.stories.tsx +++ b/packages/ui/src/components/WorkloadPicker.stories.tsx @@ -10,8 +10,10 @@ import { const FIXTURE: Record = { service: [{ name: "demo-svc" }, { name: "activemq-svc" }], ingress: [{ name: "demo-ing", hosts: ["demo.example.com"] }], + pod: [{ name: "demo-api-abc12" }], deployment: [{ name: "demo-web" }, { name: "palette-web" }], statefulset: [{ name: "demo-cycle" }, { name: "sqlserver" }], + daemonset: [{ name: "node-agent" }], }; // makeLoader fakes the consumer's `loadWorkloads` getter: it resolves after @@ -55,7 +57,7 @@ const meta = { docs: { description: { component: - "Selects a Kubernetes workload (Service / Ingress / Deployment / StatefulSet) for an endpoint. Options from every kind are merged into one Combobox, grouped by kind via Combobox group headers and labelled with the kind's icon. Fetches nothing itself — the consumer supplies the async `loadWorkloads` getter. An ingress emits its first host (the routable address) as the value, labelled with the ingress name for context; every other kind emits its name.", + "Selects a Kubernetes workload for an endpoint or query. Options from every requested kind are merged into one Combobox, grouped by kind via Combobox group headers and labelled with the kind's icon. Fetches nothing itself — the consumer supplies the async `loadWorkloads` getter. An ingress emits its first host (the routable address) as the value, labelled with the ingress name for context; every other kind emits its name.", }, }, }, @@ -92,6 +94,25 @@ export const Namespaced: Story = { render: () => , }; +export const NamespaceSelection: Story = { + parameters: { + docs: { + description: { + story: + "Opt-in namespace selection scopes workload discovery and emits a fully-qualified namespace/kind/name value.", + }, + }, + }, + render: () => ( + Promise.resolve(["demo", "platform", "search"])} + /> + ), +}; + export const Loading: Story = { parameters: { docs: { description: { story: "A slow loader keeps the spinner visible while options resolve." } }, diff --git a/packages/ui/src/components/WorkloadPicker.test.tsx b/packages/ui/src/components/WorkloadPicker.test.tsx index 3c5686e31..bfd79470c 100644 --- a/packages/ui/src/components/WorkloadPicker.test.tsx +++ b/packages/ui/src/components/WorkloadPicker.test.tsx @@ -13,13 +13,33 @@ import { const FIXTURE: Record = { service: [{ name: "demo-svc" }], ingress: [{ name: "demo-ing", hosts: ["demo.example.com"] }], + pod: [{ name: "demo-api-abc12" }], deployment: [{ name: "demo-web" }], statefulset: [{ name: "demo-cycle" }], + daemonset: [{ name: "node-agent" }], }; const loadAll = () => Promise.resolve(FIXTURE); describe("WorkloadPicker", () => { + it("preserves each loaded workload's namespace in a cross-namespace catalog", async () => { + const onChange = vi.fn(); + render( + ({ + deployment: [{ name: "api", namespace: "payments" }], + })} + />, + ); + + fireEvent.focus(screen.getByRole("combobox", { name: "Workload" })); + fireEvent.mouseDown(await screen.findByRole("option", { name: "api" })); + expect(onChange).toHaveBeenCalledWith("payments/deployment/api"); + }); + it("groups options by kind with one header each", async () => { render(); fireEvent.focus(screen.getByRole("combobox")); @@ -30,7 +50,14 @@ describe("WorkloadPicker", () => { const headers = [...listbox.querySelectorAll('[role="presentation"]')] .map((h) => h.textContent) .filter((t) => t); - expect(headers).toEqual(["Service", "Ingress", "Deployment", "StatefulSet"]); + expect(headers).toEqual([ + "Service", + "Ingress", + "Pod", + "Deployment", + "StatefulSet", + "DaemonSet", + ]); }); it("annotates an ingress option with its first host", async () => { @@ -97,14 +124,94 @@ describe("WorkloadPicker", () => { expect(onChange).toHaveBeenCalledWith("demo/service/demo-svc"); }); + it("reloads workloads from a namespace selected by the user", async () => { + const onChange = vi.fn(); + const loadWorkloads = vi.fn( + (_kinds: WorkloadKind[], namespace?: string) => + Promise.resolve({ + service: [{ name: namespace === "search" ? "redis" : "demo-svc" }], + ingress: [], + pod: [], + deployment: [], + statefulset: [], + daemonset: [], + }), + ); + render( + Promise.resolve(["demo", "search"])} + />, + ); + + await waitFor(() => + expect(loadWorkloads).toHaveBeenCalledWith(["service"], "demo"), + ); + fireEvent.focus(screen.getByRole("combobox", { name: "Namespace" })); + fireEvent.mouseDown(await screen.findByRole("option", { name: "search" })); + + await waitFor(() => + expect(loadWorkloads).toHaveBeenCalledWith(["service"], "search"), + ); + expect(onChange).toHaveBeenCalledWith(""); + + fireEvent.focus(screen.getByRole("combobox", { name: "Workload" })); + fireEvent.mouseDown(await screen.findByRole("option", { name: "redis" })); + expect(onChange).toHaveBeenLastCalledWith("search/service/redis"); + }); + + it("uses the namespace encoded in the controlled value", async () => { + const loadWorkloads = vi.fn(loadAll); + render( + Promise.resolve(["demo", "search"])} + />, + ); + + await waitFor(() => + expect(loadWorkloads).toHaveBeenCalledWith(["service"], "search"), + ); + expect(screen.getByRole("combobox", { name: "Namespace" })).toHaveValue( + "search", + ); + }); + + it("rejects namespace selection without a namespace loader", () => { + expect(() => + render( + , + ), + ).toThrow( + "WorkloadPicker namespace selection requires loadNamespaces", + ); + }); + it("disambiguates same-named workloads of different kinds", async () => { // A Service and a Deployment both named "demo" yield distinct keyed values. const load = () => Promise.resolve({ service: [{ name: "demo" }], ingress: [], + pod: [], deployment: [{ name: "demo" }], statefulset: [], + daemonset: [], }); render(); fireEvent.focus(screen.getByRole("combobox")); @@ -151,6 +258,70 @@ describe("WorkloadPicker", () => { }); }); +describe("WorkloadPicker collapseSingleOption", () => { + const loadOne = () => Promise.resolve({ pod: [{ name: "demo-api-abc12", namespace: "demo" }] }); + + it("states the sole workload as text instead of offering a choice", async () => { + const onChange = vi.fn(); + render( + , + ); + + await waitFor(() => expect(screen.getByText("demo-api-abc12")).toBeInTheDocument()); + expect(screen.queryByRole("combobox")).not.toBeInTheDocument(); + // The caller is already scoped to it, so collapsing selects nothing. + expect(onChange).not.toHaveBeenCalled(); + }); + + it("keeps the picker while the single option is still loading", () => { + render( + new Promise(() => {})} + />, + ); + expect(screen.getByRole("combobox")).toBeInTheDocument(); + }); + + it("keeps the picker when more than one workload is offered", async () => { + render( + , + ); + await waitFor(() => expect(screen.getByRole("combobox")).toBeInTheDocument()); + }); + + // A value the scope did not return is what strict mode exists to surface; + // collapsing would hide the control that reports it. + it("keeps the picker when the selection is not the sole option", async () => { + render( + , + ); + await waitFor(() => + expect(screen.getByRole("combobox")).toHaveAttribute("aria-invalid", "true"), + ); + }); + + it("offers the picker as usual when collapsing is not asked for", async () => { + render(); + await waitFor(() => expect(screen.getByRole("combobox")).toBeInTheDocument()); + }); +}); + describe("WorkloadPicker strict mode", () => { it("flags a value that matches no loaded workload once loaded", async () => { render( @@ -258,6 +429,19 @@ describe("workloadKey / parseWorkloadKey", () => { }); }); + it("round-trips pod and daemonset keys", () => { + expect(parseWorkloadKey("default/pod/api-abc12")).toEqual({ + namespace: "default", + kind: "pod", + name: "api-abc12", + }); + expect(parseWorkloadKey("observability/daemonset/node-agent")).toEqual({ + namespace: "observability", + kind: "daemonset", + name: "node-agent", + }); + }); + it("treats a value with no recognised kind segment as a bare name", () => { expect(parseWorkloadKey("legacy-svc")).toEqual({ name: "legacy-svc" }); // 'foo' is not a known kind, so the whole value is the name. diff --git a/packages/ui/src/components/WorkloadPicker.tsx b/packages/ui/src/components/WorkloadPicker.tsx index f2a351e92..952043d76 100644 --- a/packages/ui/src/components/WorkloadPicker.tsx +++ b/packages/ui/src/components/WorkloadPicker.tsx @@ -1,20 +1,23 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { cn } from "../lib/utils"; import { Combobox } from "./Combobox"; +import { Field } from "./Field"; +import { NamespacePicker, type NamespacePickerProps } from "./NamespacePicker"; import { ALL_WORKLOAD_KINDS, WORKLOAD_META, buildWorkloadOptions, kindForValue, loadedWorkloads, + parseWorkloadKey, type WorkloadKind, type WorkloadResource, } from "./workload-picker-utils"; -// WorkloadPicker selects a backing Kubernetes workload (Service / Ingress / -// Deployment / StatefulSet) for an endpoint. Options from every kind are merged -// into one Combobox, grouped by kind via the Combobox group-header support and -// labelled with the kind's icon. +// WorkloadPicker selects a backing Kubernetes workload for an endpoint or +// query. Options from every requested kind are merged into one Combobox, +// grouped by kind via the Combobox group-header support and labelled with the +// kind's icon. // // The emitted value is a `[namespace/]kind/name` key (see workloadKey / // parseWorkloadKey in workload-picker-utils) so two workloads of different @@ -38,13 +41,22 @@ export type WorkloadPickerProps = { * Async getter the component calls to load the requested kinds' workloads. * Returns a map keyed by kind. The consumer owns fetching/caching. */ - loadWorkloads: (kinds: WorkloadKind[]) => Promise>; + loadWorkloads: ( + kinds: WorkloadKind[], + namespace?: string, + ) => Promise>>; /** * Namespace the workloads live in. When set it prefixes the emitted key * (`namespace/kind/name`), so values stay distinct across namespaces. */ namespace?: string; - /** Kinds to offer, in display order. Defaults to all four. */ + /** Lets the user choose the namespace used to load and key workloads. */ + allowNamespaceSelection?: boolean; + /** Required when namespace selection is enabled. */ + loadNamespaces?: NamespacePickerProps["loadNamespaces"]; + /** Reports the namespace independently of workload selection. */ + onNamespaceChange?: (namespace: string | undefined) => void; + /** Kinds to offer, in display order. Defaults to every supported kind. */ kinds?: WorkloadKind[]; /** * When true, a non-empty value that does not match any loaded workload (once @@ -58,6 +70,17 @@ export type WorkloadPickerProps = { * Defaults to true for the existing free-form workload picker behavior. */ allowCustomValue?: boolean; + /** + * Render the sole loaded workload as plain text instead of a picker. + * + * A dropdown whose only entry is the one already in effect asks a question + * with one answer. This is for a scope that leaves nothing to choose — a + * Deployment with a single pod — where the workload is a fact to state rather + * than a selection to make. No value is emitted: the caller is already scoped + * to it, so writing it back would only add a filter that changes nothing. + */ + collapseSingleOption?: boolean; + disabled?: boolean; placeholder?: string; className?: string; }; @@ -67,20 +90,60 @@ export function WorkloadPicker({ onChange, loadWorkloads, namespace, + allowNamespaceSelection = false, + loadNamespaces, + onNamespaceChange, kinds = ALL_WORKLOAD_KINDS, strict = false, allowCustomValue = true, + collapseSingleOption = false, + disabled = false, placeholder = "Select workload / service…", className, }: WorkloadPickerProps) { + const initialNamespace = value + ? parseWorkloadKey(value).namespace + : undefined; + const [selectedNamespace, setSelectedNamespace] = useState( + initialNamespace ?? namespace ?? "", + ); const [byKind, setByKind] = useState>>({}); const [loading, setLoading] = useState(false); + const defaultNamespaceRef = useRef(namespace); + const effectiveNamespace = allowNamespaceSelection + ? selectedNamespace || undefined + : namespace; + + useEffect(() => { + if (!allowNamespaceSelection || !value) return; + const valueNamespace = parseWorkloadKey(value).namespace; + if (valueNamespace) setSelectedNamespace(valueNamespace); + }, [allowNamespaceSelection, value]); + + useEffect(() => { + if ( + !allowNamespaceSelection || + defaultNamespaceRef.current === namespace + ) { + return; + } + defaultNamespaceRef.current = namespace; + if (!value) setSelectedNamespace(namespace ?? ""); + }, [allowNamespaceSelection, namespace, value]); const kindsKey = kinds.join(","); useEffect(() => { + if (allowNamespaceSelection && !effectiveNamespace) { + setByKind({}); + setLoading(false); + return; + } let cancelled = false; setLoading(true); - loadWorkloads(kinds) + const request = effectiveNamespace + ? loadWorkloads(kinds, effectiveNamespace) + : loadWorkloads(kinds); + request .then((res) => { if (!cancelled) setByKind(res); }) @@ -93,11 +156,11 @@ export function WorkloadPicker({ // loadWorkloads is expected to be stable (memoized by the consumer); kindsKey // is the joined `kinds` so a changed selection reloads without depending on // the array's identity. - }, [loadWorkloads, kindsKey]); + }, [allowNamespaceSelection, effectiveNamespace, loadWorkloads, kindsKey]); const options = useMemo( - () => buildWorkloadOptions(namespace, byKind, kinds, value), - [namespace, byKind, kinds, value], + () => buildWorkloadOptions(effectiveNamespace, byKind, kinds, value), + [effectiveNamespace, byKind, kinds, value], ); // In strict mode a non-empty value is invalid once loading settles unless it @@ -108,9 +171,9 @@ export function WorkloadPicker({ // membership against the loaded set, not `options`. const invalid = useMemo(() => { if (!strict || !value || loading) return false; - const { keys, names } = loadedWorkloads(namespace, byKind, kinds); + const { keys, names } = loadedWorkloads(effectiveNamespace, byKind, kinds); return !keys.has(value) && !names.has(value); - }, [strict, value, loading, namespace, byKind, kinds]); + }, [strict, value, loading, effectiveNamespace, byKind, kinds]); // The lead icon reflects the selected workload's kind (read from the value's // key), falling back to the first offered kind when nothing is selected or the @@ -118,24 +181,74 @@ export function WorkloadPicker({ const selectedKind = kindForValue(kinds, value); const leadMeta = WORKLOAD_META[selectedKind]; const LeadIcon = leadMeta.Icon; - return ( -
+ // Only once loading settles, and only while the sole option is not contested + // by a selection that named something else — a value the load did not return + // is exactly the case `strict` exists to surface, and hiding the control + // would hide it too. + const soleOption = + collapseSingleOption && !loading && options.length === 1 && (!value || value === options[0]!.value) + ? options[0]! + : undefined; + const control = ( +
- + {soleOption ? ( + + {soleOption.label} + + ) : ( + + )}
); + if (!allowNamespaceSelection) return control; + if (!loadNamespaces) { + throw new Error( + "WorkloadPicker namespace selection requires loadNamespaces", + ); + } + + return ( +
+ + { + setSelectedNamespace(nextNamespace); + setByKind({}); + onNamespaceChange?.(nextNamespace || undefined); + onChange(""); + }} + loadNamespaces={loadNamespaces} + strict + /> + + {control} +
+ ); } diff --git a/packages/ui/src/components/combobox-types.ts b/packages/ui/src/components/combobox-types.ts index d5eb8ca16..a565dbfb1 100644 --- a/packages/ui/src/components/combobox-types.ts +++ b/packages/ui/src/components/combobox-types.ts @@ -120,6 +120,13 @@ export type ComboboxMultiProps = ComboboxBaseProps & { tristate?: false; /** Renders selected values as wrapping removable pills instead of a summary. */ variant?: "default" | "tags"; + /** + * Characters that commit the typed text as a value, alongside Enter — and + * that pasted text is split on (newlines always split too). For a list of + * short values that is usually typed or pasted comma-separated. Tags variant + * only; needs `allowCustomValue`. + */ + separators?: string[]; value: string[]; onChange: (value: string[]) => void; }; @@ -129,6 +136,12 @@ export type ComboboxTriStateMode = "include" | "exclude"; export type ComboboxTriStateProps = ComboboxBaseProps & { multiple: true; tristate: true; + /** + * Renders each value as a pill carrying its mode (included / excluded) instead + * of the `+n -n` summary. The pill cycles include ↔ exclude on click and its + * close button returns the value to neutral. + */ + variant?: "default" | "tags"; value: Record; onChange: (value: Record) => void; }; diff --git a/packages/ui/src/components/combobox-utils.ts b/packages/ui/src/components/combobox-utils.ts index 2f4619eed..f40239e6b 100644 --- a/packages/ui/src/components/combobox-utils.ts +++ b/packages/ui/src/components/combobox-utils.ts @@ -47,6 +47,29 @@ export function multipleComboboxLabel( return `${labels.length} selected`; } +// Splits pasted text into one entry per value. Newlines always separate — a +// list copied out of a file or a column of cells arrives that way — plus +// whichever characters the consumer declared. Empty entries are the caller's to +// drop, so "a,,b" and a trailing comma stay visible here. +export function splitOnComboboxSeparators( + text: string, + separators: string[], +): string[] { + const marks = new Set([...separators, "\n", "\r"]); + const parts: string[] = []; + let current = ""; + for (const char of text) { + if (marks.has(char)) { + parts.push(current); + current = ""; + continue; + } + current += char; + } + parts.push(current); + return parts; +} + export function createComboboxCustomEntry(options: { allowCustomValue: boolean; query: string; diff --git a/packages/ui/src/components/filter-bar-utils.ts b/packages/ui/src/components/filter-bar-utils.ts index 63d238c10..84d7d6b2e 100644 --- a/packages/ui/src/components/filter-bar-utils.ts +++ b/packages/ui/src/components/filter-bar-utils.ts @@ -16,7 +16,12 @@ export function applyFilterExtensions( } export function clearFilterBarFilter(filter: FilterBarFilter) { - if (filter.kind === "text" || filter.kind === "lookup" || filter.kind === "enum") { + if ( + filter.kind === "text" || + filter.kind === "lookup" || + filter.kind === "workload" || + filter.kind === "enum" + ) { filter.onChange(""); return; } @@ -53,7 +58,12 @@ export function isFilterBarFilterActive(filter: FilterBarFilter) { if (filter.kind === "date-range") { return String(filter.from ?? "").trim() !== "" || String(filter.to ?? "").trim() !== ""; } - if (filter.kind === "text" || filter.kind === "lookup" || filter.kind === "enum") { + if ( + filter.kind === "text" || + filter.kind === "lookup" || + filter.kind === "workload" || + filter.kind === "enum" + ) { return String(filter.value ?? "").trim() !== ""; } if (filter.kind === "lookup-multi" || filter.kind === "select-multi") { diff --git a/packages/ui/src/components/jotai-bindings-core.ts b/packages/ui/src/components/jotai-bindings-core.ts index 21526f5ca..064919478 100644 --- a/packages/ui/src/components/jotai-bindings-core.ts +++ b/packages/ui/src/components/jotai-bindings-core.ts @@ -14,6 +14,7 @@ import type { FilterBarSearchProps, FilterBarSelectMultiFilter, FilterBarTextFilter, + FilterBarWorkloadFilter, } from "./FilterBar"; import type { JsonSchemaFormProps } from "./json-schema-form-types"; @@ -51,6 +52,7 @@ type AtomizedFilter = Omit; +export type JotaiFilterBarWorkloadFilter = AtomizedFilter; export type JotaiFilterBarLookupFilter = AtomizedFilter; export type JotaiFilterBarLookupMultiFilter = AtomizedFilter; export type JotaiFilterBarMultiFilter = AtomizedFilter; @@ -62,6 +64,7 @@ export type JotaiFilterBarBooleanFilter = AtomizedFilter export type JotaiFilterBarFilter = | JotaiFilterBarTextFilter + | JotaiFilterBarWorkloadFilter | JotaiFilterBarLookupFilter | JotaiFilterBarLookupMultiFilter | JotaiFilterBarMultiFilter @@ -211,6 +214,17 @@ function bindJotaiFilter(store: ReturnType, filter: JotaiFilter }, }; } + case "workload": { + const { atom, onChange, ...rest } = filter; + return { + ...rest, + value: store.get(atom), + onChange: (next: string) => { + store.set(atom, next); + onChange?.(next); + }, + }; + } case "lookup-multi": case "select-multi": { const { atom, onChange, ...rest } = filter; diff --git a/packages/ui/src/components/json-schema-form-accordion-array.test.tsx b/packages/ui/src/components/json-schema-form-accordion-array.test.tsx index 0d372df2b..83328b753 100644 --- a/packages/ui/src/components/json-schema-form-accordion-array.test.tsx +++ b/packages/ui/src/components/json-schema-form-accordion-array.test.tsx @@ -231,6 +231,20 @@ describe("JsonSchemaForm accordion array", () => { expect(next[1]).not.toBe(next[0]); }); + it("offers only the actions x-item.actions lists", () => { + renderAccordion({ extras: { "x-item": { ...X_ITEM, actions: ["reorder"] } } }); + expect(screen.getByRole("button", { name: "Move Service down" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Duplicate Service" })).toBeNull(); + expect(screen.queryByRole("button", { name: "Remove Service" })).toBeNull(); + }); + + it("offers no actions when x-item.actions is empty, and still opens rows", () => { + renderAccordion({ extras: { "x-item": { ...X_ITEM, actions: [] } } }); + expect(screen.queryByRole("button", { name: /^(Move|Duplicate|Remove) / })).toBeNull(); + fireEvent.click(headerFor("Service")); + expect(screen.getByLabelText(/^Field/)).toBeInTheDocument(); + }); + it("keeps the open row open when it moves", () => { // The index is the key, so a mutator that forgets to follow the item would // silently expand whichever row took its place. @@ -331,4 +345,27 @@ describe("JsonSchemaForm accordion array", () => { const title = screen.getByText("Params"); expect(title.closest("label")).toBeNull(); }); + + // A collapsed row renders none of the controls that would carry a message, so + // the row itself has to say an error is hiding in there. + it("counts the errors inside a collapsed row on the row itself", () => { + render( + , + ); + expect(within(headerFor("Service")).getByTitle("2 errors")).toBeInTheDocument(); + expect(within(headerFor("Since")).queryByTitle(/error/)).toBeNull(); + // Opening the row hands the messages back to the controls that own them. + fireEvent.click(headerFor("Service")); + expect(screen.getByText("Already used")).toBeInTheDocument(); + expect(within(headerFor("Service")).queryByTitle(/error/)).toBeNull(); + }); }); 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 3d36c7cb8..528a4ac46 100644 --- a/packages/ui/src/components/json-schema-form-accordion-array.tsx +++ b/packages/ui/src/components/json-schema-form-accordion-array.tsx @@ -1,54 +1,28 @@ +import { AccordionList } from "./AccordionList"; +import { appendInstancePath, errorCountUnderInstancePath } from "./json-schema-form-errors"; import { - useEffect, - useRef, - useState, - type KeyboardEvent, - 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 { appendInstancePath } from "./json-schema-form-errors"; + ItemBadge, + ItemErrorMark, + ItemGlyph, + RequiredMark, +} from "./json-schema-form-item-row"; import { addItemLabel, emptyItemsCopy, + itemActionsAllow, itemCountLabel, itemSummaryFor, 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 { - duplicateIndex, - fieldInputId, - moveItem, - removeIndex, - seedFromSchema, - setIndex, -} from "./json-schema-form-utils"; -import type { - ArrayItemSummary, - 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 -// a ten-property item stacked in full costs ~700px of screen each, and a list of -// them says nothing about which item is which. -// -// "Add" is the list's own last row rather than a button beside it: the -// affordance sits exactly where the next item will appear, and at zero items -// that same row IS the empty state — one control, no separate screen. +import { fieldInputId, seedFromSchema } from "./json-schema-form-utils"; +import type { FieldControl, RenderContext } from "./json-schema-form-types"; + +// AccordionArray is the JsonSchemaForm adapter over AccordionList: it turns +// `x-item` into row content and the schema's item into a recursive sub-form, and +// owns nothing structural. Everything about the interaction — one row open at a +// time, roving focus, the add row that doubles as the empty state — lives in +// AccordionList, which knows nothing about JSON Schema. export function AccordionArray({ field, ctx, @@ -75,316 +49,68 @@ export function AccordionArray({ : {}), }; - // Which row is open is transient view state, like the form's field filter — - // it is deliberately local and never persisted. The INDEX is the key, not the - // item: every keystroke replaces the item object (setIndex returns a fresh - // one), so any identity-based map would die on the first character typed. - // That makes it the mutators' job to keep this pointing at the same row. - const [expanded, setExpanded] = useState(null); - const rowRefs = useRef>([]); - const addRef = useRef(null); - const pendingFocus = useRef<{ kind: "panel" | "row" | "add"; index: number } | null>(null); - - useEffect(() => { - const pending = pendingFocus.current; - if (!pending) return; - pendingFocus.current = null; - if (pending.kind === "add") { - addRef.current?.focus(); - return; - } - const row = rowRefs.current[pending.index]; - if (pending.kind === "row") { - row?.focus(); - return; - } - // Land in the new item's first editable control, so adding an item leaves - // the caret where the author is about to type. - const panel = document.getElementById(panelId(pending.index)); - const input = panel?.querySelector("[data-jsf-input]"); - (input ?? row)?.focus(); - }); - - function panelId(index: number): string { - return `${fieldInputId(appendInstancePath(ctx.instancePath, index), ctx.idPrefix)}-panel`; - } - function headerId(index: number): string { - return `${fieldInputId(appendInstancePath(ctx.instancePath, index), ctx.idPrefix)}-header`; - } - - function commit(next: unknown[]) { - field.onChange(next); - } - - function add() { - pendingFocus.current = { kind: "panel", index: items.length }; - setExpanded(items.length); - commit([...items, seedFromSchema(itemSchema)]); - } - - function remove(index: number) { - setExpanded((open) => (open === index ? null : open !== null && open > index ? open - 1 : open)); - pendingFocus.current = - items.length === 1 ? { kind: "add", index: 0 } : { kind: "row", index: Math.max(index - 1, 0) }; - commit(removeIndex(items, index)); - } - - function duplicate(index: number) { - setExpanded((open) => (open !== null && open > index ? open + 1 : open)); - pendingFocus.current = { kind: "row", index: index + 1 }; - commit(duplicateIndex(items, index)); - } - - function move(index: number, to: number) { - // Follow the item, not the slot — otherwise moving an open row silently - // expands whichever row took its place. - setExpanded((open) => (open === index ? to : open === to ? index : open)); - pendingFocus.current = { kind: "row", index: to }; - commit(moveItem(items, index, to)); - } - - // Roving focus across the headers, with the add row as the final stop. - function handleKeyDown(e: KeyboardEvent, index: number) { - const stops = rowRefs.current.length; - const focusAt = (i: number) => { - e.preventDefault(); - (i >= stops ? addRef.current : rowRefs.current[i])?.focus(); - }; - if (e.key === "ArrowDown") focusAt(Math.min(index + 1, stops)); - else if (e.key === "ArrowUp" && index > 0) focusAt(index - 1); - else if (e.key === "Home") focusAt(0); - else if (e.key === "End") focusAt(stops); + function summaryFor(item: unknown, index: number) { + return field.itemSummary?.({ item, index }) ?? itemSummaryFor({ item, index, spec, itemSchema }); } return ( -
-

- {items.length === 0 ? noItemsLabel(spec) : itemCountLabel(spec, items.length)} -

-
- {items.map((item, i) => { - const open = expanded === i; - const summary = - field.itemSummary?.({ item, index: i }) ?? - itemSummaryFor({ item, index: i, spec, itemSchema }); - return ( -
- {/* The row is a plain element, NOT a button: the actions beside it - are buttons, and nesting interactive content is invalid DOM - with undefined click targeting. Only the disclosure toggles. */} -
- - {!readOnly && ( - 0 ? { onUp: () => move(i, i - 1) } : {})} - {...(i < items.length - 1 ? { onDown: () => move(i, i + 1) } : {})} - onDuplicate={() => duplicate(i)} - onRemove={() => remove(i)} - /> + + items={items} + onChange={(next) => field.onChange(next)} + summary={items.length === 0 ? noItemsLabel(spec) : itemCountLabel(spec, items.length)} + size={ctx.size} + readOnly={readOnly} + itemId={(index) => fieldInputId(appendInstancePath(ctx.instancePath, index), ctx.idPrefix)} + // The panel's controls are the form's own inputs; anything else focusable + // inside an item (a help disclosure, a remove button on a nested array) + // would otherwise win the caret on add. + focusSelector="[data-jsf-input]" + itemLabel={({ item, index }) => summaryFor(item, index).title} + allowReorder={itemActionsAllow(spec, "reorder")} + allowDuplicate={itemActionsAllow(spec, "duplicate")} + allowRemove={itemActionsAllow(spec, "remove")} + onCreate={() => seedFromSchema(itemSchema)} + addLabel={addItemLabel(spec)} + {...(emptyCopy ? { addDescription: emptyCopy } : {})} + renderHeader={({ item, index, open }) => { + const summary = summaryFor(item, index); + return ( + <> + + {summary.title} + + {summary.flagged && } + {summary.summary && ( + + {summary.summary} + + )} + {!open && ( + - {open && ( -
- {/* Recurse through the shared pipeline so consumer pre/post - extensions still apply to the item and its properties. */} - {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} -
- )} -
- ); - })} - {!readOnly && ( - handleKeyDown(e, items.length)} - {...(emptyCopy ? { copy: emptyCopy } : {})} - /> - )} -
-
- ); -} - -// AddItemRow is the list's own final row. At zero items it grows to carry the -// schema's explanation of what an item is — so the empty state is the same -// control the author will use, not a separate screen they have to leave. -function AddItemRow({ - label, - copy, - empty, - onAdd, - onKeyDown, - buttonRef, -}: { - label: string; - copy?: string; - empty: boolean; - onAdd: () => void; - onKeyDown: (e: KeyboardEvent) => void; - buttonRef: MutableRefObject; -}) { - return ( - - ); -} - -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 ( -
- - - - -
+ renderBody={({ item, index, onChange }) => + // Recurse through the shared pipeline so consumer pre/post extensions + // still apply to the item and its properties. + ctx.render.renderFieldNodes( + { + key: `${field.key}[${index}]`, + prop: itemSchema, + required: false, + value: item, + onChange, + instancePath: appendInstancePath(ctx.instancePath, index), + }, + childCtx, + )?.value ?? null + } + /> ); } 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 000000000..822aa5eb7 --- /dev/null +++ b/packages/ui/src/components/json-schema-form-array-inline.test.tsx @@ -0,0 +1,94 @@ +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", () => { + // The stacked opt-out is what still renders a row per item — the case where a + // track-less subgrid would be visible. + const stackedPorts: JsonSchemaObject = { + type: "object", + properties: { + ports: { + type: "array", + title: "Ports", + "x-array-display": "stacked", + items: { type: "integer" }, + }, + }, + }; + + it("gives a scalar item row the label/value tracks its subgrid inherits", () => { + inlineForm(stackedPorts, { 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", () => { + inlineForm(stackedPorts, { 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", () => { + // Object items render as an accordion unless asked otherwise, and the + // accordion owns its own full-width row — the per-item section this covers + // only exists on the stacked opt-out. + const schema: JsonSchemaObject = { + type: "object", + properties: { + servers: { + type: "array", + title: "Servers", + "x-array-display": "stacked", + 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 3a9fb2d51..f90b8b823 100644 --- a/packages/ui/src/components/json-schema-form-array.tsx +++ b/packages/ui/src/components/json-schema-form-array.tsx @@ -1,35 +1,43 @@ -import { type KeyboardEvent } from "react"; +import { moveItem, removeIndex, setIndex } from "../lib/collections"; import { cn } from "../lib/utils"; import { Icon } from "../data/Icon"; import { FilterPill } from "../data/FilterPill"; -import { UiAdd, UiChevronDown, UiChevronUp, UiClose, UiTrash } from "../icons"; +import { UiAdd } from "../icons"; import { Button } from "./button"; +import { ItemActions } from "./ItemActions"; import { - controlHeightClass, controlMinHeightClass, fieldInnerGapClass, inputSizeClass, - labelSizeClass, type FormSize, } from "./json-schema-form-size"; import { AccordionArray } from "./json-schema-form-accordion-array"; -import { isScalarStringItems } from "./json-schema-form-resolve"; +import { CardsArray } from "./json-schema-form-cards-array"; +import { FieldsGrid } from "./json-schema-form-layout"; +import { scalarItemsType } from "./json-schema-form-resolve"; import { appendInstancePath } from "./json-schema-form-errors"; import { TableArray } from "./json-schema-form-table-array"; +import { TagsComboboxControl } from "./json-schema-form-tags-combobox"; import { - defaultPlaceholder, hasObjectItemProperties, - moveItem, - removeIndex, seedFromSchema, - setIndex, + toStringArray, } from "./json-schema-form-utils"; -import type { FieldControl, RenderContext } from "./json-schema-form-types"; +import type { + FieldControl, + FieldOption, + RenderContext, +} from "./json-schema-form-types"; -// ArrayControl is a hybrid: plain string-item arrays keep the compact tag UI; -// anything richer (objects, numbers, enums, nested arrays) renders one recursive -// control per item with add / remove / reorder. Recursion goes through -// ctx.render so this module never imports the renderer (no import cycle). +// A comma is the second commit key in every tag list, and what a pasted list is +// split on. Newlines split too — see splitOnComboboxSeparators. +const TAG_SEPARATORS = [","]; + +// ArrayControl routes an array to the control its items call for: a flat list of +// values (choices, scalars) is ONE tag combobox; object items are summary rows, +// cards or a table; anything else renders a control per item with add / remove / +// reorder. Recursion goes through ctx.render so this module never imports the +// renderer (no import cycle). export function ArrayControl({ field, fieldId, @@ -43,10 +51,8 @@ export function ArrayControl({ // and item inputs disabled (a child the schema marks readOnly still renders as // a value span). const readOnly = ctx.readOnly || field.readOnly === true; - if ( - field.arrayDisplay === "filter-pills" && - enumItemOptions(field).length > 0 - ) { + const choices = enumItemOptions(field); + if (field.arrayDisplay === "filter-pills" && choices.length > 0) { return ( ); } - // Placed before the scalar-string branch: an array of plain strings has no - // properties to summarize, so it falls through to the compact tag editor even - // if the schema asks for an accordion. - if ( - field.arrayDisplay === "accordion" && - hasObjectItemProperties(field.itemSchema) - ) { - return ; + // A list of choices is ONE control, not a stack of them: the enum item schema + // makes the whole array a multi-select whose committed values are pills, so + // the option set is discoverable in one dropdown instead of repeated per item. + // `x-array-display: "stacked"` opts back into a combobox per item. + if (field.arrayDisplay !== "stacked" && choices.length > 0) { + return ( + + ); } - if (isScalarStringItems(field.itemSchema)) { + // A flat list of scalars is the same gesture as the enum list above — type a + // value, get a pill — so it takes the same control, with no options to pick + // from and a comma as a second commit key. Numeric items commit numbers. + const scalarType = scalarItemsType(field.itemSchema); + if (field.arrayDisplay !== "stacked" && scalarType) { return ( - ); } - // `x-layout: table` renders object-item arrays as compact rows with one column - // per item property — a denser alternative to the per-item stacked sub-form. - if (field.layout === "table" && hasObjectItemProperties(field.itemSchema)) { - return ; + if (hasObjectItemProperties(field.itemSchema)) { + if (field.arrayDisplay === "cards") { + return ; + } + // The accordion is the DEFAULT for object items: stacked in full, a + // ten-property item costs ~700px of screen each and a list of them says + // nothing about which item is which. `x-array-display: "stacked"` opts back + // into the per-item sub-form below; `x-layout: "table"` into the row grid. + // An explicit accordion outranks the table, since it names the renderer. + if ( + field.arrayDisplay === "accordion" || + (field.arrayDisplay !== "stacked" && field.layout !== "table") + ) { + return ; + } + // `x-layout: table` renders object-item arrays as compact rows with one + // column per item property — denser still than the summary rows. + if (field.layout === "table") { + return ; + } } const items = Array.isArray(field.value) ? field.value : []; const itemSchema = field.itemSchema ?? { type: "string" }; @@ -93,7 +128,13 @@ export function ArrayControl({ > {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,22 +147,16 @@ export function ArrayControl({ childCtx, { labelOverride: `Item ${i + 1}` }, )} -
+ {!readOnly && ( - 0 - ? () => field.onChange(moveItem(items, i, i - 1)) - : undefined - } - onDown={ - i < items.length - 1 - ? () => field.onChange(moveItem(items, i, i + 1)) - : undefined - } - onRemove={() => field.onChange(removeIndex(items, i))} + field.onChange(moveItem(items, i, to))} + onRemove={() => field.onChange(removeIndex(items, i))} /> )}
@@ -141,9 +176,10 @@ export function ArrayControl({ ); } -function enumItemOptions( - field: FieldControl, -): Array<{ value: string; label: string }> { +// The choices an array of enum items offers, deduped by value. Prefers the +// resolved `options` (they carry the schema's labels, icons and tones) and falls +// back to the raw item `enum` for a field a pre-extension built by hand. +function enumItemOptions(field: FieldControl): FieldOption[] { const rawOptions = field.options ?? (Array.isArray(field.itemSchema?.enum) @@ -153,10 +189,10 @@ function enumItemOptions( })) : []); const seen = new Set(); - return rawOptions.flatMap((option) => { - if (seen.has(option.value)) return []; + return rawOptions.filter((option) => { + if (seen.has(option.value)) return false; seen.add(option.value); - return [{ value: option.value, label: option.label }]; + return true; }); } @@ -226,137 +262,3 @@ function FilterPillArray({ ); } -function ItemControls({ - onUp, - onDown, - onRemove, - index, - size, -}: { - onUp: (() => void) | undefined; - onDown: (() => void) | undefined; - onRemove: () => void; - index: number; - size: FormSize; -}) { - const actionClassName = 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 ( -
- - - -
- ); -} - -// TagArray is the compact tag editor for plain string-item arrays. -function TagArray({ - field, - fieldId, - readOnly, - size, -}: { - field: FieldControl; - fieldId: string; - readOnly: boolean; - size: FormSize; -}) { - const tags = toStringArray(field.value); - - function commit(raw: string, input: HTMLInputElement) { - const next = raw - .split(",") - .map((p) => p.trim()) - .filter(Boolean); - if (next.length === 0) return; - field.onChange([...tags, ...next]); - input.value = ""; - } - - function handleKeyDown(e: KeyboardEvent) { - const input = e.currentTarget; - if (e.key === "Enter" || e.key === ",") { - e.preventDefault(); - commit(input.value, input); - return; - } - if (e.key === "Backspace" && input.value === "" && tags.length > 0) { - field.onChange(tags.slice(0, -1)); - } - } - - return ( -
- {tags.map((tag, i) => ( - - {tag} - {!readOnly && ( - - )} - - ))} - {!readOnly && ( - commit(e.currentTarget.value, e.currentTarget)} - /> - )} -
- ); -} - -function toStringArray(value: unknown): string[] { - if (Array.isArray(value)) return value.map(String); - return []; -} diff --git a/packages/ui/src/components/json-schema-form-cards-array.test.tsx b/packages/ui/src/components/json-schema-form-cards-array.test.tsx new file mode 100644 index 000000000..0448bec39 --- /dev/null +++ b/packages/ui/src/components/json-schema-form-cards-array.test.tsx @@ -0,0 +1,185 @@ +import { useState } from "react"; +import { fireEvent, render, screen, within } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { JsonSchemaForm } from "./JsonSchemaForm"; +import type { JsonSchemaObject } from "./json-schema-form-types"; + +// One card per parameter, headed by what the parameter is rather than "Item 3". +// `x-item` is the only place the schema says how to summarize an item, so these +// tests drive the cards display entirely through it. +const PARAM_ITEM = { + type: "object" as const, + properties: { + name: { type: "string" as const, title: "Name" }, + kind: { + type: "string" as const, + title: "Kind", + enum: ["string", "number", "list"], + "x-enum-tones": { string: "slate", number: "violet", list: "indigo" }, + }, + required: { type: "boolean" as const, title: "Required" }, + field: { type: "string" as const, title: "Field" }, + }, + required: ["name"], +}; + +const CARDS_SCHEMA: JsonSchemaObject = { + type: "object", + properties: { + params: { + type: "array", + title: "Parameters", + "x-array-display": "cards", + "x-item": { + title: ["name"], + fallback: "Untitled parameter", + summary: [{ property: "field" }], + glyph: "kind", + flag: "required", + noun: "parameter", + nounPlural: "parameters", + }, + items: PARAM_ITEM, + }, + }, +}; + +const SAMPLE = [ + { name: "namespace", kind: "string", required: true, field: "metadata.namespace" }, + { name: "limit", kind: "number", required: false, field: "spec.limit" }, +]; + +function ControlledForm({ initial }: { initial: Record }) { + 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 only the actions x-item.actions lists", () => { + const params = CARDS_SCHEMA.properties!.params!; + const schema: JsonSchemaObject = { + type: "object", + properties: { + params: { + ...params, + "x-item": { ...(params["x-item"] as Record), actions: ["remove"] }, + }, + }, + }; + render( + {}} + showPreferencesMenu={false} + />, + ); + expect(screen.getByRole("button", { name: "Remove limit" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Move limit up" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Duplicate 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 000000000..4f72ef86f --- /dev/null +++ b/packages/ui/src/components/json-schema-form-cards-array.tsx @@ -0,0 +1,135 @@ +import { duplicateIndex, moveItem, removeIndex, setIndex } from "../lib/collections"; +import { cn } from "../lib/utils"; +import { Icon } from "../data/Icon"; +import { UiAdd } from "../icons"; +import { Button } from "./button"; +import { ItemActions } from "./ItemActions"; +import { appendInstancePath } from "./json-schema-form-errors"; +import { + addItemLabel, + emptyItemsCopy, + itemActionsAllow, + itemSummaryFor, + resolveItemSpec, +} from "./json-schema-form-item-summary"; +import { ItemBadge, RequiredMark } from "./json-schema-form-item-row"; +import { inputSizeClass } from "./json-schema-form-size"; +import { TONE_EDGE_CLASS } from "./json-schema-form-tone"; +import { seedFromSchema } 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 && ( + commit(moveItem(items, i, to)) } + : {})} + {...(itemActionsAllow(spec, "duplicate") + ? { onDuplicate: () => commit(duplicateIndex(items, i)) } + : {})} + {...(itemActionsAllow(spec, "remove") + ? { 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-errors.ts b/packages/ui/src/components/json-schema-form-errors.ts index 9ec6b5e87..dd8194c01 100644 --- a/packages/ui/src/components/json-schema-form-errors.ts +++ b/packages/ui/src/components/json-schema-form-errors.ts @@ -1,9 +1,10 @@ import { effectiveProperties, - isScalarStringItems, resolveControl, + scalarItemsType, } from "./json-schema-form-resolve"; -import { isPlainObject, matchesFieldFilter } from "./json-schema-form-utils"; +import { isPlainObject } from "../lib/collections"; +import { matchesFieldFilter } from "./json-schema-form-utils"; import type { JsonSchemaFormError } from "./json-schema-form-error-types"; import type { FieldControl, @@ -20,6 +21,20 @@ export function appendInstancePath( return `${base}/${escaped}`; } +// How many errors sit at or below a subtree. A collapsed container (an +// accordion row) renders none of the controls that would show them, so it +// reports the count on its own header instead. +export function errorCountUnderInstancePath( + errors: JsonSchemaFormError[], + instancePath: string +): number { + return errors.filter( + (error) => + error.instancePath === instancePath || + error.instancePath.startsWith(`${instancePath}/`) + ).length; +} + export function errorsAtInstancePath( errors: JsonSchemaFormError[], instancePath: string @@ -169,9 +184,14 @@ function collectArrayPaths( paths: Set, options: CollectOptions ) { + // Mirrors ArrayControl's branch order: a flat list of values (pills, choices + // or scalars) is ONE control, with no per-item field to hang a message on, so + // its item paths stay unmatched and the error surfaces in the form summary + // instead of pointing at nothing. + if (field.arrayDisplay === "filter-pills") return; if ( - field.arrayDisplay === "filter-pills" || - isScalarStringItems(field.itemSchema) + field.arrayDisplay !== "stacked" && + (hasEnumItems(field) || scalarItemsType(field.itemSchema)) ) { return; } @@ -212,6 +232,13 @@ function collectArrayPaths( } } +// Mirrors ArrayControl's tags branch: resolved options win over the raw item +// enum, since a pre-extension may have supplied them. +function hasEnumItems(field: FieldControl): boolean { + if (field.options && field.options.length > 0) return true; + return Array.isArray(field.itemSchema?.enum) && field.itemSchema.enum.length > 0; +} + function collectMapPaths( field: FieldControl, instancePath: string, diff --git a/packages/ui/src/components/json-schema-form-fields.tsx b/packages/ui/src/components/json-schema-form-fields.tsx index ed51f743b..abfa25a76 100644 --- a/packages/ui/src/components/json-schema-form-fields.tsx +++ b/packages/ui/src/components/json-schema-form-fields.tsx @@ -6,6 +6,7 @@ import { DateTimePicker } from "./DateTimePicker"; import { SegmentedControl, type SegmentedSize } from "./SegmentedControl"; import { GridControl } from "./json-schema-form-grid"; import { LookupTreeControl } from "./json-schema-form-lookup-tree"; +import { TagsComboboxControl } from "./json-schema-form-tags-combobox"; import { resolveLookupScope, useLookupFetcher } from "./form-lookup-context"; import type { FieldControl, FieldOption } from "./json-schema-form-types"; import { @@ -473,6 +474,24 @@ export function LookupControl({ ); } + // A multi lookup commits an ARRAY, so it takes the tags picker rather than the + // single-value combobox below — which would render the array as an empty box + // and then overwrite it with a bare string. Without a fetcher the same control + // renders the already-committed values with an empty option set. + if (descriptor?.multi) { + return ( + + ); + } + if (!descriptor || !fetcher) { return ( { tags: { type: "array", title: "Tags", + // Stacked: the "Item N" rows this covers are that opt-out's doing. + "x-array-display": "stacked", items: { type: "number", title: "Tag", description: LABEL_HELP }, }, }, 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 000000000..3b26dcb5c --- /dev/null +++ b/packages/ui/src/components/json-schema-form-item-row.tsx @@ -0,0 +1,62 @@ +import { cn } from "../lib/utils"; +import { Icon, LabelIcon } from "../data/Icon"; +import { UiAsterisk, UiWarningCircle } from "../icons"; +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} + + ); +} + +// A collapsed row hides the controls that would carry a validation message, so +// it says how many errors are inside it instead — an error the reader cannot +// see is an error they cannot fix. +export function ItemErrorMark({ count }: { count: number }) { + if (count <= 0) return null; + const label = count === 1 ? "1 error" : `${count} errors`; + return ( + + + {label} + + ); +} + +export function RequiredMark() { + return ( + + + + ); +} + diff --git a/packages/ui/src/components/json-schema-form-item-summary.test.ts b/packages/ui/src/components/json-schema-form-item-summary.test.ts index 04a146cb8..c13babcfe 100644 --- a/packages/ui/src/components/json-schema-form-item-summary.test.ts +++ b/packages/ui/src/components/json-schema-form-item-summary.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { addItemLabel, emptyItemsCopy, + itemActionsAllow, itemCountLabel, itemSummaryFor, noItemsLabel, @@ -108,6 +109,35 @@ describe("resolveItemSpec", () => { ); expect(spec.summary).toEqual(["name"]); }); + + it("leaves actions unset when the schema is silent, so every action is offered", () => { + const spec = resolveItemSpec({ type: "array" }, PARAM_ITEM); + expect(spec.actions).toBeUndefined(); + expect(itemActionsAllow(spec, "reorder")).toBe(true); + expect(itemActionsAllow(spec, "duplicate")).toBe(true); + expect(itemActionsAllow(spec, "remove")).toBe(true); + }); + + it("keeps an explicitly empty actions list, which offers none", () => { + const spec = resolveItemSpec( + { type: "array", "x-item": { actions: [] } as ArrayItemSpec }, + PARAM_ITEM, + ); + expect(spec.actions).toEqual([]); + expect(itemActionsAllow(spec, "remove")).toBe(false); + }); + + it("drops actions it does not implement rather than trusting the schema", () => { + const spec = resolveItemSpec( + { + type: "array", + "x-item": { actions: ["remove", "launch-missiles", 7] } as unknown as ArrayItemSpec, + }, + PARAM_ITEM, + ); + expect(spec.actions).toEqual(["remove"]); + expect(itemActionsAllow(spec, "reorder")).toBe(false); + }); }); describe("itemSummaryFor", () => { diff --git a/packages/ui/src/components/json-schema-form-item-summary.ts b/packages/ui/src/components/json-schema-form-item-summary.ts index d0b93be72..8ffd7f130 100644 --- a/packages/ui/src/components/json-schema-form-item-summary.ts +++ b/packages/ui/src/components/json-schema-form-item-summary.ts @@ -1,6 +1,7 @@ +import { isPlainObject } from "../lib/collections"; import { normalizeTone } from "./json-schema-form-tone"; -import { isPlainObject } from "./json-schema-form-utils"; import type { + ArrayItemAction, ArrayItemSpec, ArrayItemSummary, ArrayItemSummaryPart, @@ -35,12 +36,30 @@ export function resolveItemSpec( ...(glyph ? { glyph } : {}), ...(typeof raw.badge === "string" && raw.badge ? { badge: raw.badge } : {}), ...(typeof raw.flag === "string" && raw.flag ? { flag: raw.flag } : {}), + // Only carried when the schema actually said something: an absent key means + // every action, while an explicit empty list means none, and those two + // cannot be told apart once the default has been filled in. + ...(Array.isArray(raw.actions) ? { actions: itemActions(raw.actions) } : {}), noun: firstNonEmpty([raw.noun, itemSchema?.title]) ?? "item", nounPlural: firstNonEmpty([raw.nounPlural, arraySchema.title]) ?? "items", ...(typeof raw.empty === "string" && raw.empty ? { empty: raw.empty } : {}), }; } +const ITEM_ACTIONS: ArrayItemAction[] = ["reorder", "duplicate", "remove"]; + +function itemActions(value: unknown[]): ArrayItemAction[] { + return value.filter((entry): entry is ArrayItemAction => + ITEM_ACTIONS.includes(entry as ArrayItemAction), + ); +} + +// itemActionsAllow is the one question every array display asks of `x-item`, so +// the accordion and the cards never disagree about what a schema permitted. +export function itemActionsAllow(spec: ArrayItemSpec, action: ArrayItemAction): boolean { + return spec.actions ? spec.actions.includes(action) : true; +} + // autoGlyphKey picks the item property that can colour a row without being told: // the first enum carrying per-value icons. Keeps a schema that already declares // x-enum-icons from having to repeat itself in x-item. diff --git a/packages/ui/src/components/json-schema-form-lookup-tree.tsx b/packages/ui/src/components/json-schema-form-lookup-tree.tsx index df3c66eb5..070772c33 100644 --- a/packages/ui/src/components/json-schema-form-lookup-tree.tsx +++ b/packages/ui/src/components/json-schema-form-lookup-tree.tsx @@ -8,6 +8,7 @@ import { } from "../lib/path-tree"; import { cn } from "../lib/utils"; import { TreePickerField } from "./TreePickerField"; +import { toStringArray } from "./json-schema-form-utils"; import type { FormSize } from "./json-schema-form-size"; import type { FieldControl, @@ -32,14 +33,6 @@ function treeFrom( ); } -// toValues normalises the committed value of a multi lookup. The schema type is -// an array of strings, but a form mid-edit can hold anything, so anything that -// is not a usable string is dropped rather than rendered as a broken chip. -function toValues(value: unknown): string[] { - if (!Array.isArray(value)) return []; - return value.filter((entry): entry is string => typeof entry === "string"); -} - function OptionRow({ node }: { node: OptionNode }) { const option = optionOf(node); return ( @@ -117,7 +110,7 @@ export function LookupTreeControl({ ); } - const values = toValues(field.value); + const values = toStringArray(field.value); return (
{values.length > 0 && ( diff --git a/packages/ui/src/components/json-schema-form-object.tsx b/packages/ui/src/components/json-schema-form-object.tsx index 279c68e64..7bd93cc05 100644 --- a/packages/ui/src/components/json-schema-form-object.tsx +++ b/packages/ui/src/components/json-schema-form-object.tsx @@ -1,4 +1,5 @@ import { type ReactNode } from "react"; +import { isPlainObject } from "../lib/collections"; import { cn } from "../lib/utils"; import { Icon } from "../data/Icon"; import { UiAdd, UiTrash } from "../icons"; @@ -10,7 +11,6 @@ import { DEFAULT_COLUMN_MIN_WIDTH, fieldInputId, inputClass, - isPlainObject, keyPickerOptions, normalizeColumns, } from "./json-schema-form-utils"; diff --git a/packages/ui/src/components/json-schema-form-render.tsx b/packages/ui/src/components/json-schema-form-render.tsx index 15e8e7346..3a2341191 100644 --- a/packages/ui/src/components/json-schema-form-render.tsx +++ b/packages/ui/src/components/json-schema-form-render.tsx @@ -39,6 +39,7 @@ import type { import { fieldErrorId, fieldInputId, + hasObjectItemProperties, matchesFieldFilter, normalizeColSpan, normalizeColumns, @@ -247,11 +248,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"; + // An object-item array is a summary-row list (or cards) unless it opts out, + // and each of those renderers owns the row — mirrors ArrayControl's own + // branching, which decides the same thing from the resolved FieldControl. + if (hasObjectItemProperties(prop.items) && prop["x-array-display"] !== "stacked") { + return true; + } + return prop["x-layout"] === "table"; } // renderApi is the RenderContext injection bundle: the root form stores it on diff --git a/packages/ui/src/components/json-schema-form-resolve.test.ts b/packages/ui/src/components/json-schema-form-resolve.test.ts index 93d3d9260..ba9201e16 100644 --- a/packages/ui/src/components/json-schema-form-resolve.test.ts +++ b/packages/ui/src/components/json-schema-form-resolve.test.ts @@ -5,9 +5,9 @@ import { effectiveProperties, enumBranch, isOpenStringMap, - isScalarStringItems, matchesIf, resolveControl, + scalarItemsType, } from "./json-schema-form-resolve"; import type { JsonSchemaObject, JsonSchemaProperty } from "./json-schema-form-types"; @@ -561,18 +561,27 @@ describe("isOpenStringMap", () => { }); }); -describe("isScalarStringItems", () => { - it("is true for plain string items and for untyped items", () => { - expect(isScalarStringItems({ type: "string" })).toBe(true); - expect(isScalarStringItems(undefined)).toBe(true); +describe("scalarItemsType", () => { + it("names the scalar an item holds, defaulting untyped items to string", () => { + expect(scalarItemsType({ type: "string" })).toBe("string"); + expect(scalarItemsType(undefined)).toBe("string"); }); - it("is false for enum items (need per-item combobox)", () => { - expect(isScalarStringItems({ type: "string", enum: ["a", "b"] })).toBe(false); + it("carries the numeric types through, so the list can commit numbers", () => { + expect(scalarItemsType({ type: "integer" })).toBe("integer"); + expect(scalarItemsType({ type: "number" })).toBe("number"); }); - it("is false for object / array / non-string items", () => { - expect(isScalarStringItems({ type: "object", properties: {} })).toBe(false); - expect(isScalarStringItems({ type: "array", items: { type: "string" } })).toBe(false); - expect(isScalarStringItems({ type: "number" })).toBe(false); + it("is undefined for enum items (a list of choices, not free text)", () => { + expect(scalarItemsType({ type: "string", enum: ["a", "b"] })).toBeUndefined(); + expect(scalarItemsType({ type: "integer", enum: [80, 443] })).toBeUndefined(); + expect(scalarItemsType({ type: "string", const: "a" })).toBeUndefined(); + }); + it("is undefined for items that need a control of their own", () => { + expect(scalarItemsType({ type: "object", properties: {} })).toBeUndefined(); + expect(scalarItemsType({ type: "array", items: { type: "string" } })).toBeUndefined(); + expect(scalarItemsType({ type: "boolean" })).toBeUndefined(); + expect( + scalarItemsType({ type: "string", additionalProperties: { type: "string" } }), + ).toBeUndefined(); }); }); diff --git a/packages/ui/src/components/json-schema-form-resolve.ts b/packages/ui/src/components/json-schema-form-resolve.ts index b5d44bc11..fc40a95fd 100644 --- a/packages/ui/src/components/json-schema-form-resolve.ts +++ b/packages/ui/src/components/json-schema-form-resolve.ts @@ -8,11 +8,12 @@ import type { JsonSchemaObject, JsonSchemaProperty, LookupDescriptor, + ScalarItemType, } from "./json-schema-form-types"; +import { isPlainObject } from "../lib/collections"; import { LabelIcon } from "../data/Icon"; import { resolveItemSpec } from "./json-schema-form-item-summary"; import { isFieldTone } from "./json-schema-form-tone"; -import { isPlainObject } from "./json-schema-form-utils"; // isOpenStringMap reports whether a property is an object whose entries are // described by a sub-schema in `additionalProperties` (a typed key/value map) or @@ -112,7 +113,9 @@ 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 === "stacked" + ? d + : undefined; } // helpDisplay reads the per-field `x-help-display` override. Returns undefined @@ -340,7 +343,10 @@ export function resolveControl(args: ResolveControlArgs): FieldControl { // add row, so a paragraph of the same sentence directly above would be a // literal duplicate. The schema can still say otherwise. ...(display === "accordion" && !helpMode ? { helpDisplay: "hover" as const } : {}), - ...(display && itemSchema && Array.isArray(itemSchema.enum) + // An enum item schema makes the whole array a list of choices, whichever + // display renders it (tags by default, filter pills on request), so the + // options — labels, icons, tones and all — always resolve. + ...(itemSchema && Array.isArray(itemSchema.enum) ? { options: enumOptions(itemSchema) } : {}), ...(layout ? { layout } : {}), @@ -416,23 +422,29 @@ function keyOptionsFor(prop: JsonSchemaProperty): FieldOption[] | undefined { return enumOptions(pn); } -// isScalarStringItems reports whether an array's item schema is a plain string -// with no richer shape — the case the array control renders as compact tags. -// Anything with an enum/const/properties/items/additionalProperties/allOf needs -// a real per-item control instead. -export function isScalarStringItems( +// scalarItemsType returns the scalar type an array's items hold — the case the +// array renders as ONE tag list rather than a control per item — or undefined +// when the items need a real per-item control. Anything carrying an +// enum/const/properties/items/additionalProperties/allOf has a richer shape (an +// enum array is a list of choices, and takes the option-backed branch instead). +export function scalarItemsType( items: JsonSchemaProperty | undefined, -): boolean { - if (!items) return true; // untyped items default to string tags - if (items.type !== undefined && !schemaHasType(items, "string")) return false; - return ( - items.enum === undefined && - items.const === undefined && - items.properties === undefined && - items.items === undefined && - items.additionalProperties === undefined && - items.allOf === undefined - ); +): ScalarItemType | undefined { + if (!items) return "string"; // untyped items default to string tags + if ( + items.enum !== undefined || + items.const !== undefined || + items.properties !== undefined || + items.items !== undefined || + items.additionalProperties !== undefined || + items.allOf !== undefined + ) { + return undefined; + } + if (items.type === undefined || schemaHasType(items, "string")) return "string"; + if (schemaHasType(items, "integer")) return "integer"; + if (schemaHasType(items, "number")) return "number"; + return undefined; } // matchesIf reports whether the `if` sub-schema holds for the current value: diff --git a/packages/ui/src/components/json-schema-form-table-array.tsx b/packages/ui/src/components/json-schema-form-table-array.tsx index f70421c0d..928e10d51 100644 --- a/packages/ui/src/components/json-schema-form-table-array.tsx +++ b/packages/ui/src/components/json-schema-form-table-array.tsx @@ -1,4 +1,5 @@ import { type ReactNode } from "react"; +import { isPlainObject, removeIndex, setIndex } from "../lib/collections"; import { cn } from "../lib/utils"; import { Icon } from "../data/Icon"; import { UiAdd, UiTrash } from "../icons"; @@ -11,12 +12,9 @@ import { import { schemaHelper } from "./json-schema-form-resolve"; import { appendInstancePath } from "./json-schema-form-errors"; import { - isPlainObject, orderByClickyOrder, orderByXOrder, - removeIndex, seedFromSchema, - setIndex, } from "./json-schema-form-utils"; import type { FieldControl, diff --git a/packages/ui/src/components/json-schema-form-tags-combobox.test.tsx b/packages/ui/src/components/json-schema-form-tags-combobox.test.tsx new file mode 100644 index 000000000..273023328 --- /dev/null +++ b/packages/ui/src/components/json-schema-form-tags-combobox.test.tsx @@ -0,0 +1,248 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { useState } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { JsonSchemaForm } from "./JsonSchemaForm"; +import type { + JsonSchemaObject, + LookupFetcher, +} from "./json-schema-form-types"; + +// Both array shapes that hold a list of choices reach the same control: an +// `enum` item schema (options in the schema) and an `x-clicky-lookup` with +// `multi: true` (options fetched). These tests drive it through JsonSchemaForm, +// since the wiring — the resolved options, the fetcher context — is the point. + +const PROFILES = ["jms", "logs.api", "remote-debugger"]; + +const fetcher: LookupFetcher = async ({ query }) => + PROFILES.filter((name) => name.includes(query)).map((name) => ({ + value: name, + label: name, + })); + +const lookupSchema: JsonSchemaObject = { + type: "object", + properties: { + imports: { + type: "array", + title: "Imports", + items: { type: "string" }, + "x-clicky-lookup": { url: "/api/v1/profiles", filter: "profile", multi: true }, + }, + }, +}; + +const enumSchema: JsonSchemaObject = { + type: "object", + properties: { + roles: { + type: "array", + title: "Roles", + items: { + type: "string", + enum: ["admin", "editor", "viewer"], + "x-enum-labels": { admin: "Administrator" }, + }, + }, + }, +}; + +// A stateful host: a pill only disappears once the committed value actually +// changes, which a stateless render can never show. +function Harness({ + schema, + initial, + onChange, + lookupFetcher, +}: { + schema: JsonSchemaObject; + initial: Record; + onChange: (next: Record) => void; + lookupFetcher?: LookupFetcher; +}) { + const [value, setValue] = useState(initial); + return ( + { + setValue(next); + onChange(next); + }} + showPreferencesMenu={false} + {...(lookupFetcher ? { lookupFetcher } : {})} + /> + ); +} + +// Focus opens the menu, which works while a lookup's head set is still loading +// (the toggle button is a spinner then). The menu commits on mousedown. +function openMenu() { + fireEvent.focus(screen.getByRole("combobox")); +} + +function select(name: string) { + fireEvent.mouseDown(screen.getByRole("option", { name })); +} + +describe("enum array as a tags combobox", () => { + it("renders each committed value as a pill under the schema's label", () => { + render( + , + ); + // The pill reads the enum's label, exactly as the single-value combobox + // renders a chosen option. + expect(screen.getByText("Administrator (admin)")).toBeInTheDocument(); + expect(screen.getByText("viewer")).toBeInTheDocument(); + }); + + it("removes the value its pill names", () => { + const onChange = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByRole("button", { name: "Remove viewer" })); + expect(onChange).toHaveBeenCalledWith({ roles: ["admin"] }); + }); + + it("offers every option, including the ones already selected", () => { + render(); + openMenu(); + expect(screen.getAllByRole("option").map((o) => o.textContent)).toEqual([ + "Administrator (admin)", + "editor", + "viewer", + ]); + }); +}); + +// A list of scalars has no options at all: typing IS how a value is added. The +// numeric case has to survive the round trip through the pill's text. +describe("scalar array as a tags combobox", () => { + const portsSchema: JsonSchemaObject = { + type: "object", + properties: { + ports: { type: "array", title: "Ports", items: { type: "integer" } }, + }, + }; + + function typeAndCommit(text: string, key = "Enter") { + const input = screen.getByRole("combobox"); + fireEvent.focus(input); + fireEvent.change(input, { target: { value: text } }); + fireEvent.keyDown(input, { key }); + } + + it("renders committed numbers as pills", () => { + render(); + expect(screen.getByRole("button", { name: "Remove 80" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Remove 443" })).toBeInTheDocument(); + }); + + it("commits typed digits as a number, not a string", () => { + const onChange = vi.fn(); + render(); + typeAndCommit("443"); + expect(onChange).toHaveBeenCalledWith({ ports: [80, 443] }); + }); + + it("keeps text that is not a number, so a template token survives", () => { + const onChange = vi.fn(); + render(); + typeAndCommit("{{.params.port}}"); + expect(onChange).toHaveBeenCalledWith({ ports: ["{{.params.port}}"] }); + }); + + it("commits on a comma as well as Enter", () => { + const onChange = vi.fn(); + render(); + typeAndCommit("8080", ","); + expect(onChange).toHaveBeenCalledWith({ ports: [8080] }); + }); + + it("splits a pasted list into one pill per value", () => { + const onChange = vi.fn(); + render(); + fireEvent.paste(screen.getByRole("combobox"), { + clipboardData: { getData: () => "80, 443,\n8080" }, + }); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith({ ports: [80, 443, 8080] }); + }); + + it("drops the last value on Backspace with an empty query", () => { + const onChange = vi.fn(); + render(); + const input = screen.getByRole("combobox"); + fireEvent.focus(input); + fireEvent.keyDown(input, { key: "Backspace" }); + expect(onChange).toHaveBeenCalledWith({ ports: [80] }); + }); + + it("keeps a string list on strings", () => { + const onChange = vi.fn(); + render( + , + ); + typeAndCommit("engine"); + expect(onChange).toHaveBeenCalledWith({ tags: ["math", "engine"] }); + }); +}); + +describe("multi lookup as a tags combobox", () => { + it("keeps the committed values visible instead of dropping the array", async () => { + render( + , + ); + // The head set has not resolved yet, so this can only come from the value. + expect(screen.getByText("jms")).toBeInTheDocument(); + }); + + it("commits an array of the fetched values, never a bare string", async () => { + const onChange = vi.fn(); + render( + , + ); + openMenu(); + await waitFor(() => + expect(screen.getByRole("option", { name: "logs.api" })).toBeInTheDocument(), + ); + select("logs.api"); + expect(onChange).toHaveBeenCalledWith({ imports: ["jms", "logs.api"] }); + }); + + it("searches the server as the query is typed", async () => { + const spy = vi.fn(fetcher); + render( + , + ); + const input = screen.getByRole("combobox"); + fireEvent.focus(input); + fireEvent.change(input, { target: { value: "debug" } }); + await waitFor(() => + expect(spy).toHaveBeenCalledWith(expect.objectContaining({ query: "debug" })), + ); + await waitFor(() => + expect(screen.getByRole("option", { name: "remote-debugger" })).toBeInTheDocument(), + ); + }); +}); diff --git a/packages/ui/src/components/json-schema-form-tags-combobox.tsx b/packages/ui/src/components/json-schema-form-tags-combobox.tsx new file mode 100644 index 000000000..426fd0ca5 --- /dev/null +++ b/packages/ui/src/components/json-schema-form-tags-combobox.tsx @@ -0,0 +1,106 @@ +import { Combobox } from "./Combobox"; +import { withSelectedComboboxOptions } from "./combobox-utils"; +import { + comboboxAriaProps, + defaultPlaceholder, +} from "./json-schema-form-utils"; +import type { FormSize } from "./json-schema-form-size"; +import type { + FieldControl, + FieldOption, + ScalarItemType, +} from "./json-schema-form-types"; + +// TagsComboboxControl is the multi-value picker: one field whose committed +// values are removable pills. It serves every array that holds a flat list of +// values — an `enum` item schema (options in the schema), an `x-clicky-lookup` +// with `multi: true` (options fetched, with server-side search), and a plain +// list of scalars (no options at all, so typing IS how a value is added) — so +// all three look and behave identically. +// +// The value it commits is ALWAYS an array. That is the difference from +// EnumControl/LookupControl, which own the single-value case. +export function TagsComboboxControl({ + field, + fieldId, + readOnly, + size, + options, + itemType, + separators, + loading, + onSearch, +}: { + field: FieldControl; + fieldId: string; + readOnly: boolean; + size: FormSize; + options: FieldOption[]; + /** The scalar the items hold, when the list is free-form rather than a choice + * set. Turns on free entry and converts numeric text on commit. */ + itemType?: ScalarItemType; + /** Characters that commit the typed text, alongside Enter (see Combobox). */ + separators?: string[]; + loading?: boolean; + onSearch?: (query: string) => void; +}) { + const values = toTagValues(field.value); + return ( + field.onChange(next.map((v) => coerceScalarItem(v, itemType)))} + disabled={readOnly} + size={size} + // An enum (or a strict lookup) is a closed set: typed text matching no + // option is discarded rather than committed. A free scalar list is the + // opposite — there is nothing to match against. A pre-extension wins over + // both. + allowCustomValue={field.allowCustomValue ?? itemType !== undefined} + prefix={field.prefix} + suffix={field.suffix} + {...(separators ? { separators } : {})} + {...(loading !== undefined ? { loading } : {})} + {...(onSearch ? { onSearch } : {})} + {...comboboxAriaProps(field, fieldId)} + {...(field.inputClassName ? { className: field.inputClassName } : {})} + {...(defaultPlaceholder(field.schema) + ? { placeholder: defaultPlaceholder(field.schema) } + : {})} + /> + ); +} + +// The pills a committed value renders as. A numeric list holds numbers, and a +// tag is text, so both scalars render as their own text — anything else (an +// object mid-edit, a null) has no legible pill and is dropped. +function toTagValues(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value + .filter( + (entry): entry is string | number => + typeof entry === "string" || typeof entry === "number", + ) + .map(String); +} + +// coerceScalarItem converts a pill's text back to the type the items declare. +// Text that does not parse as a number stays a string even in a numeric list, so +// a template token survives — the same allowance NumberControl makes. The +// round-trip is stable: 80 renders as "80" and commits as 80. +function coerceScalarItem( + raw: string, + itemType: ScalarItemType | undefined, +): string | number { + if (itemType !== "integer" && itemType !== "number") return raw; + const trimmed = raw.trim(); + if (trimmed === "" || !Number.isFinite(Number(trimmed))) return raw; + return Number(trimmed); +} diff --git a/packages/ui/src/components/json-schema-form-types.ts b/packages/ui/src/components/json-schema-form-types.ts index 0b2d336ba..224937074 100644 --- a/packages/ui/src/components/json-schema-form-types.ts +++ b/packages/ui/src/components/json-schema-form-types.ts @@ -71,8 +71,11 @@ 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. Object items default to the accordion, so this is + // only needed to say "cards" or to opt out with "stacked". "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 +214,23 @@ 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"; +// The scalar an array's items hold, when they hold one. A list of scalars is a +// tag list: type a value, get a pill — whatever the type. Integer/number lists +// commit numbers, so the control converts on the way in and out. +export type ScalarItemType = "string" | "integer" | "number"; + +// 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. An object-item array +// renders as an accordion without being asked; "stacked" is the opt-out back to +// one full sub-form per item, labelled *Item N*. +export type ArrayDisplay = "filter-pills" | "accordion" | "cards" | "stacked"; + +// A per-item editing action an object-array row can offer. "reorder" covers both +// the up and the down button — a list where only one direction moved would be a +// list you cannot put back. +export type ArrayItemAction = "reorder" | "duplicate" | "remove"; // 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 @@ -233,6 +251,11 @@ export interface ArrayItemSpec { badge?: string; /** Boolean property rendered as the required mark. */ flag?: string; + /** + * Which per-item actions the rows offer. Omit for all of them (the default); + * `[]` for a list that can only be read and reordered by no one. + */ + actions?: ArrayItemAction[]; /** Noun for the add row ("Add parameter"). Defaults to `items.title`, then "item". */ noun?: string; /** Plural for the count line ("4 parameters"). Defaults to the array title, then "items". */ diff --git a/packages/ui/src/components/json-schema-form-utils.test.ts b/packages/ui/src/components/json-schema-form-utils.test.ts index 90652bae9..11c7cc60e 100644 --- a/packages/ui/src/components/json-schema-form-utils.test.ts +++ b/packages/ui/src/components/json-schema-form-utils.test.ts @@ -3,14 +3,11 @@ import { fieldInputId, isEmptyValue, matchesFieldFilter, - moveItem, normalizeColSpan, normalizeColumns, orderByPriority, orderRequiredFirst, - removeIndex, seedFromSchema, - setIndex, softError, } from "./json-schema-form-utils"; import type { FieldControl } from "./json-schema-form-types"; @@ -28,29 +25,6 @@ function field(over: Partial): FieldControl { }; } -describe("immutable array helpers", () => { - it("setIndex replaces one element without mutating the source", () => { - const src = [1, 2, 3]; - const out = setIndex(src, 1, 9); - expect(out).toEqual([1, 9, 3]); - expect(src).toEqual([1, 2, 3]); - }); - - it("removeIndex drops one element without mutating the source", () => { - const src = ["a", "b", "c"]; - const out = removeIndex(src, 0); - expect(out).toEqual(["b", "c"]); - expect(src).toEqual(["a", "b", "c"]); - }); - - it("moveItem reorders and is a no-op past the boundaries", () => { - expect(moveItem([1, 2, 3], 0, 1)).toEqual([2, 1, 3]); - expect(moveItem([1, 2, 3], 2, 1)).toEqual([1, 3, 2]); - expect(moveItem([1, 2, 3], 0, -1)).toEqual([1, 2, 3]); - expect(moveItem([1, 2, 3], 2, 3)).toEqual([1, 2, 3]); - }); -}); - describe("orderRequiredFirst", () => { const entries: [string, number][] = [ ["a", 1], diff --git a/packages/ui/src/components/json-schema-form-utils.ts b/packages/ui/src/components/json-schema-form-utils.ts index 99faa2e91..af9674b58 100644 --- a/packages/ui/src/components/json-schema-form-utils.ts +++ b/packages/ui/src/components/json-schema-form-utils.ts @@ -92,8 +92,12 @@ export function toText(value: unknown): string { return String(value); } -export function isPlainObject(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); +// toStringArray normalises the committed value of a multi-value control. The +// schema types it as an array of strings, but a form mid-edit can hold anything, +// so a non-string entry is dropped rather than rendered as a broken tag. +export function toStringArray(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value.filter((entry): entry is string => typeof entry === "string"); } // hasObjectItemProperties reports whether an array's items are objects with a @@ -143,21 +147,6 @@ export function cssLength(value: unknown, fallback: string): string { return typeof value === "string" && value.trim() ? value.trim() : fallback; } -// duplicateIndex copies the item at `index` and inserts the copy after it. -// Plain objects and arrays are cloned one level deep so editing the copy cannot -// write through to the original. -export function duplicateIndex(items: T[], index: number): T[] { - const source = items[index]; - const copy = Array.isArray(source) - ? ([...source] as T) - : isPlainObject(source) - ? ({ ...source } as T) - : source; - const next = [...items]; - next.splice(index + 1, 0, copy as T); - return next; -} - // withSyntheticValue prepends the current value as an option when it is not in // the enum, so an out-of-enum value (e.g. a template token) still displays. export function withSyntheticValue(options: FieldOption[], value: string): FieldOption[] { @@ -285,23 +274,6 @@ export function softError(field: FieldControl): string | undefined { return undefined; } -// Immutable array helpers used by the array control. -export function setIndex(arr: T[], i: number, v: T): T[] { - return arr.map((x, idx) => (idx === i ? v : x)); -} - -export function removeIndex(arr: T[], i: number): T[] { - return arr.filter((_, idx) => idx !== i); -} - -export function moveItem(arr: T[], from: number, to: number): T[] { - if (to < 0 || to >= arr.length) return arr; - const next = [...arr]; - const [moved] = next.splice(from, 1); - next.splice(to, 0, moved as T); - return next; -} - // seedFromSchema produces the initial value for a freshly-added array item or // object field, honouring an explicit default. export function seedFromSchema(schema: JsonSchemaProperty): unknown { diff --git a/packages/ui/src/components/jsonPathTree.ts b/packages/ui/src/components/jsonPathTree.ts index dea91514f..502fa75b1 100644 --- a/packages/ui/src/components/jsonPathTree.ts +++ b/packages/ui/src/components/jsonPathTree.ts @@ -38,10 +38,28 @@ export interface JSONPathNode { * `source`, so the two have to be committed together. */ root?: string; + /** + * The JSON-in-a-string boundaries crossed on the way down to this node, + * innermost first. `root` names only the top-level column, because that is + * what the backend's `source` takes — which loses the rest of the chain when + * the encoded value is nested, and says nothing about what it decoded to. + * A caller rebuilding an accessor needs both. + */ + origin?: JSONPathOrigin; /** Number of entries the node holds, before any paging. */ childCount: number; } +/** One JSON-in-a-string boundary: where it was, and what came out of it. */ +export interface JSONPathOrigin { + /** Path of the string node the document was decoded out of, in its own scope. */ + path: string; + /** Kind of the decoded document — which decoder crosses this boundary. */ + kind: "array" | "object"; + /** The boundary enclosing this one, when JSON is encoded inside JSON. */ + outer?: JSONPathOrigin; +} + export interface BuildJSONPathNodeOptions { maxDepth?: number; maxObjectProperties?: number; @@ -188,14 +206,31 @@ export function createLazyJSONPathTree( // decoded out of it when it holds JSON as text. An embedded document restarts // at `$` because that is where the backend evaluates it from once the column // is named as a `source`. - function contents(node: JSONPathNode): { value: unknown; path: string; root?: string } { + function contents(node: JSONPathNode): { + value: unknown; + path: string; + root?: string; + origin?: JSONPathOrigin; + } { const inner = embedded(node); - const scope: { value: unknown; path: string; root?: string } = + const scope: { value: unknown; path: string; root?: string; origin?: JSONPathOrigin } = inner === undefined ? { value: node.value, path: node.path } : { value: inner, path: "$" }; const root = inner === undefined ? node.root : node.root ?? sourceColumn(node.path); if (root !== undefined) scope.root = root; + // A boundary is only visible here: this is the one place that knows both + // which node held the text and what the text decoded to. embedded() has + // already rejected anything scalar, so the kind is array or object. + const origin = + inner === undefined + ? node.origin + : { + path: node.path, + kind: jsonPathKind(inner) as "array" | "object", + ...(node.origin ? { outer: node.origin } : {}), + }; + if (origin !== undefined) scope.origin = origin; return scope; } @@ -204,6 +239,7 @@ export function createLazyJSONPathTree( path: string, key: string, root: string | undefined, + origin: JSONPathOrigin | undefined, ): JSONPathNode { const cached = nodes.get(key); if (cached) return cached; @@ -216,6 +252,7 @@ export function createLazyJSONPathTree( childCount: entryCount(value), }; if (root !== undefined) created.root = root; + if (origin !== undefined) created.origin = origin; nodes.set(key, created); return created; } @@ -235,7 +272,7 @@ export function createLazyJSONPathTree( const path = appendJSONPath(scope.path, key); // The key namespaces on the parent so an embedded document — which // restarts its paths at `$` — cannot collide with the outer row. - return node(value, path, `${parent.key}>${path}`, scope.root); + return node(value, path, `${parent.key}>${path}`, scope.root, scope.origin); }); if (limit < entries.length) { children.push({ @@ -258,7 +295,7 @@ export function createLazyJSONPathTree( return inner !== undefined && entryCount(inner) > 0; } - const rootNode = node(json, "$", `${keyPrefix}$`, undefined); + const rootNode = node(json, "$", `${keyPrefix}$`, undefined, undefined); return { roots: json === undefined ? [] : [rootNode], 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 000000000..f1fa75414 --- /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 000000000..747a7f231 --- /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/components/workload-picker-utils.tsx b/packages/ui/src/components/workload-picker-utils.tsx index 6003deaf5..8e77da325 100644 --- a/packages/ui/src/components/workload-picker-utils.tsx +++ b/packages/ui/src/components/workload-picker-utils.tsx @@ -2,16 +2,24 @@ import { type ComponentType } from "react"; import { K8SService, K8SIngress, + K8SPod, K8SDeployment, K8SStatefulset, } from "@flanksource/icons/mi"; import type { ComboboxOption } from "./Combobox"; +import { DaemonSetIcon } from "./DaemonSetIcon"; // Key encoding and option-building for WorkloadPicker: the emitted value is a // `[namespace/]kind/name` composite (see workloadKey / parseWorkloadKey) so two // workloads of different kinds that share a name don't collide. -export type WorkloadKind = "service" | "ingress" | "deployment" | "statefulset"; +export type WorkloadKind = + | "service" + | "ingress" + | "pod" + | "deployment" + | "statefulset" + | "daemonset"; /** One exposed port of a workload/service. */ export type WorkloadPort = { name?: string; number: number }; @@ -21,7 +29,12 @@ export type WorkloadPort = { name?: string; number: number }; * services/deployments/statefulsets may carry the ports a consumer offers when * building a connection URL. */ -export type WorkloadResource = { name: string; hosts?: string[]; ports?: WorkloadPort[] }; +export type WorkloadResource = { + name: string; + namespace?: string; + hosts?: string[]; + ports?: WorkloadPort[]; +}; type KindIconProps = { className?: string; title?: string; "aria-label"?: string }; type KindMeta = { label: string; Icon: ComponentType }; @@ -31,15 +44,19 @@ type KindMeta = { label: string; Icon: ComponentType }; export const WORKLOAD_META: Record = { service: { label: "Service", Icon: K8SService }, ingress: { label: "Ingress", Icon: K8SIngress }, + pod: { label: "Pod", Icon: K8SPod }, deployment: { label: "Deployment", Icon: K8SDeployment }, statefulset: { label: "StatefulSet", Icon: K8SStatefulset }, + daemonset: { label: "DaemonSet", Icon: DaemonSetIcon }, }; export const ALL_WORKLOAD_KINDS: WorkloadKind[] = [ "service", "ingress", + "pod", "deployment", "statefulset", + "daemonset", ]; // ingressHost is the first host an ingress routes, or undefined for non-ingress @@ -57,7 +74,7 @@ function workloadName(kind: WorkloadKind, r: WorkloadResource): string { // ParsedWorkloadKey is the structured form of a `[namespace/]kind/name` value. export type ParsedWorkloadKey = { namespace?: string; kind?: WorkloadKind; name: string }; -const KNOWN_KINDS = new Set(["service", "ingress", "deployment", "statefulset"]); +const KNOWN_KINDS = new Set(ALL_WORKLOAD_KINDS); // workloadKey is the value a resource contributes (what onChange emits): a // `[namespace/]kind/name` composite so two workloads of different kinds sharing @@ -68,7 +85,8 @@ export function workloadKey( r: WorkloadResource, ): string { const name = workloadName(kind, r); - return namespace ? `${namespace}/${kind}/${name}` : `${kind}/${name}`; + const resourceNamespace = r.namespace ?? namespace; + return resourceNamespace ? `${resourceNamespace}/${kind}/${name}` : `${kind}/${name}`; } // parseWorkloadKey splits a `[namespace/]kind/name` value back into its parts. @@ -103,7 +121,7 @@ export function loadedWorkloads( const names = new Set(); for (const kind of kinds) { for (const r of byKind[kind] ?? []) { - keys.add(workloadKey(namespace, kind, r)); + keys.add(workloadKey(namespace, kind, r)); names.add(workloadName(kind, r)); } } @@ -135,7 +153,7 @@ export function buildWorkloadOptions( for (const r of byKind[kind] ?? []) { const host = ingressHost(kind, r); opts.push({ - value: workloadKey(namespace, kind, r), + value: workloadKey(namespace, kind, r), // The label is the human name; an ingress pairs its host with the // ingress name for context. label: host ? `${host} (${r.name})` : r.name, diff --git a/packages/ui/src/data.ts b/packages/ui/src/data.ts index 8de9f12c2..8a69a2d77 100644 --- a/packages/ui/src/data.ts +++ b/packages/ui/src/data.ts @@ -64,6 +64,7 @@ export { isGroupCollapsedByDefault, type DataTableGroup, type DataTableGrouping, + type DataTableGroupMetaAlign, } from "./data/DataTable.grouping"; export { StatStrip, @@ -301,6 +302,8 @@ export * from "./data/git"; export * from "./data/test-runner"; export * from "./data/cache-browser"; export * from "./data/query-browser"; +export * from "./data/query-info"; +export * from "./data/clicky-export"; export { type LogEntry, type TaskControlAction, diff --git a/packages/ui/src/data/Clicky.test.tsx b/packages/ui/src/data/Clicky.test.tsx index 4c56e88a1..08c3eba21 100644 --- a/packages/ui/src/data/Clicky.test.tsx +++ b/packages/ui/src/data/Clicky.test.tsx @@ -19,16 +19,6 @@ vi.mock("./code-highlight", () => ({ import { highlightCode } from "./code-highlight"; const mockHighlightCode = vi.mocked(highlightCode); -// Returns the menu sub-group introduced by a section heading ("View" / -// "Download"), so assertions can target one group when labels (JSON, PDF, …) -// appear in more than one. -function sectionGroup(menu: HTMLElement, label: string): HTMLElement { - const header = within(menu).getByText(label); - const group = header.parentElement; - if (!group) throw new Error(`no menu group for section "${label}"`); - return group; -} - function createCommandClient() { const executeCommand = vi.fn().mockResolvedValue({ success: true, @@ -740,10 +730,27 @@ describe("Clicky", () => { fireEvent.click(screen.getByRole("button", { name: /open column menu/i })); const tableMenu = screen.getByRole("menu", { name: /column menu/i }); - // The menu carries a "View" group (every preview format) above the - // download-format "Download" group. - const viewGroup = sectionGroup(tableMenu, "View"); - const downloadGroup = sectionGroup(tableMenu, "Download"); + // Ten view formats and five download formats are two rows, not fifteen: + // one submenu trigger naming the active view, and one Export row. + expect( + within(tableMenu).getByRole("menuitem", { name: /^View: Clicky/i }), + ).toBeInTheDocument(); + expect( + within(tableMenu).getByRole("menuitem", { name: /^Export/i }), + ).toBeInTheDocument(); + for (const label of ["PDF", "HTML", "Pretty", "Slack"]) { + expect( + within(tableMenu).queryByRole("menuitem", { + name: new RegExp(`^${label}$`, "i"), + }), + ).not.toBeInTheDocument(); + } + + // The formats live behind the submenu, with the active one not re-selectable. + fireEvent.click( + within(tableMenu).getByRole("menuitem", { name: /^View: Clicky/i }), + ); + const viewMenu = screen.getByRole("menu", { name: /^View: Clicky/i }); for (const label of [ "Clicky", "JSON", @@ -757,26 +764,23 @@ describe("Clicky", () => { "Slack", ]) { expect( - within(viewGroup).getByRole("menuitem", { - name: new RegExp(`^${label}$`, "i"), + within(viewMenu).getByRole("menuitem", { + name: new RegExp(`^${label}`, "i"), }), ).toBeInTheDocument(); } - for (const label of ["YAML", "JSON", "CSV", "PDF", "Markdown"]) { - expect( - within(downloadGroup).getByRole("menuitem", { - name: new RegExp(`^${label}$`, "i"), - }), - ).toBeInTheDocument(); - } - // The active (Clicky) view is shown but not re-selectable. expect( - within(viewGroup).getByRole("menuitem", { name: /^Clicky$/i }), + within(viewMenu).getByRole("menuitem", { name: /^Clicky/i }), ).toBeDisabled(); - // Picking a download format triggers the download frame. + // Exporting picks format and range in a dialog, then downloads. + fireEvent.click( + within(tableMenu).getByRole("menuitem", { name: /^Export/i }), + ); + const exportDialog = await screen.findByRole("dialog"); + fireEvent.click(within(exportDialog).getByText("JSON")); fireEvent.click( - within(downloadGroup).getByRole("menuitem", { name: /^JSON$/i }), + within(exportDialog).getByRole("button", { name: /^Download$/i }), ); const downloadFrame = document.getElementById( "clicky-download-frame", @@ -789,12 +793,13 @@ describe("Clicky", () => { // brings the view bar back so the user can switch away from JSON again. fireEvent.click(screen.getByRole("button", { name: /open column menu/i })); fireEvent.click( - within( - sectionGroup( - screen.getByRole("menu", { name: /column menu/i }), - "View", - ), - ).getByRole("menuitem", { name: /^JSON$/i }), + screen.getByRole("menuitem", { name: /^View: Clicky/i }), + ); + fireEvent.click( + within(screen.getByRole("menu", { name: /^View: Clicky/i })).getByRole( + "menuitem", + { name: /^JSON/i }, + ), ); expect(await screen.findByLabelText("JSON tree")).toBeInTheDocument(); expect(screen.getByText("service")).toBeInTheDocument(); @@ -853,13 +858,20 @@ describe("Clicky", () => { fireEvent.click(screen.getByRole("button", { name: /open column menu/i })); const tableMenu = screen.getByRole("menu", { name: /column menu/i }); - expect(within(tableMenu).getByText("Download")).toBeInTheDocument(); + // With no view formats configured there is no View submenu to offer, so + // Export is the only action the menu carries. expect( - within(tableMenu).queryByText( - "Portable document for sharing and printing", - ), + within(tableMenu).queryByRole("menuitem", { name: /^View:/i }), ).not.toBeInTheDocument(); - fireEvent.click(within(tableMenu).getByRole("menuitem", { name: /pdf/i })); + fireEvent.click( + within(tableMenu).getByRole("menuitem", { name: /^Export/i }), + ); + + const exportDialog = await screen.findByRole("dialog"); + fireEvent.click(within(exportDialog).getByText("PDF")); + fireEvent.click( + within(exportDialog).getByRole("button", { name: /^Download$/i }), + ); const downloadFrame = document.getElementById( "clicky-download-frame", @@ -920,35 +932,30 @@ describe("Clicky", () => { const densityItem = within(menu).getByRole("menuitemradio", { name: /use page density/i, }); - const downloadGroup = sectionGroup(menu, "Download"); - const downloadHeader = within(menu).getByText("Download"); + const exportItem = within(menu).getByRole("menuitem", { name: /^Export/i }); - // The download group follows the density control. + // Export follows the density control. expect( Boolean( - densityItem.compareDocumentPosition(downloadHeader) & + densityItem.compareDocumentPosition(exportItem) & Node.DOCUMENT_POSITION_FOLLOWING, ), ).toBe(true); - // Only the whitelisted download formats appear under Download. + fireEvent.click(exportItem); + const dialog = await screen.findByRole("dialog"); + + // Only the whitelisted download formats are offered. for (const label of ["YAML", "JSON", "CSV", "PDF", "Markdown"]) { - expect( - within(downloadGroup).getByRole("menuitem", { - name: new RegExp(`^${label}$`, "i"), - }), - ).toBeInTheDocument(); + expect(within(dialog).getByText(label)).toBeInTheDocument(); } for (const label of ["Clicky", "HTML", "Pretty", "Excel", "Slack"]) { - expect( - within(downloadGroup).queryByRole("menuitem", { - name: new RegExp(`^${label}$`, "i"), - }), - ).not.toBeInTheDocument(); + expect(within(dialog).queryByText(label)).not.toBeInTheDocument(); } + fireEvent.click(within(dialog).getByText("PDF")); fireEvent.click( - within(downloadGroup).getByRole("menuitem", { name: /^PDF$/i }), + within(dialog).getByRole("button", { name: /^Download$/i }), ); const downloadFrame = document.getElementById( @@ -962,6 +969,69 @@ describe("Clicky", () => { fetchSpy.mockRestore(); }); + it("prepares a table download before starting the native download", async () => { + const prepare = vi.fn().mockResolvedValue({ + url: "/api/v1/profile/reconciliations/run/export", + label: "selected reconciliation", + }); + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: /open column menu/i })); + const menu = screen.getByRole("menu", { name: /column menu/i }); + fireEvent.click(within(menu).getByRole("menuitem", { name: /^Export/i })); + const dialog = await screen.findByRole("dialog"); + fireEvent.click( + within(dialog).getByRole("button", { name: /^Download$/i }), + ); + + await waitFor(() => + expect(prepare).toHaveBeenCalledWith({ format: "csv", scope: "all" }), + ); + const frame = document.getElementById( + "clicky-download-frame", + ) as HTMLIFrameElement; + await waitFor(() => + expect(frame.src).toContain("/api/v1/profile/reconciliations/run/export"), + ); + expect(frame.src).toContain("format=csv"); + expect(frame.src).toContain("scope=all"); + expect(frame.src).toContain("filename=selected-reconciliation.csv"); + }); + + it("surfaces a prepared download failure without starting a download", async () => { + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: /open column menu/i })); + const menu = screen.getByRole("menu", { name: /column menu/i }); + fireEvent.click(within(menu).getByRole("menuitem", { name: /^Export/i })); + const dialog = await screen.findByRole("dialog"); + fireEvent.click( + within(dialog).getByRole("button", { name: /^Download$/i }), + ); + + // The dialog stays open holding the reason, rather than closing onto a + // download that never started. + expect(await within(dialog).findByRole("alert")).toHaveTextContent( + "snapshot expired", + ); + expect(document.getElementById("clicky-download-frame")).toBeNull(); + }); + it("separates current-page and all-row endpoint exports", async () => { const tableDocument: ClickyDocument = { version: 1, @@ -992,45 +1062,64 @@ describe("Clicky", () => { ); fireEvent.click(screen.getByRole("button", { name: /open column menu/i })); - let menu = screen.getByRole("menu", { name: /column menu/i }); - const pageGroup = sectionGroup(menu, "Download current page"); - const allGroup = sectionGroup(menu, "Download all rows"); - expect( - within(pageGroup).getByRole("menuitem", { name: /^NDJSON$/i }), - ).toBeInTheDocument(); + fireEvent.click(screen.getByRole("menuitem", { name: /^Export/i })); + const dialog = await screen.findByRole("dialog"); + + // One range at a time, so the per-format caps describe the range in hand + // rather than being listed twice under two headings. + expect(within(dialog).getByText("NDJSON")).toBeInTheDocument(); + fireEvent.click(within(dialog).getByRole("radio", { name: "All rows" })); expect( - within(allGroup).getAllByText("Streams rows as they are read"), + within(dialog).getAllByText("Streams rows as they are read"), ).toHaveLength(3); expect( - within(allGroup).getByText("Limited to 1,000 rows"), + within(dialog).getByText("Limited to 1,000 rows"), ).toBeInTheDocument(); - fireEvent.click(within(allGroup).getByRole("menuitem", { name: /^JSON/i })); - let frame = document.getElementById( - "clicky-download-frame", - ) as HTMLIFrameElement; - let downloaded = new URL(frame.src); - expect(downloaded.searchParams.get("scope")).toBe("all"); - expect(downloaded.searchParams.get("region")).toBe("EU"); - expect(downloaded.searchParams.has("limit")).toBe(false); - expect(downloaded.searchParams.has("offset")).toBe(false); + fireEvent.click(within(dialog).getByRole("radio", { name: "JSON" })); + fireEvent.click( + within(dialog).getByRole("button", { name: /^Download$/i }), + ); + // The download resolves a tick after the click, so read it once it lands. + const downloadedUrl = async () => { + let params: URLSearchParams | null = null; + await waitFor(() => { + const frame = document.getElementById( + "clicky-download-frame", + ) as HTMLIFrameElement | null; + expect(frame).not.toBeNull(); + params = new URL(frame!.src).searchParams; + expect(params.get("format")).toBeTruthy(); + }); + return params!; + }; + + let downloaded = await downloadedUrl(); + expect(downloaded.get("scope")).toBe("all"); + expect(downloaded.get("region")).toBe("EU"); + expect(downloaded.has("limit")).toBe(false); + expect(downloaded.has("offset")).toBe(false); fireEvent.click(screen.getByRole("button", { name: /open column menu/i })); - menu = screen.getByRole("menu", { name: /column menu/i }); + fireEvent.click(screen.getByRole("menuitem", { name: /^Export/i })); + const pageDialog = await screen.findByRole("dialog"); fireEvent.click( - within(sectionGroup(menu, "Download current page")).getByRole( - "menuitem", - { name: /^Excel/i }, - ), + within(pageDialog).getByRole("radio", { name: "Current page" }), ); - frame = document.getElementById( - "clicky-download-frame", - ) as HTMLIFrameElement; - downloaded = new URL(frame.src); - expect(downloaded.searchParams.get("scope")).toBe("page"); - expect(downloaded.searchParams.get("limit")).toBe("25"); - expect(downloaded.searchParams.get("offset")).toBe("50"); - expect(downloaded.searchParams.get("format")).toBe("excel"); + fireEvent.click(within(pageDialog).getByRole("radio", { name: "Excel" })); + fireEvent.click( + within(pageDialog).getByRole("button", { name: /^Download$/i }), + ); + await waitFor(() => { + const frame = document.getElementById( + "clicky-download-frame", + ) as HTMLIFrameElement; + expect(new URL(frame.src).searchParams.get("format")).toBe("excel"); + }); + downloaded = await downloadedUrl(); + expect(downloaded.get("scope")).toBe("page"); + expect(downloaded.get("limit")).toBe("25"); + expect(downloaded.get("offset")).toBe("50"); fetchSpy.mockRestore(); }); @@ -1314,4 +1403,130 @@ describe("Clicky", () => { expect(container.querySelector("pre.shiki")).toBeNull(); expect(within(container).getByText(source)).toBeInTheDocument(); }); + + // The clicky server answers every failure with the ErrorResponse envelope + // from entity/errors.go — code, message, trace, hint, details, stacktrace. + // A remote view that reduces all of that to "Request failed with 422" is + // unactionable, so these cases pin each field to the rendered output. + describe("remote failures", () => { + const errorPayload = { + code: "format_not_exportable", + message: "scope=all cannot be rendered as clicky-json", + trace: "9f2b1c4d5e6a7b8c", + hint: "Request format=csv for a full export", + context: { profile: "gavel-sessions", scope: "all" }, + details: [ + { + label: "Query", + value: "SELECT * FROM sessions WHERE started_at > $1", + content_type: "text/sql", + }, + ], + stacktrace: + "format_not_exportable\n--- at profiles/execution.go:212 execHandler.execute", + }; + + const tableDocument: ClickyDocument = { + version: 1, + node: { + kind: "table", + columns: [{ name: "session", label: "Session" }], + rows: [ + { + cells: { + session: { kind: "text", text: "abc123", plain: "abc123" }, + }, + }, + ], + }, + }; + + // A fresh Response per call: the QueryClient retries once (Clicky.tsx sets + // `retry: 1`), and a Response body can only be read one time. + function mockFailure(body: string, contentType: string, status = 422) { + return vi.spyOn(globalThis, "fetch").mockImplementation( + async () => + new Response(body, { + status, + statusText: "Unprocessable Entity", + headers: { "Content-Type": contentType }, + }), + ); + } + + // Long enough to cover the retry plus its backoff. + const untilRetried = { timeout: 3_000 }; + + it("keeps the loaded rows and reports the server error when a refresh fails", async () => { + const fetchSpy = mockFailure( + JSON.stringify(errorPayload), + "application/json", + ); + + render(); + + // The server's message replaces the synthesized "Request failed with 422". + expect( + await screen.findByText(errorPayload.message, undefined, untilRetried), + ).toBeInTheDocument(); + expect( + screen.queryByText(/showing the local fallback payload instead/i), + ).not.toBeInTheDocument(); + + fireEvent.click(screen.getByText(/more details/i)); + // getAllByText because several of these legitimately appear twice — the + // code as both a badge and the stack-trace headline, for instance. + for (const value of [ + errorPayload.code, + errorPayload.trace, + errorPayload.hint, + errorPayload.details[0]!.value, + "422", + "/api/v1/profile/profile-gavel-sessions", + ]) { + expect(screen.getAllByText(value, { exact: false })).not.toHaveLength(0); + } + + // A failed *refresh* must not blank the rows the list request already + // returned — the error goes above them, it does not replace them. + expect(screen.getByRole("table")).toBeInTheDocument(); + expect(screen.getByText("abc123")).toBeInTheDocument(); + + fetchSpy.mockRestore(); + }); + + it("reports the server error when there is no local payload to fall back to", async () => { + const fetchSpy = mockFailure( + JSON.stringify(errorPayload), + "application/json", + ); + + render(); + + expect( + await screen.findByText(errorPayload.message, undefined, untilRetried), + ).toBeInTheDocument(); + expect( + screen.queryByText(/request failed with 422/i), + ).not.toBeInTheDocument(); + + fetchSpy.mockRestore(); + }); + + it("surfaces a non-JSON error body verbatim", async () => { + const body = "502 Bad Gateway"; + const fetchSpy = mockFailure(body, "text/html", 502); + + render(); + + // Nothing the server said stays hidden, even when it is not our envelope. + // The body reaches both the headline and the stack-trace block, so match + // all of them rather than pinning a single node. + expect( + await screen.findAllByText(/502 Bad Gateway/, undefined, untilRetried), + ).not.toHaveLength(0); + + fetchSpy.mockRestore(); + }); + }); }); diff --git a/packages/ui/src/data/Clicky.tsx b/packages/ui/src/data/Clicky.tsx index 0130d1f0b..05ee0fe8f 100644 --- a/packages/ui/src/data/Clicky.tsx +++ b/packages/ui/src/data/Clicky.tsx @@ -6,6 +6,7 @@ import { import { createContext, Fragment, + useCallback, useContext, useEffect, useMemo, @@ -18,6 +19,8 @@ import { type RefObject, } from "react"; import { FilterForm } from "../rpc/FilterForm"; +import { errorFromResponse, readResponseBody } from "../rpc/apiClient"; +import { renderOperationError } from "../rpc/operationErrorDiagnostics"; import { parseJsonBody } from "../rpc/classify"; import { packParameterValues, @@ -54,8 +57,6 @@ import { ExecutionTree, type ExecutionNode } from "./ExecutionTree"; import { Icon, type StaticIconComponent } from "./Icon"; import { UiCheck, - UiChevronDown, - UiChevronUp, UiCloudDownload, UiComment, UiEllipsis, @@ -83,6 +84,13 @@ import { type TagsValue, } from "./cells/tag-utils"; import { parseClickyData, type ParsedClicky } from "./clicky-parse"; +import { useQueryInfo } from "./query-info/useQueryInfo"; +import { useClickyExport } from "./clicky-export/useClickyExport"; +import { + ClickyExportDialog, + type ClickyExportFormatOption, + type ClickyExportScopeOption, +} from "./clicky-export/ClickyExportDialog"; export type ClickyStyle = { className?: string; @@ -297,6 +305,11 @@ export type ClickyDownloadOptions = { allRowsMode?: "streaming" | "buffered"; /** Per-format server row caps, for example `{ pdf: 1000 }`. */ formatMaxRows?: Partial>; + /** Prepare an export-specific resource before the native download starts. */ + prepare?: (request: { + format: ClickyRemoteFormat; + scope?: ClickyDownloadScope; + }) => Promise<{ url: string; label?: string }>; }; export type ClickyDownloadScope = "page" | "all"; @@ -430,47 +443,79 @@ export function Clicky(props: ClickyProps) { }, }), ); + // A remote payload knows the URL it came from, so the table can ask that URL + // what it ran — the one thing rows never show. + const queryInfo = useQueryInfo({ url: props.url }); + const exporter = useClickyExport({ + formats: useMemo( + () => exportFormatOptions({ url: props.url, download: props.download }), + [props.download, props.url], + ), + scopes: useMemo( + () => exportScopeOptions(props.download, props.pagination), + [props.download, props.pagination], + ), + onDownload: useCallback( + ({ format, scope }: { format: string; scope: string | undefined }) => + runClickyDownload({ + url: props.url ?? "", + format: format as ClickyRemoteFormat, + scope: scope as ClickyDownloadScope | undefined, + download: props.download, + }), + [props.download, props.url], + ), + }); const tableMenuActions = useMemo( - () => getDownloadMenuActions({ url: props.url, download: props.download }), - [props.download, props.url], + () => + [exporter.action, queryInfo.action].filter( + (action): action is DataTableMenuAction => action !== undefined, + ), + [exporter.action, queryInfo.action], ); const content = ( - 0 ? { tableMenuActions } : {})} - > - {props.url ? ( - - ) : ( - - )} - + <> + {queryInfo.dialog} + {exporter.dialog} + 0 ? { tableMenuActions } : {})} + > + {props.url ? ( + + ) : ( + + )} + + ); if (props.url || props.commandRuntime) { @@ -734,20 +779,37 @@ function ClickyRemoteRenderer({ // surface. Non-table payloads, and the JSON/PDF/HTML previews, keep the bar so // the user can always switch back to the table. const tableHostsControls = downloadHandledByTable && activeView === "clicky"; + // One row that opens a flyout, not one row per format: a dozen formats listed + // inline is what buried everything below them in this menu. const viewMenuActions = useMemo(() => { if (!tableHostsControls || availableViews.length <= 1) return []; - return availableViews.map((format) => { - const meta = getRemoteFormatMeta(format); - return { - id: `view-${format}`, - label: formatViewLabel(format), - icon: meta.icon, - ...(meta.iconClassName ? { iconClassName: meta.iconClassName } : {}), - section: "View", - disabled: format === activeView, - onSelect: () => setActiveView(format), - }; - }); + const activeMeta = getRemoteFormatMeta(activeView); + return [ + { + id: "view", + label: `View: ${formatViewLabel(activeView)}`, + icon: activeMeta.icon, + ...(activeMeta.iconClassName + ? { iconClassName: activeMeta.iconClassName } + : {}), + section: "", + children: availableViews.map((format) => { + const meta = getRemoteFormatMeta(format); + return { + id: `view-${format}`, + label: formatViewLabel(format), + description: meta.description, + icon: meta.icon, + ...(meta.iconClassName + ? { iconClassName: meta.iconClassName } + : {}), + disabled: format === activeView, + onSelect: () => setActiveView(format), + }; + }), + onSelect: () => undefined, + }, + ]; }, [tableHostsControls, availableViews, activeView]); const combinedTableActions = useMemo( () => [...viewMenuActions, ...(runtime.tableMenuActions ?? [])], @@ -798,10 +860,9 @@ function ClickyRemoteRenderer({ )} {canDownload && ( - )}
@@ -822,24 +883,18 @@ function ClickyRemoteRenderer({ activeQuery.isPending && effectiveClickyData === undefined ? ( ) : activeQuery.isError && effectiveClickyData === undefined ? ( - + renderOperationError( + activeQuery.error, + `Failed to load ${formattedUrl}`, + ) ) : ( <> - {activeQuery.isError && data !== undefined && ( - - )} + {activeQuery.isError && + data !== undefined && + renderOperationError( + activeQuery.error, + `Refresh failed: ${formattedUrl}`, + )} @@ -854,24 +909,18 @@ function ClickyRemoteRenderer({ activeQuery.isPending && effectiveJsonData === undefined ? ( ) : activeQuery.isError && effectiveJsonData === undefined ? ( - + renderOperationError( + activeQuery.error, + `Failed to load ${formattedUrl}`, + ) ) : ( <> - {activeQuery.isError && fallbackJsonData !== undefined && ( - - )} + {activeQuery.isError && + fallbackJsonData !== undefined && + renderOperationError( + activeQuery.error, + `Refresh failed: ${formattedUrl}`, + )} ) @@ -881,15 +930,10 @@ function ClickyRemoteRenderer({ message={loadingMessage} /> ) : activeQuery.isError ? ( - + renderOperationError( + activeQuery.error, + `Failed to load ${formatViewLabel(activeView)} from ${formattedUrl}`, + ) ) : ( (null); - const triggerRef = useRef(null); const [open, setOpen] = useState(false); - const primaryFormat: ClickyRemoteFormat = formats.includes("json") - ? "json" - : formats[0]!; - const primaryMeta = getRemoteFormatMeta(primaryFormat); - const scopes = getDownloadScopes(download); - const primaryScope = scopes[0]; - - useDismissablePopup(open, rootRef, triggerRef, () => setOpen(false)); + const formats = useMemo( + () => exportFormatOptions({ url, download }), + [download, url], + ); + const scopes = useMemo(() => exportScopeOptions(download), [download]); + if (formats.length === 0) return null; return ( -
-
- - -
- - {open && ( -
- {scopes.map((scope, scopeIndex) => ( -
0 && "mt-1 border-t border-border pt-1", - )} - > - {scope && ( -
- {downloadSection(scope)} -
- )} - {formats.map((format) => { - const meta = getRemoteFormatMeta(format); - const description = downloadDescription( - format, - scope, - download, - ); - return ( - - ); - })} -
- ))} -
- )} -
+ <> + + setOpen(false)} + formats={formats} + scopes={scopes} + onDownload={({ + format, + scope, + }: { + format: string; + scope: string | undefined; + }) => + runClickyDownload({ + url, + format: format as ClickyRemoteFormat, + scope: scope as ClickyDownloadScope | undefined, + ...(download ? { download } : {}), + }) + } + /> + ); } @@ -1146,8 +1121,15 @@ function fetchRemoteFormat( }, }).then(async (response) => { if (!response.ok) { - throw new Error( - `Request failed with ${response.status} ${response.statusText}`.trim(), + // Read the failure as JSON whatever the requested format: an excel or + // pdf request still fails with the server's error envelope, and throwing + // on status alone loses the code, trace and hint that say what actually + // went wrong. A body that is not JSON still comes back as raw text. + throw errorFromResponse( + response, + "GET", + url, + await readResponseBody(response, "json"), ); } @@ -1471,10 +1453,6 @@ function getDownloadScopes( return download?.scopes?.length ? download.scopes : [undefined]; } -function downloadSection(scope: ClickyDownloadScope) { - return scope === "all" ? "Download all rows" : "Download current page"; -} - function downloadDescription( format: ClickyRemoteFormat, scope: ClickyDownloadScope | undefined, @@ -1493,33 +1471,130 @@ function downloadDescription( return undefined; } -function getDownloadMenuActions({ +// exportFormatOptions describes the advertised formats for the export dialog. +// The dialog is presentational and knows nothing about Clicky's formats, so the +// labels, icons and blurbs are resolved here, from the same metadata the view +// switcher reads. +function exportFormatOptions({ url, download, }: { url?: string | undefined; download?: ClickyDownloadOptions | undefined; -}): DataTableMenuAction[] { - const formats = getDownloadFormats({ url, download }); - if (!url || formats.length === 0) return []; +}): ClickyExportFormatOption[] { + if (!url) return []; + return getDownloadFormats({ url, download }).map((format) => { + const meta = getRemoteFormatMeta(format); + return { + format, + label: meta.label, + description: meta.description, + icon: meta.icon, + ...(meta.iconClassName ? { iconClassName: meta.iconClassName } : {}), + }; + }); +} - return getDownloadScopes(download).flatMap((scope) => - formats.map((format) => { - const meta = getRemoteFormatMeta(format); - return { - id: `download-${scope ?? "legacy"}-${format}`, - label: meta.label, - description: downloadDescription(format, scope, download), - icon: meta.icon, - ...(meta.iconClassName ? { iconClassName: meta.iconClassName } : {}), - ...(scope ? { section: downloadSection(scope) } : {}), - onSelect: () => - triggerDownload( - buildDownloadUrl(url, format, download?.label, scope), - ), - }; - }), - ); +// exportScopeOptions describes the ranges the endpoint serves. The per-format +// note is a function rather than a string because a ceiling belongs to a format +// and a range together: a PDF stops at 1,000 rows, the CSV beside it does not. +// +// The all-rows label carries the count the table was told, because "all" is the +// one number a person needs before starting an export and the only one the page +// in front of them does not show. +function exportScopeOptions( + download?: ClickyDownloadOptions, + pagination?: DataTablePagination, +): ClickyExportScopeOption[] { + return getDownloadScopes(download).map((scope) => ({ + scope, + label: + scope === "all" || scope === undefined + ? allRowsLabel(pagination) + : "Current page", + note: (format: string) => + downloadDescription(format as ClickyRemoteFormat, scope, download), + })); +} + +// allRowsLabel states the total when the backend gave one. A "gte" total is a +// lower bound a backend stopped counting at, so it is rendered as one rather +// than as a count nobody promised. +function allRowsLabel(pagination?: DataTablePagination): string { + const total = pagination?.total; + if (total === undefined) return "All rows"; + const count = total.toLocaleString(); + return pagination?.totalRelation === "gte" + ? `All ${count}+ rows` + : `All ${count} rows`; +} + +// runClickyDownload is prepareDownload for a caller that awaits it. The export +// dialog reports its own failure and stays open on one, so the reason has to +// come back as a rejection rather than through a callback. +async function runClickyDownload({ + url, + format, + scope, + download, +}: { + url: string; + format: ClickyRemoteFormat; + scope?: ClickyDownloadScope | undefined; + download?: ClickyDownloadOptions | undefined; +}): Promise { + let failure = ""; + await prepareDownload({ + url, + format, + ...(scope ? { scope } : {}), + ...(download ? { download } : {}), + onError: (message) => { + failure = message; + }, + }); + if (failure) throw new Error(failure); +} + +async function prepareDownload({ + url, + format, + scope, + download, + onPending, + onError, +}: { + url: string; + format: ClickyRemoteFormat; + scope?: ClickyDownloadScope | undefined; + download?: ClickyDownloadOptions | undefined; + onPending?: ((pending: boolean) => void) | undefined; + onError?: ((message: string) => void) | undefined; +}) { + onError?.(""); + onPending?.(true); + try { + const prepared = download?.prepare + ? await download.prepare({ format, ...(scope ? { scope } : {}) }) + : { url, ...(download?.label ? { label: download.label } : {}) }; + if (!prepared.url.trim()) { + throw new Error("The prepared download URL is empty"); + } + triggerDownload( + buildDownloadUrl( + prepared.url, + format, + prepared.label ?? download?.label, + scope, + ), + ); + } catch (error) { + onError?.( + error instanceof Error ? error.message : "Preparing the download failed", + ); + } finally { + onPending?.(false); + } } function getRemoteFormatMeta(format: ClickyRemoteFormat): { diff --git a/packages/ui/src/data/DataTable.grouping.ts b/packages/ui/src/data/DataTable.grouping.ts index 424b1bb7a..965cb81fa 100644 --- a/packages/ui/src/data/DataTable.grouping.ts +++ b/packages/ui/src/data/DataTable.grouping.ts @@ -6,13 +6,24 @@ export type DataTableGroup = { rows: T[]; }; +export type DataTableGroupMetaAlign = "start" | "end"; + export type DataTableGrouping = { /** Groups rows by the returned key. */ getGroupKey: (row: T) => string; /** Human label for a group header. Defaults to the group key. */ getGroupLabel?: (key: string, rows: T[]) => ReactNode; - /** Summary rendered at the trailing edge of the group header. */ + /** Summary rendered in the group header, placed by `metaAlign`. */ getGroupMeta?: (key: string, rows: T[]) => ReactNode; + /** + * Where `getGroupMeta` sits in the header row. `"end"` (the default) pins it + * to the trailing edge; `"start"` puts it immediately after the label and + * count, which keeps a per-group aggregate next to the thing it aggregates + * instead of a table-width away from it. + */ + metaAlign?: DataTableGroupMetaAlign; + /** Classes applied to the `getGroupMeta` wrapper. */ + metaClassName?: string; /** Whether a group starts collapsed. Defaults to expanded. */ defaultCollapsed?: boolean | ((key: string, rows: T[]) => boolean); /** Reorders groups. Omit to keep first-appearance order, which follows the sort. */ diff --git a/packages/ui/src/data/DataTable.stories.tsx b/packages/ui/src/data/DataTable.stories.tsx index 033dcbe09..b9d9a347b 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 ( row.owner, + getGroupLabel: (key: string) => `Owned by ${key}`, + getGroupMeta: (_key: string, groupRows: Row[]) => + `${groupRows.reduce((sum, row) => sum + row.restarts, 0)} restarts`, + }; + + return ( +
+
+

metaAlign: "end" (default)

+ row.service} + grouping={grouping} + /> +
+
+

metaAlign: "start"

+ row.service} + grouping={{ ...grouping, metaAlign: "start" }} + /> +
+
+ ); +} + function SelectionActionsShowcase() { const [selected, setSelected] = useState([]); return ( @@ -944,18 +981,90 @@ function SelectionActionsShowcase() { const meta = { title: "Data/DataTable", component: DataTable, - render: () => , + 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 +1081,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: () => , }; @@ -1082,3 +1204,36 @@ export const FilterDescriptions: Story = { }, }, }; + +export const GroupedRows: Story = { + render: () => , + parameters: { + docs: { + description: { + story: [ + "`grouping` splits the rendered rows into collapsible groups. It presents what is already on screen — it runs after filtering, sorting and pagination, so it never reorders rows within a group and never pulls in rows from another page.", + "", + "`getGroupMeta` is a per-group summary rendered inside the header row. `metaAlign` places it: `\"end\"` (the default) pins it to the trailing edge, while `\"start\"` keeps it immediately after the label and count — which is what you want when the summary is an aggregate of the group rather than a status for the row region.", + ].join("\n"), + }, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const headers = canvas + .getAllByRole("button") + .filter((button) => button.hasAttribute("aria-expanded")); + // Two groups per table, default table first. + const [trailing] = headers; + const adjacent = headers[headers.length / 2]; + + // The summary is always the label's next sibling; `flex-1` on the label is + // what pushes it to the far edge, so that is what the option toggles. + await expect(trailing).toHaveClass("flex-1"); + await expect(adjacent).not.toHaveClass("flex-1"); + await expect(adjacent?.nextElementSibling).toHaveTextContent("restarts"); + + await userEvent.click(adjacent!); + await expect(adjacent).toHaveAttribute("aria-expanded", "false"); + }, +}; diff --git a/packages/ui/src/data/DataTable.test.tsx b/packages/ui/src/data/DataTable.test.tsx index b2dbd318a..3e6336b8e 100644 --- a/packages/ui/src/data/DataTable.test.tsx +++ b/packages/ui/src/data/DataTable.test.tsx @@ -247,6 +247,45 @@ describe("DataTable", () => { expect(screen.getByText("worker")).toBeInTheDocument(); }); + // The summary is always the label's next sibling; what moves it to the + // trailing edge is the label growing to fill the row. So the class is the + // behaviour here, not an implementation detail standing in for it. + it("stops the group label filling the row when metaAlign is start, so the summary sits next to it", () => { + const grouping = { + getGroupKey: (row: ServiceRow) => row.status, + getGroupLabel: (key: string) => `Status: ${key}`, + getGroupMeta: (_key: string, groupRows: ServiceRow[]) => + `${groupRows.length} services`, + }; + + const { rerender } = render( + row.service} + grouping={{ ...grouping, metaAlign: "start", metaClassName: "font-mono" }} + />, + ); + + const label = screen.getByRole("button", { name: /Status: healthy/ }); + expect(label).not.toHaveClass("flex-1"); + expect(label.nextElementSibling).toHaveTextContent("2 services"); + expect(label.nextElementSibling).toHaveClass("font-mono"); + + rerender( + row.service} + grouping={grouping} + />, + ); + + expect(screen.getByRole("button", { name: /Status: healthy/ })).toHaveClass( + "flex-1", + ); + }); + it("selects only the selectable rows of the group whose header checkbox is toggled", () => { const onSelectionChange = vi.fn(); render( @@ -430,6 +469,37 @@ describe("DataTable", () => { expect(screen.queryByText("Page 1 of 1")).not.toBeInTheDocument(); }); + // Column widths sized for rows that are no longer rendered stretch the table + // past the viewport, taking the error's own copy and expand controls with it. + it("stops sizing columns while an error replaces the rows", () => { + const sized: DataTableColumn[] = [ + { key: "service", label: "Service", grow: true }, + { key: "status", label: "Status", shrink: true }, + ]; + const { rerender, container } = render( + row.service} />, + ); + expect( + Array.from(container.querySelectorAll("col")).map((col) => col.className), + ).toEqual(["", "w-px"]); + + rerender( + row.service} + error={Plan selection is ambiguous} + />, + ); + + const onError = Array.from(container.querySelectorAll("col")); + expect(onError.map((col) => col.className)).toEqual(["", ""]); + expect(onError.map((col) => col.getAttribute("style"))).toEqual([ + null, + null, + ]); + }); + it("renders native server pagination controls", () => { const onPageChange = vi.fn(); const onPageSizeChange = vi.fn(); @@ -659,6 +729,78 @@ describe("DataTable", () => { expect(exportPdf).toHaveBeenCalledTimes(1); }); + it("opens a submenu for a menu action with children rather than listing them", () => { + const chooseJson = vi.fn(); + const parent = vi.fn(); + + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: /open column menu/i })); + const menu = screen.getByRole("menu", { name: /column menu/i }); + + // The children stay behind the trigger — that is the whole point of a + // submenu, and what keeps a dozen formats from burying the rows below them. + expect( + within(menu).queryByRole("menuitem", { name: "JSON" }), + ).not.toBeInTheDocument(); + + const trigger = within(menu).getByRole("menuitem", { name: /^View: Clicky/ }); + expect(trigger).toHaveAttribute("aria-haspopup", "menu"); + + // Hovering past the row must not open it: a flyout nobody asked for covers + // the rows they were reaching for. + fireEvent.mouseEnter(trigger); + expect( + screen.queryByRole("menu", { name: "View: Clicky" }), + ).not.toBeInTheDocument(); + + fireEvent.click(trigger); + + // A trigger opens its flyout instead of firing its own onSelect. + expect(parent).not.toHaveBeenCalled(); + const submenu = screen.getByRole("menu", { name: "View: Clicky" }); + expect( + within(submenu).getByRole("menuitem", { name: "Clicky" }), + ).toBeDisabled(); + + // Clicking the trigger again closes it, so the same gesture undoes itself. + fireEvent.click(trigger); + expect( + screen.queryByRole("menu", { name: "View: Clicky" }), + ).not.toBeInTheDocument(); + fireEvent.click(trigger); + + fireEvent.click( + within(screen.getByRole("menu", { name: "View: Clicky" })).getByRole( + "menuitem", + { name: "JSON" }, + ), + ); + expect(chooseJson).toHaveBeenCalledTimes(1); + // Choosing a child closes the whole menu, both levels with it. + expect( + screen.queryByRole("menu", { name: /column menu/i }), + ).not.toBeInTheDocument(); + }); + it("renders an initial loading state inside the table shell", () => { render( void; }; @@ -1632,8 +1640,17 @@ function DataTableInner>({ {visibleColumns.map((column) => ( ))} @@ -1783,6 +1800,10 @@ function DataTableInner>({ key={`group:${item.group.key}`} label={item.group.label} meta={item.group.meta} + metaAlign={grouping?.metaAlign ?? "end"} + {...(grouping?.metaClassName + ? { metaClassName: grouping.metaClassName } + : {})} count={item.group.records.length} colSpan={visibleColumns.length + (rowSelection ? 1 : 0)} collapsed={item.group.collapsed} @@ -2298,6 +2319,8 @@ function DataTablePaginationFooter({ function DataTableGroupHeaderRow({ label, meta, + metaAlign, + metaClassName, count, colSpan, collapsed, @@ -2306,6 +2329,8 @@ function DataTableGroupHeaderRow({ }: { label: ReactNode; meta: ReactNode; + metaAlign: DataTableGroupMetaAlign; + metaClassName?: string; count: number; colSpan: number; collapsed: boolean; @@ -2347,7 +2372,12 @@ function DataTableGroupHeaderRow({ type="button" aria-expanded={!collapsed} onClick={onToggleCollapsed} - className="flex min-w-0 flex-1 items-center gap-1.5 text-left text-xs font-semibold text-foreground hover:text-primary" + className={cn( + "flex min-w-0 items-center gap-1.5 text-left text-xs font-semibold text-foreground hover:text-primary", + // `flex-1` is what pushes the meta to the trailing edge. Dropping + // it lets the meta sit right after the count instead. + metaAlign === "end" && "flex-1", + )} > {meta ? ( -
{meta}
+
+ {meta} +
) : null}
@@ -2446,7 +2483,15 @@ function DataTableErrorRow({ colSpan={Math.max(1, colSpan)} className={cn(DATA_TABLE_CELL_DENSITY_CLASS, "p-density-3")} > -
{error}
+ {/* + w-0 min-w-full keeps the error out of the table's width calculation: + a cell sizes to its content, so an error carrying a long unbroken + line — a response body, a SQL statement — would widen the table + itself and push its own copy and expand controls off-screen. + */} +
+ {error} +
); @@ -2752,6 +2797,15 @@ function MenuActionSection({ onClose: () => void; }) { const groups = groupMenuActions(actions); + // Which submenu is open, if any. One at a time: hovering a sibling takes the + // flyout with it, which is what every menu does and what stops two levels + // from being open over each other. + const [openSubmenu, setOpenSubmenu] = useState<{ + id: string; + x: number; + y: number; + } | null>(null); + return ( <> {groups.map((group, index) => ( @@ -2761,57 +2815,128 @@ function MenuActionSection({ (separated || index > 0) && "mt-1 border-t border-border pt-1", )} > -
- {group.section} -
- {group.actions.map((action) => { - const hasDescription = Boolean(action.description); - return ( - - ); - })} + {group.section && ( +
+ {group.section} +
+ )} + {group.actions.map((action) => ( + + ))}
))} ); } +function MenuActionItem({ + action, + submenu, + onOpenSubmenu, + onClose, +}: { + action: DataTableMenuAction; + submenu: { id: string; x: number; y: number } | null; + onOpenSubmenu: (state: { id: string; x: number; y: number } | null) => void; + onClose: () => void; +}) { + const hasDescription = Boolean(action.description); + const children = action.children ?? []; + const isSubmenu = children.length > 0; + + const openFrom = (element: HTMLElement) => { + const rect = element.getBoundingClientRect(); + // Flip to the left when the flyout would run off the right edge, using the + // same minimum width the panel below is given. + const width = 224; + const x = + rect.right + width > window.innerWidth ? rect.left - width : rect.right; + onOpenSubmenu({ id: action.id, x, y: rect.top }); + }; + + return ( + <> + + + {isSubmenu && submenu && ( +
+ {children.map((child) => ( + + ))} +
+ )} + + ); +} + function DensityMenuSection({ densityOverride, separated, diff --git a/packages/ui/src/data/ai/ChatButton.tsx b/packages/ui/src/data/ai/ChatButton.tsx new file mode 100644 index 000000000..78aef9127 --- /dev/null +++ b/packages/ui/src/data/ai/ChatButton.tsx @@ -0,0 +1,24 @@ +import { UiSparkles } from "../../icons"; +import { cn } from "../../lib/utils"; +import { ChatFab, type ChatFabProps } from "./ChatFab"; + +export type ChatButtonProps = Omit; + +/** A persistent chat trigger styled as navbar chrome for AppShell actions. */ +export function ChatButton({ + icon = UiSparkles, + label = "Open chat", + className, +}: ChatButtonProps) { + return ( + + ); +} diff --git a/packages/ui/src/data/ai/ChatFab.test.tsx b/packages/ui/src/data/ai/ChatFab.test.tsx new file mode 100644 index 000000000..20f9a0325 --- /dev/null +++ b/packages/ui/src/data/ai/ChatFab.test.tsx @@ -0,0 +1,79 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { useEffect } from "react"; +import { describe, expect, it } from "vitest"; +import { ChatWindowManagerProvider } from "./ChatWindowManager"; +import { useChatWindowManager } from "./chat-window-context"; +import { ChatButton } from "./ChatButton"; +import { ChatFab } from "./ChatFab"; + +function OpenPanelOnMount() { + const { openPanel } = useChatWindowManager(); + useEffect(() => { + openPanel(); + }, [openPanel]); + return null; +} + +function PanelCount() { + const { panels } = useChatWindowManager(); + return {panels.length}; +} + +describe("ChatFab", () => { + it("keeps floating launchers hidden while a chat window is open", async () => { + render( + + + + , + ); + + await act(async () => undefined); + expect(screen.queryByTestId("chat-fab")).toBeNull(); + }); + + it("keeps persistent launchers visible and focuses the existing window", async () => { + render( + + + + + , + ); + + const button = await screen.findByTestId("chat-fab"); + expect(button).not.toHaveAttribute("style"); + fireEvent.click(button); + expect(screen.getByLabelText("Panel count")).toHaveTextContent("1"); + }); + + it("uses the current-color AI sparkle icon by default", () => { + render( + + + , + ); + + expect(screen.getByTestId("chat-fab").querySelector("path")).toHaveAttribute( + "fill", + "currentColor", + ); + }); +}); + +describe("ChatButton", () => { + it("reuses ChatFab as persistent navbar chrome", () => { + render( + + + , + ); + + const button = screen.getByRole("button", { name: "Open assistant" }); + expect(button).toHaveAttribute("data-testid", "chat-fab"); + expect(button.className).toContain("static"); + expect(button.className).toContain("bg-transparent"); + expect(button.className).not.toContain("fixed"); + expect(button.className).not.toContain("bg-primary"); + }); +}); diff --git a/packages/ui/src/data/ai/ChatFab.tsx b/packages/ui/src/data/ai/ChatFab.tsx index bac50640a..87650520e 100644 --- a/packages/ui/src/data/ai/ChatFab.tsx +++ b/packages/ui/src/data/ai/ChatFab.tsx @@ -1,6 +1,6 @@ import { cn } from "../../lib/utils"; import { Icon, type StaticIconComponent } from "../Icon"; -import { UiComment } from "../../icons"; +import { UiSparkles } from "../../icons"; import { useChatWindowManager } from "./chat-window-context"; import { zIndex } from "../../overlay/zIndex"; @@ -9,14 +9,21 @@ export type ChatFabProps = { icon?: string | StaticIconComponent; /** Accessible label / tooltip. */ label?: string; + /** Keep navbar chrome visible and focus an existing window when clicked. */ + persistent?: boolean; className?: string; }; -/** A fixed bottom-right launch button that opens the first chat window. It - * hides itself once any window is open (the windows carry their own controls). */ -export function ChatFab({ icon = UiComment, label = "Open chat", className }: ChatFabProps) { - const { panels, openPanel } = useChatWindowManager(); - if (panels.length > 0) return null; +/** A fixed bottom-right chat launcher. By default it hides while a window is + * open; persistent launchers remain visible and focus the existing window. */ +export function ChatFab({ + icon = UiSparkles, + label = "Open chat", + persistent = false, + className, +}: ChatFabProps) { + const { panels, openPanel, findOrCreatePanel } = useChatWindowManager(); + if (!persistent && panels.length > 0) return null; return ( + +
+ } + > +
+ {error && ( +
+ {error} +
+ )} + + {scopes.length > 1 ? ( +
+ + Rows + +
+ {scopes.map((option) => ( + + ))} +
+
+ ) : ( + scopes[0]?.label && ( +

{scopes[0].label}

+ ) + )} + +
+ + Format + +
+ {formats.map((option) => { + const note = activeScope?.note?.(option.format); + return ( + + ); + })} +
+
+ +
+ + ); +} diff --git a/packages/ui/src/data/clicky-export/index.ts b/packages/ui/src/data/clicky-export/index.ts new file mode 100644 index 000000000..a8f75e6e0 --- /dev/null +++ b/packages/ui/src/data/clicky-export/index.ts @@ -0,0 +1,11 @@ +export { + ClickyExportDialog, + type ClickyExportDialogProps, + type ClickyExportFormatOption, + type ClickyExportScopeOption, +} from "./ClickyExportDialog"; +export { + useClickyExport, + type UseClickyExportOptions, + type UseClickyExportResult, +} from "./useClickyExport"; diff --git a/packages/ui/src/data/clicky-export/useClickyExport.tsx b/packages/ui/src/data/clicky-export/useClickyExport.tsx new file mode 100644 index 000000000..8606ff466 --- /dev/null +++ b/packages/ui/src/data/clicky-export/useClickyExport.tsx @@ -0,0 +1,76 @@ +import { useCallback, useMemo, useState, type ReactNode } from "react"; +import { UiCloudDownload } from "../../icons"; +import type { DataTableMenuAction } from "../DataTable"; +import { + ClickyExportDialog, + type ClickyExportFormatOption, + type ClickyExportScopeOption, +} from "./ClickyExportDialog"; + +export type UseClickyExportOptions = { + formats: ClickyExportFormatOption[]; + scopes: ClickyExportScopeOption[]; + onDownload: (request: { + format: string; + scope: string | undefined; + }) => Promise | void; + /** Menu row label. Defaults to "Export…". */ + label?: string | undefined; + /** Dialog heading. Defaults to "Export". */ + title?: string | undefined; +}; + +export type UseClickyExportResult = { + /** "Export…" for the table's overflow menu; absent with nothing to export. */ + action: DataTableMenuAction | undefined; + /** Render alongside the table — the dialog the action opens. */ + dialog: ReactNode; +}; + +/** + * useClickyExport puts a single "Export…" row in a table's overflow menu. + * + * One row rather than one per format-and-range: the choice belongs in a dialog + * that has room to say what each range costs, and a menu whose formats outnumber + * everything else in it is a menu whose other entries never get found. + */ +export function useClickyExport({ + formats, + scopes, + onDownload, + label = "Export…", + title, +}: UseClickyExportOptions): UseClickyExportResult { + const [open, setOpen] = useState(false); + const close = useCallback(() => setOpen(false), []); + const available = formats.length > 0; + const action = useMemo( + () => + available + ? { + id: "export", + label, + icon: UiCloudDownload, + section: "", + onSelect: () => setOpen(true), + } + : undefined, + [available, label], + ); + + if (!available) return { action: undefined, dialog: null }; + + return { + action, + dialog: ( + + ), + }; +} diff --git a/packages/ui/src/data/data-table-server-filters.ts b/packages/ui/src/data/data-table-server-filters.ts index a36841978..b3376b8d1 100644 --- a/packages/ui/src/data/data-table-server-filters.ts +++ b/packages/ui/src/data/data-table-server-filters.ts @@ -9,6 +9,7 @@ import type { FilterExtension } from "../components/filter-bar-utils"; import { applyFilterExtensions } from "../components/filter-bar-utils"; import type { MultiSelectOption } from "../components/MultiSelect"; import type { TriState } from "../components/TriStateToggle"; +import { formatUnit } from "../lib/format"; import type { DataTableColumn, DataTableColumnKind } from "./DataTable"; import { parseBoundsValue, @@ -29,7 +30,19 @@ import { prettifyKey } from "./data-table-utils"; * source compiles the selection; nothing about how it does so crosses into the * browser. */ -export type DataTableFilterKind = "terms" | "range" | "time" | "boolean" | "text"; +export type DataTableFilterKind = + | "terms" + /** Whole values like "terms", but typed rather than picked — an identifier + * has no list worth offering. */ + | "exact" + | "range" + /** A range over elapsed time, labelled in the column's own `unit`. */ + | "duration" + | "time" + /** A range over whole days: "time" with the clock taken off it. */ + | "date" + | "boolean" + | "text"; export type DataTableFilterOption = { /** Value written into the selection. */ @@ -54,6 +67,10 @@ export type DataTableColumnFilter = { min?: number; max?: number; step?: number; + /** The unit a kind:"duration" bound is written in ("ms" or "s"), so the + * control labels itself in the numbers the column is stored in. Absent + * means milliseconds. */ + unit?: string; /** Range presets for kind:"time". */ presets?: FilterBarRangePreset[]; /** Helper text shown in the filter popover. */ @@ -223,6 +240,9 @@ function buildFilter(args: BuildArgs): FilterBarFilter { switch (filter.kind) { case "time": + // A date range is the same two-edged control with the clock taken off it, + // so it shares every line below except timeEnabled. + case "date": return { ...shared, kind: "date-range", @@ -230,9 +250,12 @@ function buildFilter(args: BuildArgs): FilterBarFilter { onApply: (from: string, to: string) => write(serializeBoundsValue({ min: from, max: to })), ...(filter.presets !== undefined ? { presets: filter.presets } : {}), - timeEnabled: true, + timeEnabled: filter.kind === "time", }; case "range": + // A duration is a range that knows what its numbers mean, so it differs + // only in how the slider labels them. + case "duration": return { ...shared, kind: "number", @@ -241,7 +264,15 @@ function buildFilter(args: BuildArgs): FilterBarFilter { ...(filter.min !== undefined ? { domainMin: filter.min } : {}), ...(filter.max !== undefined ? { domainMax: filter.max } : {}), ...(filter.step !== undefined ? { step: filter.step } : {}), + ...(filter.kind === "duration" + ? { formatValue: (value: number) => formatUnit(value, filter.unit || "ms") } + : {}), }; + // An exact match has nothing to enumerate, which is what buildTermsFilter + // already falls back to for a selection that turned out to have no values. + // Declaring it says so up front rather than arriving there by exhaustion. + case "exact": + return { ...shared, kind: "text", value: raw, onChange: (next: string) => write(next) }; case "boolean": return { ...shared, 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 000000000..05c336850 --- /dev/null +++ b/packages/ui/src/data/diagnostics/ErrorDetails.test.tsx @@ -0,0 +1,107 @@ +import { fireEvent, getDefaultNormalizer, 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("renders long detail values as preformatted blocks and copies them with the report", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + value: { writeText }, + configurable: true, + }); + const sql = "SELECT scheme, premum\nFROM policies\nWHERE start >= $1"; + render( + , + ); + + expect( + screen.getByText("Failed to load /api/v1/profile/profile-om-malawi-scheme"), + ).toBeInTheDocument(); + const block = screen.getByText(sql, { + normalizer: getDefaultNormalizer({ collapseWhitespace: false }), + }); + expect(block.tagName).toBe("PRE"); + + fireEvent.click(screen.getByRole("button", { name: "Copy error details" })); + + await waitFor(() => expect(writeText).toHaveBeenCalledOnce()); + const report = writeText.mock.calls[0]?.[0] as string; + expect(report).toContain(sql); + expect(report.startsWith("Failed to load /api/v1/profile/profile-om-malawi-scheme: ")).toBe( + true, + ); + }); + + 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 1d9a9d9e5..0774759fa 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, @@ -12,13 +19,17 @@ import { export type ErrorDetailsProps = { diagnostics: ErrorDiagnostics; + // Heading above the message. Defaults to "Error"; callers that already know + // what failed ("Failed to load /api/v1/profile/…") say so instead. + title?: string; // Optional renderer for context values that parse as JSON. Defaults to a // CopyBadge with the raw string. MC's playbook view passes a richer JsonView // here; smaller consumers (plugin iframes) can leave it unset. renderJsonContext?: (entry: { label: string; value: string; data: unknown }) => ReactNode; }; -export function ErrorDetails({ diagnostics, renderJsonContext }: ErrorDetailsProps) { +export function ErrorDetails({ diagnostics, title = "Error", renderJsonContext }: ErrorDetailsProps) { + const [copyState, setCopyState] = useState<"idle" | "copied" | "failed">("idle"); const scalarContext = diagnostics.context.filter( ([, value]) => !parseInlineJsonContextValue(value), ); @@ -31,20 +42,62 @@ 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, title)); + setCopyState("copied"); + } catch { + setCopyState("failed"); + } + }; return (
-
Error
+
{title}
{diagnostics.message}
- +
+ + + More details + Less details + + +
{(diagnostics.trace || diagnostics.time) && ( @@ -93,6 +146,28 @@ export function ErrorDetails({ diagnostics, renderJsonContext }: ErrorDetailsPro )}
)} + {diagnostics.details?.map((detail) => ( +
+
+
+ {detail.label} +
+ +
+ {/* break-all because a response body is one long token as often + as not, and pre-wrap alone only breaks on whitespace. */} +
+              {detail.value}
+            
+
+ ))} {diagnostics.stacktrace && (
@@ -116,6 +191,26 @@ export function ErrorDetails({ diagnostics, renderJsonContext }: ErrorDetailsPro ); } +function diagnosticReport(diagnostics: ErrorDiagnostics, title: string): string { + const lines = [`${title}: ${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}`), + ); + } + for (const detail of diagnostics.details ?? []) { + lines.push("", `${detail.label}:`, detail.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.test.ts b/packages/ui/src/data/diagnostics/error-diagnostics.test.ts index d56ecd65f..f9416bb3c 100644 --- a/packages/ui/src/data/diagnostics/error-diagnostics.test.ts +++ b/packages/ui/src/data/diagnostics/error-diagnostics.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { parseDiagnosticsStackTrace } from "./error-diagnostics"; +import { + normalizeErrorDiagnostics, + parseDiagnosticsStackTrace, +} from "./error-diagnostics"; describe("parseDiagnosticsStackTrace", () => { it("parses file, line, and function names", () => { @@ -25,3 +28,53 @@ describe("parseDiagnosticsStackTrace", () => { ]); }); }); + +describe("normalizeErrorDiagnostics", () => { + // Mirrors entity.ErrorResponse on the Go side: hint and details are part of + // the wire format, so dropping them loses the two fields that most often say + // what to do next and what actually ran. + it("keeps the hint and the detail blocks the server sent", () => { + const diagnostics = normalizeErrorDiagnostics({ + code: "query_failed", + message: "relation \"sessions\" does not exist", + trace: "9f2b1c4d5e6a7b8c", + hint: "Run the migrations before querying this profile", + context: { profile: "gavel-sessions" }, + details: [ + { label: "Query", value: "SELECT * FROM sessions", content_type: "text/sql" }, + ], + }); + + expect(diagnostics).not.toBeNull(); + expect(diagnostics?.message).toBe("relation \"sessions\" does not exist"); + expect(diagnostics?.trace).toBe("9f2b1c4d5e6a7b8c"); + expect(diagnostics?.context).toEqual([ + ["Hint", "Run the migrations before querying this profile"], + ["profile", "gavel-sessions"], + ]); + expect(diagnostics?.details).toEqual([ + { label: "Query", value: "SELECT * FROM sessions" }, + ]); + }); + + // `details` is overloaded on the wire: an array of blocks from our own + // envelope, but a bare string in third-party error bodies, where it is the + // message. Both readings have to keep working. + it("still reads a string details field as the message", () => { + const diagnostics = normalizeErrorDiagnostics({ + details: "upstream refused the connection", + }); + + expect(diagnostics?.message).toBe("upstream refused the connection"); + expect(diagnostics?.details).toBeUndefined(); + }); + + it("ignores detail entries with no usable value", () => { + const diagnostics = normalizeErrorDiagnostics({ + message: "render failed", + details: [{ label: "Query" }, { value: "orphan" }, "not an object"], + }); + + expect(diagnostics?.details).toBeUndefined(); + }); +}); diff --git a/packages/ui/src/data/diagnostics/error-diagnostics.ts b/packages/ui/src/data/diagnostics/error-diagnostics.ts index 45f5962e5..c5f9adab6 100644 --- a/packages/ui/src/data/diagnostics/error-diagnostics.ts +++ b/packages/ui/src/data/diagnostics/error-diagnostics.ts @@ -8,9 +8,18 @@ export type ErrorDiagnostics = { time?: string; stacktrace?: string; context: Array<[string, string]>; + // details carries values too long for a context badge — a failing SQL + // statement, a response body. They render as labeled preformatted blocks and + // are part of the copied report. + details?: ErrorDetailBlock[]; raw?: unknown; }; +export type ErrorDetailBlock = { + label: string; + value: string; +}; + export type ParsedErrorStackTrace = { headline?: string; frames: ErrorStackFrame[]; @@ -35,17 +44,30 @@ 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; const trace = firstString(record, ["trace", "trace_id", "traceId", "traceID"]); 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; + const hint = firstString(record, ["hint"]); + // The hint leads the context badges: of everything the server attaches it is + // the one field that says what to do next. + const context: Array<[string, string]> = [ + ...(hint ? ([["Hint", hint]] as Array<[string, string]>) : []), + ...contextEntries(record.context), + ]; + const details = detailBlocks(record.details); + 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, @@ -53,9 +75,25 @@ export function normalizeErrorDiagnostics( ...(trace !== undefined ? { trace } : {}), ...(time !== undefined ? { time } : {}), ...(stacktrace !== undefined ? { stacktrace } : {}), + ...(details.length > 0 ? { details } : {}), }; } +// `details` is overloaded on the wire: our own envelope sends an array of +// {label, value} blocks (entity.ErrorResponse), while third-party bodies use a +// bare string that firstString already reads as the message. Only the array +// form produces blocks; content_type is dropped because every block renders as +// preformatted text either way. +function detailBlocks(value: unknown): ErrorDetailBlock[] { + if (!Array.isArray(value)) return []; + return value.flatMap((entry) => { + const record = objectRecord(entry); + const label = firstString(record ?? {}, ["label"]); + const detail = firstString(record ?? {}, ["value"]); + return label && detail ? [{ label, value: detail }] : []; + }); +} + export function parseDiagnosticsStackTrace(stacktrace: string): ParsedErrorStackTrace { const lines = stacktrace .split(/\r?\n/) 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 a3385f4c3..dccd6242c 100644 --- a/packages/ui/src/data/query-browser/QueryBrowser.paging.test.tsx +++ b/packages/ui/src/data/query-browser/QueryBrowser.paging.test.tsx @@ -15,7 +15,7 @@ import { describe("QueryBrowser paging and provider diagnostics", () => { beforeEach(() => window.localStorage.clear()); - it("requests provider diagnostics and renders the actual provider exchange", async () => { + it("asks the backend what it ran when Debug is chosen, and renders the exchange", async () => { const diagnostics: QueryBrowserDiagnostics = { provider: "clickhouse", request: { @@ -44,9 +44,18 @@ describe("QueryBrowser paging and provider diagnostics", () => { />, ); - fireEvent.click(screen.getByRole("button", { name: "Debug" })); fireEvent.click(screen.getByRole("button", { name: "Run" })); + // The run itself asks for no diagnostics: they cost a second execution, so + // only a user who asked the question pays for the answer. + await waitFor(() => + expect(execute).toHaveBeenCalledWith({ query: "SELECT 42", options: {} }), + ); + await screen.findByRole("table"); + + fireEvent.click(screen.getByRole("button", { name: "Open column menu" })); + fireEvent.click(await screen.findByRole("menuitem", { name: /^Debug$/ })); + await waitFor(() => expect(execute).toHaveBeenCalledWith({ query: "SELECT 42", @@ -184,7 +193,6 @@ describe("QueryBrowser paging and provider diagnostics", () => { />, ); - fireEvent.click(screen.getByRole("button", { name: "Debug" })); fireEvent.click(screen.getByRole("button", { name: "Run" })); expect(await screen.findByText("query failed")).toBeInTheDocument(); @@ -195,4 +203,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 000000000..783efc7ab --- /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.test.tsx b/packages/ui/src/data/query-browser/QueryBrowser.test.tsx index d2140bb5a..f26da1beb 100644 --- a/packages/ui/src/data/query-browser/QueryBrowser.test.tsx +++ b/packages/ui/src/data/query-browser/QueryBrowser.test.tsx @@ -33,6 +33,40 @@ describe("QueryBrowser", () => { ).toContain("SELECT 42"); }); + it("runs an option-only request when an empty query is allowed", async () => { + const execute = vi.fn().mockResolvedValue({ rows: [] }); + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Run" })); + + await waitFor(() => + expect(execute).toHaveBeenCalledWith({ + query: "", + options: { + kind: "Pod", + namespace: "payments", + name: "api-abc12", + }, + }), + ); + expect( + window.localStorage.getItem( + "clicky-ui:query-browser:kubernetes-logs:history", + ), + ).toBeNull(); + }); + // A trailing "+" says there is more without saying the console stopped, which // reads as a small table rather than a bounded read. it("names the bound a truncated console read stopped at", async () => { diff --git a/packages/ui/src/data/query-browser/QueryBrowser.tsx b/packages/ui/src/data/query-browser/QueryBrowser.tsx index c1032b733..44a0af770 100644 --- a/packages/ui/src/data/query-browser/QueryBrowser.tsx +++ b/packages/ui/src/data/query-browser/QueryBrowser.tsx @@ -6,7 +6,7 @@ import { JsonSchemaForm } from "../../components/JsonSchemaForm"; import { cn } from "../../lib/utils"; import { SplitPane } from "../../layout/SplitPane"; import { Icon } from "../Icon"; -import { UiDebug, UiPlay } from "../../icons"; +import { UiPlay } from "../../icons"; import type { DataTablePagination } from "../DataTable"; import { inferColumns } from "../data-table-utils"; import type { DataTableFilterSelection } from "../data-table-filter-values"; @@ -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, @@ -26,6 +27,8 @@ import { } from "./QueryBrowser.editor"; import { QueryBrowserDiagnosticsPanel } from "./QueryBrowserDiagnosticsPanel"; import { QueryBrowserResults } from "./QueryBrowserResults"; +import { useQueryInfo } from "../query-info/useQueryInfo"; +import type { QueryExecutionInfo } from "../query-info/queryInfo"; import { QueryBrowserExecutionError, type QueryBrowserDiagnostics, @@ -40,6 +43,7 @@ export function QueryBrowser({ language = "text", initialQuery = "", queryLabel = "Query", + allowEmptyQuery = false, optionsSchema, initialOptions, completion, @@ -64,9 +68,9 @@ export function QueryBrowser({ message: string; query: string; diagnostics?: QueryBrowserDiagnostics; + errorDetails?: ErrorDiagnostics; } | null>(null); const [pending, setPending] = useState(false); - const [debug, setDebug] = useState(false); const [entries, setEntries] = useState(() => readQueryBrowserHistory(id), ); @@ -109,6 +113,9 @@ export function QueryBrowser({ ...(err instanceof QueryBrowserExecutionError && err.diagnostics ? { diagnostics: err.diagnostics } : {}), + ...(err instanceof QueryBrowserExecutionError && err.errorDetails + ? { errorDetails: err.errorDetails } + : {}), }); } finally { setPending(false); @@ -128,8 +135,8 @@ export function QueryBrowser({ const run = useCallback(async () => { const query = currentQuery().trim(); - if (!query || pending) return; - setEntries(rememberQueryBrowserQuery(id, query)); + if ((!query && !allowEmptyQuery) || pending) return; + if (query) setEntries(rememberQueryBrowserQuery(id, query)); // A different statement is a different result set, so neither its filters // nor the columns they bind to survive: both name columns the new query may // not return. @@ -143,9 +150,8 @@ export function QueryBrowser({ options, ...(Object.keys(carried).length > 0 ? { filters: carried } : {}), ...(repeat?.columns ? { columns: repeat.columns } : {}), - ...(debug ? { debug: true } : {}), }); - }, [currentQuery, debug, filters, id, options, pending, runRequest]); + }, [allowEmptyQuery, currentQuery, filters, id, options, pending, runRequest]); // A filter pill is not a draft the way query text is: it commits a discrete // value on selection, and the bar already debounces its free-text fields. So @@ -165,10 +171,9 @@ export function QueryBrowser({ ...(result?.pagination ? { pagination: { limit: result.pagination.limit } } : {}), - ...(debug ? { debug: true } : {}), }); }, - [debug, result?.pagination, runRequest], + [result?.pagination, runRequest], ); const rerunAt = useCallback( @@ -179,15 +184,58 @@ export function QueryBrowser({ "QueryBrowser: pagination changed before any query had run", ); } - await runRequest({ - ...previous, - pagination, - ...(debug ? { debug: true } : {}), - }); + await runRequest({ ...previous, pagination }); }, - [debug, runRequest], + [runRequest], ); + // "Debug" runs the displayed query again with diagnostics on rather than + // reading them off the result: a debug run costs a second execution, and one + // paid on every query so the answer is there if asked for is the wrong trade. + const loadQueryInfo = useCallback(async (): Promise => { + const previous = lastRun.current; + if (!previous) { + throw new Error("Run a query before asking what it sends"); + } + const pagination = result?.pagination; + try { + const probe = await execute({ + ...previous, + ...(pagination + ? { + pagination: { + limit: pagination.limit, + ...(pagination.offset !== undefined + ? { offset: pagination.offset } + : {}), + ...(pagination.cursor ? { cursor: pagination.cursor } : {}), + }, + } + : {}), + debug: true, + }); + return { + ...(probe.diagnostics?.provider + ? { provider: probe.diagnostics.provider } + : {}), + ...(probe.rows ? { rows: probe.rows.length } : {}), + ...(probe.durationMs !== undefined + ? { durationMs: probe.durationMs } + : {}), + ...(probe.diagnostics ? { diagnostics: probe.diagnostics } : {}), + }; + } catch (err) { + if (err instanceof QueryBrowserExecutionError) { + return { + error: err.message, + ...(err.diagnostics ? { diagnostics: err.diagnostics } : {}), + }; + } + throw err; + } + }, [execute, result?.pagination]); + const queryInfo = useQueryInfo({ load: loadQueryInfo, title: queryLabel }); + useEffect(() => { if (!lastRun.current) return; if (filters === executedFilters.current) return; @@ -255,7 +303,6 @@ export function QueryBrowser({ setResult(null); setError(null); setFilters({}); - setDebug(false); lastRun.current = null; executedFilters.current = {}; }, [id]); @@ -342,6 +389,7 @@ export function QueryBrowser({ filterConfig={filterConfig} serverFiltered={serverFiltered} {...(tablePagination ? { pagination: tablePagination } : {})} + {...(queryInfo.action ? { menuActions: [queryInfo.action] } : {})} /> ); @@ -375,15 +423,6 @@ export function QueryBrowser({ ))} )} -
)} - - {diagnostics.response?.preview && ( + + + {response?.preview && (
-              {diagnostics.response.preview}
+              {response.preview}
             
)} @@ -73,3 +113,12 @@ export function QueryBrowserDiagnosticsPanel({
); } + +// responseSummary drops what the panel renders as itself: the body preview, +// which is the response and not a fact about it, and the headers, which read as +// a list rather than as a field of a summary. +function responseSummary(response: QueryBrowserDiagnostics["response"]) { + if (!response) return undefined; + const { preview: _preview, headers: _headers, ...summary } = response; + return summary; +} diff --git a/packages/ui/src/data/query-browser/QueryBrowserResults.tsx b/packages/ui/src/data/query-browser/QueryBrowserResults.tsx index e77e9235b..90aa2854e 100644 --- a/packages/ui/src/data/query-browser/QueryBrowserResults.tsx +++ b/packages/ui/src/data/query-browser/QueryBrowserResults.tsx @@ -1,12 +1,12 @@ import { DataTable, type DataTableColumn, + type DataTableMenuAction, type DataTablePagination, } from "../DataTable"; import { Properties } from "../Properties"; import type { serverFiltersToFilterBar } from "../data-table-server-filters"; import type { QueryBrowserResult } from "./QueryBrowser.types"; -import { QueryBrowserDiagnosticsPanel } from "./QueryBrowserDiagnosticsPanel"; type QueryBrowserResultsProps = { result: QueryBrowserResult | null; @@ -17,6 +17,8 @@ type QueryBrowserResultsProps = { filterConfig: ReturnType; serverFiltered: boolean; pagination?: DataTablePagination; + /** Extra entries for the table's overflow menu — "Debug" among them. */ + menuActions?: DataTableMenuAction[]; }; export function QueryBrowserResults({ @@ -28,6 +30,7 @@ export function QueryBrowserResults({ filterConfig, serverFiltered, pagination, + menuActions, }: QueryBrowserResultsProps) { if (!result) { return ( @@ -89,6 +92,7 @@ export function QueryBrowserResults({ /> )} {...(pagination ? { pagination } : {})} + {...(menuActions && menuActions.length > 0 ? { menuActions } : {})} className="min-h-72 flex-1" /> ) : ( @@ -96,9 +100,6 @@ export function QueryBrowserResults({ {result.message ?? "Statement completed with no rows."}
)} - {result.diagnostics && ( - - )}
); } diff --git a/packages/ui/src/data/query-info/QueryInfo.clicky.test.tsx b/packages/ui/src/data/query-info/QueryInfo.clicky.test.tsx new file mode 100644 index 000000000..885d1cb37 --- /dev/null +++ b/packages/ui/src/data/query-info/QueryInfo.clicky.test.tsx @@ -0,0 +1,186 @@ +import { fireEvent, render, screen, within } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { Clicky, type ClickyDocument } from "../Clicky"; + +const tableDocument: ClickyDocument = { + version: 1, + node: { + kind: "table", + columns: [ + { name: "code", label: "Code" }, + { name: "name", label: "Name" }, + ], + rows: [ + { + cells: { + code: { kind: "text", text: "200", plain: "200" }, + name: { kind: "text", text: "Sales", plain: "Sales" }, + }, + }, + ], + }, +}; + +const info = { + profile: "orders", + provider: "postgres", + url: "/api/v1/profile/orders?limit=5", + mode: "page", + rows: 1, + durationMs: 12.4, + headers: { "X-Total-Count": "137" }, + diagnostics: { + provider: "postgres", + request: { + query: "SELECT * FROM orders WHERE state = $1 LIMIT 5", + arguments: ["open"], + }, + response: { returnedRows: 1, durationMs: 11 }, + }, +}; + +function stubRemote() { + return vi + .spyOn(globalThis, "fetch") + .mockImplementation(async (input: RequestInfo | URL) => { + const url = String(input); + const body = url.includes("__info=true") ? info : tableDocument; + return new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); +} + +describe("Debug on a remote Clicky table", () => { + afterEach(() => vi.restoreAllMocks()); + + it("asks the result URL what it ran and shows the answer", async () => { + const fetchSpy = stubRemote(); + + render( + , + ); + expect(await screen.findByRole("table")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: /open column menu/i })); + const menu = screen.getByRole("menu", { name: /column menu/i }); + fireEvent.click( + within(menu).getByRole("menuitem", { name: /^debug$/i }), + ); + + // The details come from the same URL the rows came from, marked as a + // question — every filter and paging parameter carried along. + expect( + fetchSpy.mock.calls.some(([input]) => + String(input).includes("/api/v1/profile/orders?limit=5&__info=true"), + ), + ).toBe(true); + + const dialog = await screen.findByRole("dialog"); + expect( + await within(dialog).findByLabelText("Provider query"), + ).toHaveTextContent("SELECT * FROM orders WHERE state = $1 LIMIT 5"); + expect(within(dialog).getByText(/X-Total-Count: 137/)).toBeInTheDocument(); + }); + + it("sits beside Export rather than behind a wall of formats", async () => { + stubRemote(); + + render( + , + ); + expect(await screen.findByRole("table")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: /open column menu/i })); + const menu = screen.getByRole("menu", { name: /column menu/i }); + const actions = within(menu) + .getAllByRole("menuitem") + .map((item) => item.textContent ?? ""); + + // Three rows, in this order. Ten view formats and five download formats + // are behind the first two, so Debug is on screen without scrolling. + expect(actions).toHaveLength(3); + expect(actions[0]).toMatch(/^View:/); + expect(actions[1]).toMatch(/^Export/); + expect(actions[2]).toMatch(/^Debug$/); + }); + + it("names the all-rows export by the total the table was given", async () => { + stubRemote(); + + render( + {}, + }} + />, + ); + expect(await screen.findByRole("table")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: /open column menu/i })); + fireEvent.click(screen.getByRole("menuitem", { name: /^Export/i })); + + const dialog = await screen.findByRole("dialog"); + // The count is the thing a person needs before starting an export, and the + // page in front of them never shows it. + expect( + within(dialog).getByRole("radio", { name: "All 1,372 rows" }), + ).toBeInTheDocument(); + expect( + within(dialog).queryByRole("radio", { name: "All rows" }), + ).not.toBeInTheDocument(); + }); + + it("reports a lower-bound total as a bound rather than a count", async () => { + stubRemote(); + + render( + {}, + }} + />, + ); + expect(await screen.findByRole("table")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: /open column menu/i })); + fireEvent.click(screen.getByRole("menuitem", { name: /^Export/i })); + + expect( + within(await screen.findByRole("dialog")).getByRole("radio", { + name: "All 10,000+ rows", + }), + ).toBeInTheDocument(); + }); + + it("offers nothing to ask when the payload has no URL behind it", () => { + render(); + + fireEvent.click(screen.getByRole("button", { name: /open column menu/i })); + expect( + within(screen.getByRole("menu", { name: /column menu/i })).queryByRole( + "menuitem", + { name: /^debug$/i }, + ), + ).not.toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/data/query-info/QueryInfoDialog.tsx b/packages/ui/src/data/query-info/QueryInfoDialog.tsx new file mode 100644 index 000000000..d1ff53754 --- /dev/null +++ b/packages/ui/src/data/query-info/QueryInfoDialog.tsx @@ -0,0 +1,158 @@ +import { useEffect, useState } from "react"; +import { Modal } from "../../overlay/Modal"; +import { Properties } from "../Properties"; +import { QueryBrowserDiagnosticsPanel } from "../query-browser/QueryBrowserDiagnosticsPanel"; +import type { QueryExecutionInfo, QueryInfoLoader } from "./queryInfo"; + +export type QueryInfoDialogProps = { + open: boolean; + onClose: () => void; + /** Reads the details. Keep the reference stable — it runs on every open. */ + load: QueryInfoLoader; + title?: string; +}; + +type LoadState = + | { status: "pending" } + | { status: "loaded"; info: QueryExecutionInfo } + | { status: "failed"; message: string }; + +/** + * QueryInfoDialog shows what a result surface actually ran. + * + * It reads on open rather than alongside the rows: the details cost a second + * execution, and a table nobody asked a question about should not pay for one. + */ +export function QueryInfoDialog({ + open, + onClose, + load, + title = "Query", +}: QueryInfoDialogProps) { + const [state, setState] = useState({ status: "pending" }); + + useEffect(() => { + if (!open) return; + let active = true; + setState({ status: "pending" }); + load() + .then((info) => { + if (active) setState({ status: "loaded", info }); + }) + .catch((error: unknown) => { + if (!active) return; + setState({ + status: "failed", + message: + error instanceof Error + ? error.message + : "Reading the query details failed", + }); + }); + return () => { + active = false; + }; + }, [open, load]); + + const info = state.status === "loaded" ? state.info : undefined; + + return ( + + {state.status === "pending" && ( +

Reading query details…

+ )} + + {state.status === "failed" && ( +
+ {state.message} +
+ )} + + {info && ( +
+ {info.error && ( +
+ {info.error} +
+ )} + + {info.url && ( +
+

+ Request +

+
+                {info.url}
+              
+
+ )} + + + + {info.diagnostics && ( + + )} + + {info.headers && Object.keys(info.headers).length > 0 && ( +
+

+ Response headers +

+
+                {Object.entries(info.headers)
+                  .map(([key, value]) => `${key}: ${value}`)
+                  .join("\n")}
+              
+
+ )} +
+ )} +
+ ); +} + +function summaryItems(info: QueryExecutionInfo) { + const items: { key: string; value: unknown; hidden?: boolean }[] = [ + { key: "Profile", value: info.profile }, + { key: "Provider", value: info.provider }, + { key: "Connection", value: info.connection }, + { key: "Mode", value: info.mode }, + { + key: "Rows", + value: info.rows === undefined ? undefined : info.rows.toLocaleString(), + }, + { + key: "Duration", + value: + info.durationMs === undefined + ? undefined + : `${Math.round(info.durationMs)} ms`, + }, + { + key: "Parameters", + value: + info.params && Object.keys(info.params).length > 0 + ? JSON.stringify(info.params) + : undefined, + }, + ]; + return items.filter((item) => item.value !== undefined && item.value !== ""); +} diff --git a/packages/ui/src/data/query-info/index.ts b/packages/ui/src/data/query-info/index.ts new file mode 100644 index 000000000..1fb757f34 --- /dev/null +++ b/packages/ui/src/data/query-info/index.ts @@ -0,0 +1,13 @@ +export { QueryInfoDialog, type QueryInfoDialogProps } from "./QueryInfoDialog"; +export { + useQueryInfo, + type UseQueryInfoOptions, + type UseQueryInfoResult, +} from "./useQueryInfo"; +export { + buildQueryInfoUrl, + fetchQueryInfo, + QUERY_INFO_CONTENT_TYPE, + type QueryExecutionInfo, + type QueryInfoLoader, +} from "./queryInfo"; diff --git a/packages/ui/src/data/query-info/queryInfo.test.ts b/packages/ui/src/data/query-info/queryInfo.test.ts new file mode 100644 index 000000000..1f7d368fe --- /dev/null +++ b/packages/ui/src/data/query-info/queryInfo.test.ts @@ -0,0 +1,94 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + buildQueryInfoUrl, + fetchQueryInfo, + QUERY_INFO_CONTENT_TYPE, +} from "./queryInfo"; + +function stubFetch(response: { + ok: boolean; + status?: number; + statusText?: string; + body: string; +}) { + const fetchMock = vi.fn().mockResolvedValue({ + ok: response.ok, + status: response.status ?? (response.ok ? 200 : 400), + statusText: response.statusText ?? "", + text: async () => response.body, + }); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; +} + +describe("buildQueryInfoUrl", () => { + it.each([ + ["/api/v1/profile/orders", "/api/v1/profile/orders?__info=true"], + [ + "/api/v1/profile/orders?limit=25&filter.state=open", + "/api/v1/profile/orders?limit=25&filter.state=open&__info=true", + ], + ["/orders?limit=25#rows", "/orders?limit=25&__info=true#rows"], + ])("marks %s as a question about itself", (url, expected) => { + expect(buildQueryInfoUrl(url)).toBe(expected); + }); + + it("leaves a URL that already asks the question alone", () => { + expect(buildQueryInfoUrl("/orders?__info=true")).toBe( + "/orders?__info=true", + ); + }); +}); + +describe("fetchQueryInfo", () => { + afterEach(() => vi.unstubAllGlobals()); + + it("negotiates the info media type and returns the details", async () => { + const fetchMock = stubFetch({ + ok: true, + body: JSON.stringify({ + profile: "orders", + provider: "postgres", + rows: 25, + diagnostics: { provider: "postgres", request: { query: "SELECT 1" } }, + }), + }); + + const info = await fetchQueryInfo("/api/v1/profile/orders?limit=25"); + + expect(fetchMock).toHaveBeenCalledWith( + "/api/v1/profile/orders?limit=25&__info=true", + { headers: { Accept: QUERY_INFO_CONTENT_TYPE } }, + ); + expect(info.profile).toBe("orders"); + expect(info.diagnostics?.request.query).toBe("SELECT 1"); + }); + + it("keeps the query a failed execution reports rather than throwing it away", async () => { + stubFetch({ + ok: false, + status: 400, + body: JSON.stringify({ + code: "query_failed", + message: 'relation "orders" does not exist', + diagnostics: { + provider: "postgres", + request: { query: "SELECT * FROM orders" }, + }, + }), + }); + + const info = await fetchQueryInfo("/api/v1/profile/orders"); + + expect(info.error).toBe('relation "orders" does not exist'); + expect(info.diagnostics?.request.query).toBe("SELECT * FROM orders"); + }); + + it("throws when the response carries no details at all", async () => { + stubFetch({ ok: false, status: 502, statusText: "Bad Gateway", body: "" }); + + await expect(fetchQueryInfo("/api/v1/profile/orders")).rejects.toThrow( + "502 Bad Gateway", + ); + }); +}); diff --git a/packages/ui/src/data/query-info/queryInfo.ts b/packages/ui/src/data/query-info/queryInfo.ts new file mode 100644 index 000000000..7feb43934 --- /dev/null +++ b/packages/ui/src/data/query-info/queryInfo.ts @@ -0,0 +1,90 @@ +import type { QueryBrowserDiagnostics } from "../query-browser/QueryBrowser.types"; + +/** Media type a result URL answers with when asked what it runs. */ +export const QUERY_INFO_CONTENT_TYPE = "application/info+json"; + +/** + * What a result URL says about itself: the query its backend was actually sent, + * the arguments bound into it, how long it took and what came back. + * + * A table shows rows, never the query behind them — the server renders that from + * parameters and filters which only exist at execution time. This is the answer + * to "what did this actually run", and it is the same answer whether the run + * succeeded or failed. + */ +export type QueryExecutionInfo = { + profile?: string; + provider?: string; + connection?: string; + url?: string; + params?: Record; + mode?: string; + rows?: number; + durationMs?: number; + headers?: Record; + diagnostics?: QueryBrowserDiagnostics; + /** Set when the execution failed; its diagnostics still describe the query. */ + error?: string; +}; + +/** Loads the info for one surface — a URL to ask, or a console's own re-run. */ +export type QueryInfoLoader = () => Promise; + +/** + * buildQueryInfoUrl marks a result URL as a question about itself rather than a + * request for its rows. The marker rides in the query string so the URL stays + * one a browser can follow, and every parameter the table sent — filters, paging, + * the lot — is carried along unchanged: an info request for a different page + * would describe a different query. + */ +export function buildQueryInfoUrl(url: string): string { + if (/[?&]__info=/.test(url)) return url; + const hashAt = url.indexOf("#"); + const base = hashAt === -1 ? url : url.slice(0, hashAt); + const hash = hashAt === -1 ? "" : url.slice(hashAt); + return `${base}${base.includes("?") ? "&" : "?"}__info=true${hash}`; +} + +/** + * fetchQueryInfo asks a result URL what it runs. + * + * A failed execution is not a failed request: the server answers it with the + * same document plus the error, because a query that broke is the one most worth + * reading. Only a response that carries no document at all throws. + */ +export async function fetchQueryInfo(url: string): Promise { + const response = await fetch(buildQueryInfoUrl(url), { + headers: { Accept: QUERY_INFO_CONTENT_TYPE }, + }); + const body = await response.text(); + const parsed = parseInfoBody(body); + if (response.ok) { + if (!parsed) throw new Error(`${url} did not answer with query details`); + return parsed; + } + if (!parsed) { + throw new Error(`${url} answered ${response.status} ${response.statusText}`); + } + return parsed; +} + +type ExecutionErrorBody = { + code?: string; + message?: string; + error?: string; + diagnostics?: QueryBrowserDiagnostics; +}; + +function parseInfoBody(body: string): QueryExecutionInfo | null { + let value: unknown; + try { + value = JSON.parse(body); + } catch { + return null; + } + if (!value || typeof value !== "object") return null; + const info = value as QueryExecutionInfo & ExecutionErrorBody; + const error = info.error ?? info.message; + if (!error) return info; + return { ...info, error }; +} diff --git a/packages/ui/src/data/query-info/useQueryInfo.tsx b/packages/ui/src/data/query-info/useQueryInfo.tsx new file mode 100644 index 000000000..542f1ab61 --- /dev/null +++ b/packages/ui/src/data/query-info/useQueryInfo.tsx @@ -0,0 +1,74 @@ +import { useCallback, useMemo, useState, type ReactNode } from "react"; +import { UiDebug } from "../../icons"; +import type { DataTableMenuAction } from "../DataTable"; +import { QueryInfoDialog } from "./QueryInfoDialog"; +import { fetchQueryInfo, type QueryInfoLoader } from "./queryInfo"; + +export type UseQueryInfoOptions = { + /** Result URL to ask what it runs. Ignored when `load` is given. */ + url?: string | undefined; + /** Reads the details some other way — a console re-running its own query. */ + load?: QueryInfoLoader | undefined; + /** Dialog heading. */ + title?: string | undefined; +}; + +export type UseQueryInfoResult = { + /** "Debug" for the table's overflow menu; absent with nothing to ask. */ + action: DataTableMenuAction | undefined; + /** Render alongside the table — the dialog the action opens. */ + dialog: ReactNode; +}; + +/** + * useQueryInfo puts "Debug" in a result table's overflow menu. + * + * The details are read when the menu item is chosen, never before: asking costs + * a second execution against the same backend, which is a price only a user who + * wants the answer should pay. + */ +export function useQueryInfo({ + url, + load, + title, +}: UseQueryInfoOptions): UseQueryInfoResult { + const [open, setOpen] = useState(false); + const loader = useMemo(() => { + if (load) return load; + if (!url) return undefined; + return () => fetchQueryInfo(url); + }, [load, url]); + const close = useCallback(() => setOpen(false), []); + // The action lands in a memoized menu-action list, so a fresh object each + // render would rebuild that list — and the table's menu — on every render. + const action = useMemo( + () => + loader + ? { + id: "show-query", + label: "Debug", + icon: UiDebug, + // No heading and no description: one word under a "Debug" heading + // beside a one-line gloss of itself is three ways of saying the + // same thing in a menu with room for one. + section: "", + onSelect: () => setOpen(true), + } + : undefined, + [loader], + ); + + if (!loader) return { action: undefined, dialog: null }; + + return { + action, + dialog: ( + + ), + }; +} diff --git a/packages/ui/src/jotai.ts b/packages/ui/src/jotai.ts index cc8c3e2b6..d9ef1a36b 100644 --- a/packages/ui/src/jotai.ts +++ b/packages/ui/src/jotai.ts @@ -21,6 +21,7 @@ export type { JotaiFilterBarSearchProps, JotaiFilterBarSelectMultiFilter, JotaiFilterBarTextFilter, + JotaiFilterBarWorkloadFilter, JotaiJsonSchemaFormAtom, JotaiJsonSchemaFormProps, JotaiWritableAtom, diff --git a/packages/ui/src/lib/collections.test.ts b/packages/ui/src/lib/collections.test.ts new file mode 100644 index 000000000..9b9fbe669 --- /dev/null +++ b/packages/ui/src/lib/collections.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { duplicateIndex, isPlainObject, moveItem, removeIndex, setIndex } from "./collections"; + +describe("immutable array helpers", () => { + it("setIndex replaces one element without mutating the source", () => { + const src = [1, 2, 3]; + const out = setIndex(src, 1, 9); + expect(out).toEqual([1, 9, 3]); + expect(src).toEqual([1, 2, 3]); + }); + + it("removeIndex drops one element without mutating the source", () => { + const src = ["a", "b", "c"]; + const out = removeIndex(src, 0); + expect(out).toEqual(["b", "c"]); + expect(src).toEqual(["a", "b", "c"]); + }); + + it("moveItem reorders and is a no-op past the boundaries", () => { + expect(moveItem([1, 2, 3], 0, 1)).toEqual([2, 1, 3]); + expect(moveItem([1, 2, 3], 2, 1)).toEqual([1, 3, 2]); + expect(moveItem([1, 2, 3], 0, -1)).toEqual([1, 2, 3]); + expect(moveItem([1, 2, 3], 2, 3)).toEqual([1, 2, 3]); + }); + + it("duplicateIndex inserts the copy after the source", () => { + expect(duplicateIndex(["a", "b", "c"], 1)).toEqual(["a", "b", "b", "c"]); + }); + + it("duplicateIndex clones an object item so edits cannot write through", () => { + const source = { path: "/users" }; + const [, copy] = duplicateIndex([source], 0) as Array<{ path: string }>; + expect(copy).not.toBe(source); + copy!.path = "/events"; + expect(source.path).toBe("/users"); + }); + + it("duplicateIndex defers to a supplied clone for nested state", () => { + const source = { headers: { accept: "json" } }; + const [, copy] = duplicateIndex([source], 0, (item) => ({ + headers: { ...item.headers }, + })); + expect(copy!.headers).not.toBe(source.headers); + }); + + it("isPlainObject accepts records and rejects arrays and null", () => { + expect(isPlainObject({ a: 1 })).toBe(true); + expect(isPlainObject([1])).toBe(false); + expect(isPlainObject(null)).toBe(false); + expect(isPlainObject("a")).toBe(false); + }); +}); diff --git a/packages/ui/src/lib/collections.ts b/packages/ui/src/lib/collections.ts new file mode 100644 index 000000000..f247601d3 --- /dev/null +++ b/packages/ui/src/lib/collections.ts @@ -0,0 +1,43 @@ +// Immutable index-addressed list helpers, shared by every editable-list surface +// (the JsonSchemaForm array displays and the standalone AccordionList). They live +// in lib/ rather than beside the form so a generic component never has to import +// a json-schema-form-* module to reorder a list. + +export function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function setIndex(arr: T[], i: number, v: T): T[] { + return arr.map((x, idx) => (idx === i ? v : x)); +} + +export function removeIndex(arr: T[], i: number): T[] { + return arr.filter((_, idx) => idx !== i); +} + +// moveItem is a no-op past either boundary, so a caller may offer "up" on the +// first row without guarding first. +export function moveItem(arr: T[], from: number, to: number): T[] { + if (to < 0 || to >= arr.length) return arr; + const next = [...arr]; + const [moved] = next.splice(from, 1); + next.splice(to, 0, moved as T); + return next; +} + +// duplicateIndex copies the item at `index` and inserts the copy after it. +// Plain objects and arrays are cloned one level deep so editing the copy cannot +// write through to the original. +export function duplicateIndex(items: T[], index: number, clone?: (item: T) => T): T[] { + const source = items[index] as T; + const copy = clone + ? clone(source) + : Array.isArray(source) + ? ([...source] as T) + : isPlainObject(source) + ? ({ ...source } as T) + : source; + const next = [...items]; + next.splice(index + 1, 0, copy); + return next; +} diff --git a/packages/ui/src/lib/string.test.ts b/packages/ui/src/lib/string.test.ts index 855958e6a..01524b68c 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 bbd328645..80c87b8d1 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/lib/utils.test.ts b/packages/ui/src/lib/utils.test.ts index 32938d65c..ec5ee4a6b 100644 --- a/packages/ui/src/lib/utils.test.ts +++ b/packages/ui/src/lib/utils.test.ts @@ -7,6 +7,10 @@ import { cn } from "./utils"; // consumer override, so the base (e.g. Button's fixed `h-control-h`) wins and the // override is silently dropped — that's the bug these guard against. describe("cn — design-token overrides", () => { + it("merges Tailwind 4 inset shadow scale utilities", () => { + expect(cn("inset-shadow-sm", "inset-shadow-md")).toBe("inset-shadow-md"); + }); + it.each([ // [inputs, the class that must remain, the class that must be dropped] [["h-control-h", "h-auto"], "h-auto", "h-control-h"], diff --git a/packages/ui/src/profiles.ts b/packages/ui/src/profiles.ts new file mode 100644 index 000000000..93ddf31bc --- /dev/null +++ b/packages/ui/src/profiles.ts @@ -0,0 +1,26 @@ +/** + * 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/profileApi"; +export * from "./profiles/cel"; +export * from "./profiles/connections"; +export * from "./profiles/editor"; +export * from "./profiles/elasticsearch"; +export * from "./profiles/fields"; +export * from "./profiles/processor"; +export * from "./profiles/query"; +export * from "./profiles/wizard"; diff --git a/packages/ui/src/profiles/.widen.py b/packages/ui/src/profiles/.widen.py new file mode 100644 index 000000000..ea0a22118 --- /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/cel/celEditor.test.tsx b/packages/ui/src/profiles/cel/celEditor.test.tsx new file mode 100644 index 000000000..49cd904cd --- /dev/null +++ b/packages/ui/src/profiles/cel/celEditor.test.tsx @@ -0,0 +1,136 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { describe, expect, it, vi } from "vitest"; + +import { CelEditorPanel } from "./celEditor"; +import type { CelResponse, CelScope } from "./celExpression"; + +const ROWS = [ + { message: "Timeout after 5006ms", level: "ERROR" }, + { message: "\tat com.acme.pay.Gateway.charge", level: "ERROR" }, + { message: "Timeout after 31ms", level: "WARN" }, +]; + +const EXPRESSION = 'int(row.message.split("after ")[1].split("ms")[0])'; + +/** What the server returns for an expression that reads nothing from row 2. */ +const PARTIAL: CelResponse = { + results: [ + { index: 0, value: 5006, type: "int" }, + { index: 1, type: "null" }, + { index: 2, value: 31, type: "int" }, + ], +}; + +/** + * Renders the dialog with the evaluation already answered. + * + * Seeding the cache under the dialog's own key is what lets a static render show + * the resolved state: react-query serves cached data synchronously, so no DOM, + * timers or async harness are needed to assert what the dialog makes of a + * result. It also pins the query key, which is the debounce. + */ +function renderDialog( + response: CelResponse, + { scope = "row" as CelScope, expression = EXPRESSION, rows = ROWS } = {}, +) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + client.setQueryData(["cel-expression", scope, expression, rows.length], response); + return renderToStaticMarkup( + + + , + ); +} + +describe("CelEditorDialog", () => { + it("counts the rows that read nothing apart from the ones that worked", () => { + const html = renderDialog(PARTIAL); + + expect(html).toContain("2 evaluated"); + expect(html).toContain("1 empty"); + }); + + it("offers a jump to the next barren row while one remains", () => { + expect(renderDialog(PARTIAL)).toContain("Next empty row"); + }); + + it("offers no jump once every row evaluates", () => { + const html = renderDialog({ + results: [ + { index: 0, value: 1, type: "int" }, + { index: 1, value: 2, type: "int" }, + { index: 2, value: 3, type: "int" }, + ], + }); + + expect(html).toContain("3 evaluated"); + expect(html).not.toContain("Next empty row"); + }); + + it("renders one coverage cell per sampled row, not per returned result", () => { + const html = renderDialog({ results: [{ index: 0, value: 1, type: "int" }] }); + + expect(html).toContain('aria-label="Row 3"'); + }); + + it("shows the compiler's own message for a row that failed", () => { + const html = renderDialog({ results: [{ index: 0, error: "undeclared reference to 'nope'" }] }); + + expect(html).toContain("undeclared reference"); + expect(html).toContain("1 failed"); + }); + + it("reports a request the server refused to evaluate at all", () => { + expect(renderDialog({ results: [], error: "expression is empty" })).toContain("expression is empty"); + }); + + it("warns when one expression returns more than one type", () => { + const html = renderDialog({ + results: [ + { index: 0, value: 1, type: "int" }, + { index: 1, value: "x", type: "string" }, + { index: 2, value: 3, type: "int" }, + ], + }); + + expect(html).toContain("returns int | string"); + }); + + it("names the scope it was opened in, which the document itself never records", () => { + expect(renderDialog(PARTIAL, { scope: "batch" })).toContain("Batch scope"); + expect(renderDialog(PARTIAL, { scope: "boundary" })).toContain("Boundary scope"); + }); + + it("offers the batch bindings, and none of the row's fields, in the batch scope", () => { + const html = renderDialog(PARTIAL, { scope: "batch" }); + + expect(html).toContain("the grouped rows, oldest first"); + expect(html).not.toContain(">message<"); + }); + + // The row's own keys are browsed rather than listed: the tree loads them on + // expansion, so a static render shows the root it will expand from. What the + // keys turn into once clicked is pinned in celPath.test.ts, against nodes + // this same tree produces. + it("browses the row's values in the row scope", () => { + const html = renderDialog(PARTIAL); + + expect(html).toContain('aria-label="Row values"'); + expect(html).toContain("{2 properties}"); + // The fixed names stay listed, because they are variables rather than data. + expect(html).toContain(">row<"); + expect(html).toContain(">span<"); + }); + + it("says plainly when there is nothing to evaluate against", () => { + expect(renderDialog({ results: [] }, { rows: [] })).toContain("nothing sampled yet"); + }); +}); diff --git a/packages/ui/src/profiles/cel/celEditor.tsx b/packages/ui/src/profiles/cel/celEditor.tsx new file mode 100644 index 000000000..8119d9b19 --- /dev/null +++ b/packages/ui/src/profiles/cel/celEditor.tsx @@ -0,0 +1,535 @@ +import { useMemo, useRef, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { Badge } from "../../data/Badge"; +import { Button } from "../../components/button"; +import { Icon } from "../../data/Icon"; +import { Modal } from "../../overlay/Modal"; +import { Tree } from "../../data/Tree"; +import { cn } from "../../lib/utils"; +import { createLazyJSONPathTree, type JSONPathNode } from "../../components/jsonPathTree"; +import type { + FieldControl, + PostExtensionContext, +} from "../../components/json-schema-form-types"; +import { UiArrowRight, UiCheck, UiCode2, UiSparkles, UiWarningTriangle } from "../../icons"; +import { useJsonPathSample } from "../query/jsonPathSample"; +import { celPathFor } from "./celPath"; + +import { + bindingsFor, + celExamplesFor, + coverage, + evaluateCel, + explainCelError, + isClean, + nextBarren, + unreachableKeys, + type CelScope, +} from "./celExpression"; + +const SCOPE_LABEL: Record = { + row: "Row", + batch: "Batch", + boundary: "Boundary", +}; + +function scopeOf(schema: Record): CelScope { + const declared = schema["x-clicky-cel-scope"]; + return declared === "batch" || declared === "boundary" ? declared : "row"; +} + +/** + * The expression editor. + * + * Its subject is coverage, not syntax. An expression is written against the rows + * on screen and the rows it reads nothing from are the ones the author has not + * looked at — and because this engine folds a missing field into null instead of + * throwing, a wrong expression comes back clean. So the strip of per-row + * outcomes and the jump to the next barren row are the point; highlighting and + * completion are what the input already had. + */ +export function CelEditorDialog(props: CelEditorProps) { + return ( + + + + ); +} + +type CelEditorProps = { + value: string; + scope: CelScope; + rows: Record[]; + title: string; + onChange: (next: string) => void; + onClose: () => void; +}; + +/** + * The dialog's contents, separated from the Modal that carries them. + * + * The split exists so this can be rendered and asserted without a DOM: Modal + * portals, and a portal renders to nothing server-side. + */ +export function CelEditorPanel({ value, scope, rows, onChange, onClose }: CelEditorProps) { + const [draft, setDraft] = useState(value); + const [focused, setFocused] = useState(0); + const [picked, setPicked] = useState(undefined); + const input = useRef(null); + + // Written where the caret is rather than appended: an accessor pasted onto + // the end of a half-written expression concatenates into nonsense, and the + // useful gesture is completing `…filter(e, e.key == ` from the tree. + const insert = (text: string) => { + const element = input.current; + setDraft((current) => { + const start = element?.selectionStart ?? current.length; + const end = element?.selectionEnd ?? current.length; + return current.slice(0, start) + text + current.slice(end); + }); + queueMicrotask(() => { + element?.focus(); + const at = (element?.selectionStart ?? 0) + text.length; + element?.setSelectionRange(at, at); + }); + }; + + // Evaluated server-side, debounced by react-query's key rather than a timer: + // the rows are already in the browser, so a keystroke costs one small request + // and no backend query. + const { data, isFetching, error } = useQuery({ + queryKey: ["cel-expression", scope, draft, rows.length], + enabled: draft.trim() !== "" && rows.length > 0, + staleTime: Infinity, + retry: false, + refetchOnWindowFocus: false, + queryFn: () => evaluateCel({ cel: draft, scope, rows }), + }); + + const results = data?.results ?? []; + const found = useMemo(() => coverage(results), [results]); + const focusedResult = results.find((result) => result.index === focused) ?? results[0]; + const jump = nextBarren(found, focused); + const row = rows[focused]; + const bindings = bindingsFor(scope, row); + const unreachable = unreachableKeys(row); + + // Rebuilt per focused row: the tree caches nodes by key, so without a prefix + // that changes with the row, row 2 would show row 1's loaded branches. + const tree = useMemo( + () => createLazyJSONPathTree(row, { keyPrefix: `row${focused}:` }), + [row, focused], + ); + const pickedPath = picked ? celPathFor(picked) : undefined; + + return ( +
+
+ + {SCOPE_LABEL[scope]} scope + + + {rows.length === 0 ? "nothing sampled yet" : `${rows.length} sampled rows`} + +
+ +
+
+