diff --git a/cockpit/render/computed-functions/angular/src/app/computed-functions.component.ts b/cockpit/render/computed-functions/angular/src/app/computed-functions.component.ts index 3df88f7e3..df2dc6254 100644 --- a/cockpit/render/computed-functions/angular/src/app/computed-functions.component.ts +++ b/cockpit/render/computed-functions/angular/src/app/computed-functions.component.ts @@ -12,6 +12,7 @@ import { StreamingTimelineComponent } from '../../../../shared/streaming-timelin import { ExampleSplitLayoutComponent } from '@threadplane/example-layouts'; import { COMPUTED_FUNCTIONS_SPECS } from './specs'; import { highlightJson } from '../../../../shared/json-highlight'; +import { toDisplayText } from '../../../../shared/to-display-text'; // --- Inline view components registered in the demo registry --- @@ -73,10 +74,7 @@ class DemoValueComponent { }) class DemoHeadingComponent { readonly content = input(''); - readonly displayContent = computed(() => { - const c = this.content(); - return typeof c === 'string' ? c : ''; - }); + readonly displayContent = computed(() => toDisplayText(this.content())); readonly childKeys = input([]); readonly spec = input(null); readonly bindings = input>({}); diff --git a/cockpit/render/computed-functions/angular/src/app/specs.ts b/cockpit/render/computed-functions/angular/src/app/specs.ts index 2049e89de..af3216fe9 100644 --- a/cockpit/render/computed-functions/angular/src/app/specs.ts +++ b/cockpit/render/computed-functions/angular/src/app/specs.ts @@ -16,14 +16,14 @@ export const COMPUTED_FUNCTIONS_SPECS: DemoSpec[] = [ type: 'Value', props: { label: 'Uppercase', - value: { $fn: 'uppercase', args: { value: 'hello world' } }, + value: { $computed: 'uppercase', args: { value: 'hello world' } }, }, }, reversed: { type: 'Value', props: { label: 'Reversed', - value: { $fn: 'reverse', args: { value: 'streaming' } }, + value: { $computed: 'reverse', args: { value: 'streaming' } }, }, }, }, @@ -43,14 +43,14 @@ export const COMPUTED_FUNCTIONS_SPECS: DemoSpec[] = [ type: 'Value', props: { label: 'Formatted Date', - value: { $fn: 'formatDate', args: { value: '2024-06-15T12:00:00Z' } }, + value: { $computed: 'formatDate', args: { value: '2024-06-15T12:00:00Z' } }, }, }, product: { type: 'Value', props: { label: 'Multiply 7 x 6', - value: { $fn: 'multiply', args: { a: 7, b: 6 } }, + value: { $computed: 'multiply', args: { a: 7, b: 6 } }, }, }, }, @@ -70,21 +70,21 @@ export const COMPUTED_FUNCTIONS_SPECS: DemoSpec[] = [ type: 'Value', props: { label: 'Multiply 12 x 5', - value: { $fn: 'multiply', args: { a: 12, b: 5 } }, + value: { $computed: 'multiply', args: { a: 12, b: 5 } }, }, }, transform: { type: 'Value', props: { label: 'Uppercase', - value: { $fn: 'uppercase', args: { value: 'computed functions' } }, + value: { $computed: 'uppercase', args: { value: 'computed functions' } }, }, }, format: { type: 'Value', props: { label: 'Date', - value: { $fn: 'formatDate', args: { value: '2025-01-01T00:00:00Z' } }, + value: { $computed: 'formatDate', args: { value: '2025-01-01T00:00:00Z' } }, }, }, }, diff --git a/cockpit/render/element-rendering/angular/src/app/element-rendering.component.ts b/cockpit/render/element-rendering/angular/src/app/element-rendering.component.ts index d1d7079de..687d8c25d 100644 --- a/cockpit/render/element-rendering/angular/src/app/element-rendering.component.ts +++ b/cockpit/render/element-rendering/angular/src/app/element-rendering.component.ts @@ -12,6 +12,7 @@ import { StreamingTimelineComponent } from '../../../../shared/streaming-timelin import { ExampleSplitLayoutComponent } from '@threadplane/example-layouts'; import { ELEMENT_RENDERING_SPECS } from './specs'; import { highlightJson } from '../../../../shared/json-highlight'; +import { toDisplayText } from '../../../../shared/to-display-text'; // --- Inline view components registered in the demo registry --- @@ -30,10 +31,7 @@ import { highlightJson } from '../../../../shared/json-highlight'; }) class DemoTextComponent { readonly content = input(''); - readonly displayContent = computed(() => { - const c = this.content(); - return typeof c === 'string' ? c : ''; - }); + readonly displayContent = computed(() => toDisplayText(this.content())); readonly childKeys = input([]); readonly spec = input(null); readonly bindings = input>({}); @@ -59,10 +57,7 @@ class DemoTextComponent { }) class DemoHeadingComponent { readonly content = input(''); - readonly displayContent = computed(() => { - const c = this.content(); - return typeof c === 'string' ? c : ''; - }); + readonly displayContent = computed(() => toDisplayText(this.content())); readonly childKeys = input([]); readonly spec = input(null); readonly bindings = input>({}); diff --git a/cockpit/render/registry/angular/src/app/registry.component.ts b/cockpit/render/registry/angular/src/app/registry.component.ts index 44b6b2941..9b589fabf 100644 --- a/cockpit/render/registry/angular/src/app/registry.component.ts +++ b/cockpit/render/registry/angular/src/app/registry.component.ts @@ -12,6 +12,7 @@ import { StreamingTimelineComponent } from '../../../../shared/streaming-timelin import { ExampleSplitLayoutComponent } from '@threadplane/example-layouts'; import { REGISTRY_SPECS } from './specs'; import { highlightJson } from '../../../../shared/json-highlight'; +import { toDisplayText } from '../../../../shared/to-display-text'; // --- Inline view components registered in the demo registry --- @@ -30,10 +31,7 @@ import { highlightJson } from '../../../../shared/json-highlight'; }) class DemoTextComponent { readonly content = input(''); - readonly displayContent = computed(() => { - const c = this.content(); - return typeof c === 'string' ? c : ''; - }); + readonly displayContent = computed(() => toDisplayText(this.content())); readonly childKeys = input([]); readonly spec = input(null); readonly bindings = input>({}); @@ -59,10 +57,7 @@ class DemoTextComponent { }) class DemoHeadingComponent { readonly content = input(''); - readonly displayContent = computed(() => { - const c = this.content(); - return typeof c === 'string' ? c : ''; - }); + readonly displayContent = computed(() => toDisplayText(this.content())); readonly childKeys = input([]); readonly spec = input(null); readonly bindings = input>({}); diff --git a/cockpit/render/repeat-loops/angular/src/app/repeat-loops.component.ts b/cockpit/render/repeat-loops/angular/src/app/repeat-loops.component.ts index e589655ea..836b1f150 100644 --- a/cockpit/render/repeat-loops/angular/src/app/repeat-loops.component.ts +++ b/cockpit/render/repeat-loops/angular/src/app/repeat-loops.component.ts @@ -12,6 +12,7 @@ import { StreamingTimelineComponent } from '../../../../shared/streaming-timelin import { ExampleSplitLayoutComponent } from '@threadplane/example-layouts'; import { REPEAT_LOOPS_SPECS } from './specs'; import { highlightJson } from '../../../../shared/json-highlight'; +import { toDisplayText } from '../../../../shared/to-display-text'; // --- Inline view components registered in the demo registry --- @@ -30,10 +31,7 @@ import { highlightJson } from '../../../../shared/json-highlight'; }) class DemoTextComponent { readonly content = input(''); - readonly displayContent = computed(() => { - const c = this.content(); - return typeof c === 'string' ? c : ''; - }); + readonly displayContent = computed(() => toDisplayText(this.content())); readonly childKeys = input([]); readonly spec = input(null); readonly bindings = input>({}); @@ -59,10 +57,7 @@ class DemoTextComponent { }) class DemoHeadingComponent { readonly content = input(''); - readonly displayContent = computed(() => { - const c = this.content(); - return typeof c === 'string' ? c : ''; - }); + readonly displayContent = computed(() => toDisplayText(this.content())); readonly childKeys = input([]); readonly spec = input(null); readonly bindings = input>({}); diff --git a/cockpit/render/shared/streaming-timeline.component.ts b/cockpit/render/shared/streaming-timeline.component.ts index 09ed7a18d..933a9fde7 100644 --- a/cockpit/render/shared/streaming-timeline.component.ts +++ b/cockpit/render/shared/streaming-timeline.component.ts @@ -60,7 +60,6 @@ import { StreamingSimulator } from './streaming-simulator'; border: 2px solid var(--tl-green-bright); transform: translate(-50%, -50%); box-shadow: 0 2px 6px rgba(0, 0, 0, 0.4); - transition: left 0.075s linear; } .tl__count { flex-shrink: 0; diff --git a/cockpit/render/shared/to-display-text.spec.ts b/cockpit/render/shared/to-display-text.spec.ts new file mode 100644 index 000000000..089642890 --- /dev/null +++ b/cockpit/render/shared/to-display-text.spec.ts @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MIT +import { toDisplayText } from './to-display-text'; + +describe('toDisplayText', () => { + it('returns strings unchanged', () => { + expect(toDisplayText('Alice')).toBe('Alice'); + expect(toDisplayText('')).toBe(''); + }); + + it('stringifies numbers (the bug: numeric $state bindings were dropped)', () => { + expect(toDisplayText(30)).toBe('30'); + expect(toDisplayText(42.5)).toBe('42.5'); + }); + + it('preserves zero (falsy but must display)', () => { + expect(toDisplayText(0)).toBe('0'); + }); + + it('stringifies booleans', () => { + expect(toDisplayText(true)).toBe('true'); + expect(toDisplayText(false)).toBe('false'); + }); + + it('returns empty string for null / undefined', () => { + expect(toDisplayText(null)).toBe(''); + expect(toDisplayText(undefined)).toBe(''); + }); + + it('returns empty string for objects and arrays (not display text)', () => { + expect(toDisplayText({ a: 1 })).toBe(''); + expect(toDisplayText([1, 2])).toBe(''); + }); +}); diff --git a/cockpit/render/shared/to-display-text.ts b/cockpit/render/shared/to-display-text.ts new file mode 100644 index 000000000..fca7b5a1f --- /dev/null +++ b/cockpit/render/shared/to-display-text.ts @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: MIT + +/** + * Coerce a resolved element prop (which may be a string, number, boolean, or — + * for an unresolved/object binding — something else) into display text. + * + * Demo view components bind props like `content`/`value` that can resolve to + * non-string primitives via `$state`/`$fn` bindings (e.g. a numeric + * `/user/age`). Returning `''` only for strings silently dropped those values; + * this renders any primitive and treats objects/null as "no text". + */ +export function toDisplayText(value: unknown): string { + if (value == null) return ''; + const t = typeof value; + if (t === 'string' || t === 'number' || t === 'boolean' || t === 'bigint') { + return String(value); + } + return ''; +} diff --git a/cockpit/render/spec-rendering/angular/src/app/spec-rendering.component.ts b/cockpit/render/spec-rendering/angular/src/app/spec-rendering.component.ts index 11a4afb02..18c4845b4 100644 --- a/cockpit/render/spec-rendering/angular/src/app/spec-rendering.component.ts +++ b/cockpit/render/spec-rendering/angular/src/app/spec-rendering.component.ts @@ -12,6 +12,7 @@ import { StreamingTimelineComponent } from '../../../../shared/streaming-timelin import { ExampleSplitLayoutComponent } from '@threadplane/example-layouts'; import { SPEC_RENDERING_SPECS } from './specs'; import { highlightJson } from '../../../../shared/json-highlight'; +import { toDisplayText } from '../../../../shared/to-display-text'; // --- Inline view components registered in the demo registry --- @@ -30,10 +31,7 @@ import { highlightJson } from '../../../../shared/json-highlight'; }) class DemoTextComponent { readonly content = input(''); - readonly displayContent = computed(() => { - const c = this.content(); - return typeof c === 'string' ? c : ''; - }); + readonly displayContent = computed(() => toDisplayText(this.content())); readonly childKeys = input([]); readonly spec = input(null); readonly bindings = input>({}); @@ -59,10 +57,7 @@ class DemoTextComponent { }) class DemoHeadingComponent { readonly content = input(''); - readonly displayContent = computed(() => { - const c = this.content(); - return typeof c === 'string' ? c : ''; - }); + readonly displayContent = computed(() => toDisplayText(this.content())); readonly childKeys = input([]); readonly spec = input(null); readonly bindings = input>({}); diff --git a/cockpit/render/state-management/angular/src/app/state-management.component.ts b/cockpit/render/state-management/angular/src/app/state-management.component.ts index 2f455aebe..333d513b0 100644 --- a/cockpit/render/state-management/angular/src/app/state-management.component.ts +++ b/cockpit/render/state-management/angular/src/app/state-management.component.ts @@ -12,6 +12,7 @@ import { StreamingTimelineComponent } from '../../../../shared/streaming-timelin import { ExampleSplitLayoutComponent } from '@threadplane/example-layouts'; import { STATE_MANAGEMENT_SPECS } from './specs'; import { highlightJson } from '../../../../shared/json-highlight'; +import { toDisplayText } from '../../../../shared/to-display-text'; // --- Inline view components registered in the demo registry --- @@ -30,10 +31,7 @@ import { highlightJson } from '../../../../shared/json-highlight'; }) class DemoTextComponent { readonly content = input(''); - readonly displayContent = computed(() => { - const c = this.content(); - return typeof c === 'string' ? c : ''; - }); + readonly displayContent = computed(() => toDisplayText(this.content())); readonly childKeys = input([]); readonly spec = input(null); readonly bindings = input>({}); @@ -59,10 +57,7 @@ class DemoTextComponent { }) class DemoHeadingComponent { readonly content = input(''); - readonly displayContent = computed(() => { - const c = this.content(); - return typeof c === 'string' ? c : ''; - }); + readonly displayContent = computed(() => toDisplayText(this.content())); readonly childKeys = input([]); readonly spec = input(null); readonly bindings = input>({});