Expressions in list columns in words; one menu item highlighted per page #53

Merged
jcoffey-dev merged 1 commits from fix/alerts-column-and-nav-highlight into main 2026-09-29 01:06:13 +00:00
7 changed files with 148 additions and 2 deletions

No files matched your search

+35
View File
@@ -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();
});
});
+33
View File
@@ -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<string, { if?: string; then?: string }>; 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 };
}
+17
View File
@@ -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<string, unknown>; else?: string })
: null;
if (known) return known;
const cell = expressionCell(value, ft.expression);
if (!cell) return <span className="text-muted-foreground">-</span>;
return cell.code ? (
<code className="break-all rounded bg-muted/50 px-1.5 py-0.5 font-mono text-xs">{cell.text}</code>
) : (
cell.text
);
}
if (value && typeof value === 'object' && !Array.isArray(value)) {
const typeName = (value as Record<string, unknown>)['@type'];
if (typeof typeName === 'string') {
+9 -1
View File
@@ -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();
});
});
+11
View File
@@ -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<string, unknown>; 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);
}
+36
View File
@@ -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);
});
});
+7 -1
View File
@@ -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);