From 262538aa2379522faa1c0dfb6cf2d974e9dcd9fe Mon Sep 17 00:00:00 2001 From: John Coffey Date: Mon, 28 Sep 2026 16:54:39 -0700 Subject: [PATCH] Expressions in list columns in words; one menu item highlighted per page --- src/components/expression/cell.test.ts | 35 +++++++++++++++++++++++++ src/components/expression/cell.ts | 33 +++++++++++++++++++++++ src/components/lists/DynamicList.tsx | 17 ++++++++++++ src/features/alerts/templates.test.ts | 10 ++++++- src/features/alerts/templates.ts | 11 ++++++++ src/lib/navTree.test.ts | 36 ++++++++++++++++++++++++++ src/lib/navTree.ts | 8 +++++- 7 files changed, 148 insertions(+), 2 deletions(-) create mode 100644 src/components/expression/cell.test.ts create mode 100644 src/components/expression/cell.ts create mode 100644 src/lib/navTree.test.ts diff --git a/src/components/expression/cell.test.ts b/src/components/expression/cell.test.ts new file mode 100644 index 0000000..0906a10 --- /dev/null +++ b/src/components/expression/cell.test.ts @@ -0,0 +1,35 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { describe, it, expect } from 'vitest'; +import { expressionCell } from './cell'; + +describe('expressionCell', () => { + it('says a plain expression as written', () => { + expect( + expressionCell({ match: {}, else: "metric('queue.count') > 500" }, { constants: [], variables: [] }), + ).toEqual({ text: "metric('queue.count') > 500", code: true }); + }); + + it('uses words when every part has them', () => { + const hints = { constants: ['relaxed', 'disable'], variables: ['local_port'] }; + const cell = expressionCell({ match: { 0: { if: 'local_port == 25', then: 'relaxed' } }, else: 'disable' }, hints); + expect(cell?.code).toBe(false); + expect(cell?.text).toMatch(/^Relaxed when .* 25; otherwise off$/); + }); + + it('counts rules it cannot put in words', () => { + expect(expressionCell({ match: { a: { if: 'x', then: 'y' } }, else: 'z' }, undefined)).toEqual({ + text: '1 rule; otherwise z', + code: true, + }); + }); + + it('is null for nothing', () => { + expect(expressionCell(null, undefined)).toBeNull(); + expect(expressionCell({ match: {}, else: '' }, undefined)).toBeNull(); + }); +}); diff --git a/src/components/expression/cell.ts b/src/components/expression/cell.ts new file mode 100644 index 0000000..fe7e5ee --- /dev/null +++ b/src/components/expression/cell.ts @@ -0,0 +1,33 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * inbuxa: an expression in a list column, instead of "-". In words when it + * can be said in words, else the expression as written. + */ + +import { summarize, type ExpressionHints } from './simple'; + +export interface ExpressionCell { + text: string; + /** True when `text` is the expression itself, to show as code. */ + code: boolean; +} + +export function expressionCell(value: unknown, hints: ExpressionHints | undefined): ExpressionCell | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const v = value as { match?: Record; else?: unknown }; + const match = v.match ?? {}; + const otherwise = typeof v.else === 'string' ? v.else : ''; + const rules = Object.values(match).map((r) => ({ if: String(r?.if ?? ''), then: String(r?.then ?? '') })); + if (hints) { + const words = summarize({ match: Object.fromEntries(rules.map((r, i) => [String(i), r])), else: otherwise }, hints); + if (words) return { text: words, code: false }; + } + if (rules.length === 0) return otherwise ? { text: otherwise, code: true } : null; + const n = rules.length === 1 ? '1 rule' : `${rules.length} rules`; + return { text: otherwise ? `${n}; otherwise ${otherwise}` : n, code: true }; +} diff --git a/src/components/lists/DynamicList.tsx b/src/components/lists/DynamicList.tsx index 308a9c6..b399d28 100644 --- a/src/components/lists/DynamicList.tsx +++ b/src/components/lists/DynamicList.tsx @@ -77,6 +77,8 @@ import type { ResolvedSchema } from '@/lib/schemaResolver'; import { ExplainButton } from '@/features/ai/explain/ExplainButton'; import { useExplainStore } from '@/features/ai/explain/explainStore'; import { useDirectoryLocks } from '@/features/lock/useDirectoryLocks'; +import { alertConditionWords } from '@/features/alerts/templates'; +import { expressionCell } from '@/components/expression/cell'; const ENUM_FILTER_COMBOBOX_THRESHOLD = 15; @@ -303,6 +305,21 @@ function renderCellValue( } case 'object': { + // inbuxa: an expression in words, or as written (an alert's condition, for one). + if (ft.objectName === 'x:Expression') { + const known = + objectName === 'x:Alert' + ? alertConditionWords(value as { match?: Record; else?: string }) + : null; + if (known) return known; + const cell = expressionCell(value, ft.expression); + if (!cell) return -; + return cell.code ? ( + {cell.text} + ) : ( + cell.text + ); + } if (value && typeof value === 'object' && !Array.isArray(value)) { const typeName = (value as Record)['@type']; if (typeof typeName === 'string') { diff --git a/src/features/alerts/templates.test.ts b/src/features/alerts/templates.test.ts index c7066e8..5b5b182 100644 --- a/src/features/alerts/templates.test.ts +++ b/src/features/alerts/templates.test.ts @@ -5,7 +5,7 @@ */ import { describe, it, expect } from 'vitest'; -import { describeAlert, recognize, templateCondition, TEMPLATES } from './templates'; +import { alertConditionWords, describeAlert, recognize, templateCondition, TEMPLATES } from './templates'; const byId = (id: string) => TEMPLATES.find((t) => t.id === id)!; @@ -44,3 +44,11 @@ describe('alert templates', () => { ).toBe("When the condition metric('smtp.connection-start') > 9 is true, raise an event for webhooks. (off)"); }); }); + +describe('alertConditionWords', () => { + it('names a template condition, and leaves others alone', () => { + expect(alertConditionWords({ match: {}, else: "metric('queue.count') > 500" })).toMatch(/^More than 500 /); + expect(alertConditionWords({ match: {}, else: "metric('x') > 1" })).toBeNull(); + expect(alertConditionWords({ match: { a: {} }, else: "metric('queue.count') > 500" })).toBeNull(); + }); +}); diff --git a/src/features/alerts/templates.ts b/src/features/alerts/templates.ts index d412d23..381c924 100644 --- a/src/features/alerts/templates.ts +++ b/src/features/alerts/templates.ts @@ -142,3 +142,14 @@ export function describeAlert(a: AlertRecord): string { const does = actions.length > 0 ? actions.join(' and ') : 'do nothing (no email or event is set)'; return `When ${when}, ${does}.${a.enable === false ? ' (off)' : ''}`; } + +/** An alert's condition in words for the list, when it came from a template: "More than 500 messages are waiting to be delivered". */ +export function alertConditionWords( + condition: { match?: Record; else?: string } | null, +): string | null { + if (!condition || Object.keys(condition.match ?? {}).length > 0 || !condition.else) return null; + const known = recognize(condition.else); + if (!known) return null; + const when = known.template.when(known.n); + return when.charAt(0).toUpperCase() + when.slice(1); +} diff --git a/src/lib/navTree.test.ts b/src/lib/navTree.test.ts new file mode 100644 index 0000000..d78e08d --- /dev/null +++ b/src/lib/navTree.test.ts @@ -0,0 +1,36 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { pathMatchesView } from './navTree'; +import { useSchemaStore } from '@/stores/schemaStore'; +import type { Schema } from '@/types/schema'; + +describe('pathMatchesView', () => { + beforeEach(() => { + useSchemaStore.setState({ + schema: { + objects: { + 'x:Metrics': { type: 'singleton' }, + 'x:Metrics/CollectorPrometheus': { type: 'view', objectName: 'x:Metrics' }, + 'x:Domain': { type: 'object' }, + }, + } as unknown as Schema, + }); + }); + + it('matches the page and its records', () => { + expect(pathMatchesView('/Settings/x:Metrics', 'Settings', 'x:Metrics')).toBe(true); + expect(pathMatchesView('/Management/x:Domain/b', 'Management', 'x:Domain')).toBe(true); + expect(pathMatchesView('/Management/x:Domain/new', 'Management', 'x:Domain')).toBe(true); + }); + + it('does not match a separate page under it', () => { + const path = '/Settings/x:Metrics/CollectorPrometheus'; + expect(pathMatchesView(path, 'Settings', 'x:Metrics')).toBe(false); + expect(pathMatchesView(path, 'Settings', 'x:Metrics/CollectorPrometheus')).toBe(true); + }); +}); diff --git a/src/lib/navTree.ts b/src/lib/navTree.ts index 3b3da89..0fa21ea 100644 --- a/src/lib/navTree.ts +++ b/src/lib/navTree.ts @@ -31,7 +31,13 @@ export function resolveViewPath(sectionName: string, viewName: string): string { export function pathMatchesView(currentPath: string, sectionName: string, viewName: string): boolean { const base = `/${sectionName}/${viewName}`; - if (currentPath === base || currentPath.startsWith(`${base}/`)) return true; + if (currentPath === base) return true; + if (currentPath.startsWith(`${base}/`)) { + // inbuxa: /Settings/x:Metrics/CollectorPrometheus is its own page, not a record of + // x:Metrics, so General and Prometheus aren't both highlighted. + const next = decodeURIComponent(currentPath.slice(base.length + 1).split('/')[0]); + return !useSchemaStore.getState().schema?.objects[`${viewName}/${next}`]; + } if (viewName === 'CustomComponent/Dashboard') { const dashBase = `/${sectionName}/Dashboard/`; return currentPath.startsWith(dashBase); -- 2.54.0