Expressions in list columns in words; one menu item highlighted per page #53
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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') {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user