Skip to content

Commit 5923e5b

Browse files
committed
Add format and tpl helpers
1 parent 4fdb7b9 commit 5923e5b

6 files changed

Lines changed: 212 additions & 7 deletions

File tree

docs/content/intro/TypeScriptMigration.js

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -289,8 +289,80 @@ const m = createAccessorModelProxy<FormModel>();
289289
| `lessThanOrEqual(accessor, value)` | True if `x &lt;= value` |
290290
| `greaterThan(accessor, value)` | True if `x &gt; value` |
291291
| `greaterThanOrEqual(accessor, value)` | True if `x &gt;= value` |
292+
| `format(accessor, fmt, nullText?)` | Formats value using format string |
292293

293-
These helpers return `Selector&lt;boolean&gt;` which can be used anywhere a boolean binding is expected.
294+
These helpers return `Selector&lt;boolean&gt;` (or `Selector&lt;string&gt;` for `format`) which can be used anywhere a binding is expected.
295+
296+
### Format Helper
297+
298+
The `format` helper creates a selector that formats values using CxJS format strings.
299+
This is useful for displaying formatted numbers, dates, or percentages in text props:
300+
301+
<CodeSplit>
302+
<CodeSnippet copy={false}>{`
303+
import { createAccessorModelProxy } from "cx/data";
304+
import { format } from "cx/ui";
305+
306+
interface Product {
307+
name: string;
308+
price: number;
309+
discount: number;
310+
}
311+
312+
const m = createAccessorModelProxy<Product>();
313+
314+
<cx>
315+
{/* Format as number with 2 decimal places */}
316+
<div text={format(m.price, "n;2")} />
317+
318+
{/* Format as percentage */}
319+
<div text={format(m.discount, "p;0")} />
320+
321+
{/* With custom null text */}
322+
<div text={format(m.price, "n;2", "N/A")} />
323+
</cx>
324+
`}</CodeSnippet>
325+
</CodeSplit>
326+
327+
The format string uses CxJS format syntax (e.g., `"n;2"` for numbers, `"p;0"` for percentages,
328+
`"d"` for dates). The optional third parameter specifies text to display for null/undefined values.
329+
330+
### Template Helper with Accessor Chains
331+
332+
The `tpl` function now supports accessor chains in addition to its original string-only form.
333+
This allows you to create formatted strings from multiple values with full type safety:
334+
335+
<CodeSplit>
336+
<CodeSnippet copy={false}>{`
337+
import { createAccessorModelProxy } from "cx/data";
338+
import { tpl } from "cx/ui";
339+
340+
interface Person {
341+
firstName: string;
342+
lastName: string;
343+
age: number;
344+
}
345+
346+
const m = createAccessorModelProxy<Person>();
347+
348+
<cx>
349+
{/* Original string-only form still works */}
350+
<div text={tpl("{firstName} {lastName}")} />
351+
352+
{/* New accessor chain form with positional placeholders */}
353+
<div text={tpl(m.firstName, m.lastName, "{0} {1}")} />
354+
355+
{/* Supports formatting in placeholders */}
356+
<div text={tpl(m.firstName, m.age, "{0} is {1:n;0} years old")} />
357+
358+
{/* Supports null text */}
359+
<div text={tpl(m.firstName, "Hello, {0|Guest}!")} />
360+
</cx>
361+
`}</CodeSnippet>
362+
</CodeSplit>
363+
364+
The accessor chain form uses positional placeholders (&#123;0&#125;, &#123;1&#125;, etc.) and supports
365+
all StringTemplate features including formatting (`:format`) and null text (`|nullText`).
294366

295367
### Typed Config Properties
296368

packages/cx/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "cx",
3-
"version": "26.1.3",
3+
"version": "26.1.5",
44
"description": "Advanced JavaScript UI framework for admin and dashboard applications with ready to use grid, form and chart components.",
55
"exports": {
66
"./data": {
@@ -97,4 +97,4 @@
9797
"tsconfig-paths": "^4.2.0",
9898
"typescript": "^5.9.3"
9999
}
100-
}
100+
}

packages/cx/src/ui/exprHelpers.spec.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
notEqual,
1515
strictEqual,
1616
strictNotEqual,
17+
format,
1718
} from "./exprHelpers";
1819
import assert from "assert";
1920
import { createAccessorModelProxy } from "../data/createAccessorModelProxy";
@@ -376,4 +377,36 @@ describe("exprHelpers", function () {
376377
assert.strictEqual(selector({ nullable: undefined }), true);
377378
});
378379
});
380+
381+
describe("format", function () {
382+
it("formats numbers with specified decimal places", function () {
383+
const selector = format(m.value, "n;2");
384+
assert.strictEqual(selector({ value: 1234.5 }), "1234.50");
385+
});
386+
387+
it("formats percentages", function () {
388+
const selector = format(m.value, "p;0");
389+
assert.strictEqual(selector({ value: 0.5 }), "50%");
390+
});
391+
392+
it("returns empty string for null values", function () {
393+
const selector = format(m.nullable, "n;2");
394+
assert.strictEqual(selector({ nullable: null }), "");
395+
});
396+
397+
it("returns empty string for undefined values", function () {
398+
const selector = format(m.nullable, "n;2");
399+
assert.strictEqual(selector({ nullable: undefined }), "");
400+
});
401+
402+
it("returns custom null text for null values", function () {
403+
const selector = format(m.nullable, "n;2", "N/A");
404+
assert.strictEqual(selector({ nullable: null }), "N/A");
405+
});
406+
407+
it("returns custom null text for undefined values", function () {
408+
const selector = format(m.nullable, "n;2", "-");
409+
assert.strictEqual(selector({ nullable: undefined }), "-");
410+
});
411+
});
379412
});

packages/cx/src/ui/exprHelpers.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import { computable } from "../data/computable";
12
import { AccessorChain } from "../data/createAccessorModelProxy";
23
import { Selector } from "../data/Selector";
4+
import { Format } from "../util/Format";
35
import { expr } from "./expr";
46

57
/** Returns a selector that converts the value to boolean using !! */
@@ -76,3 +78,19 @@ export function strictEqual<V>(arg: AccessorChain<V>, value: V): Selector<boolea
7678
export function strictNotEqual<V>(arg: AccessorChain<V>, value: V): Selector<boolean> {
7779
return expr(arg, (x) => x !== value);
7880
}
81+
82+
/** Returns a selector that formats the value using the specified format string.
83+
* Format strings use semicolon-separated syntax: "formatType;param1;param2"
84+
* @param arg - The accessor chain to the value
85+
* @param fmt - The format string
86+
* @param nullText - Optional text to display for null/undefined values
87+
* @example
88+
* format(m.price, "n;2") // formats as number with 2 decimal places
89+
* format(m.date, "d") // formats as date
90+
* format(m.value, "p;0;2") // formats as percentage with 0-2 decimal places
91+
* format(m.value, "n;2", "N/A") // shows "N/A" for null values
92+
*/
93+
export function format<V>(arg: AccessorChain<V>, fmt: string, nullText?: string): Selector<string> {
94+
let f = nullText != null ? `${fmt}|${nullText}` : fmt;
95+
return computable(arg, (x) => Format.value(x, f));
96+
}

packages/cx/src/ui/tpl.spec.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { tpl } from "./tpl";
2+
import assert from "assert";
3+
import { createAccessorModelProxy } from "../data/createAccessorModelProxy";
4+
5+
interface Model {
6+
firstName: string;
7+
lastName: string;
8+
age: number;
9+
city: string;
10+
}
11+
12+
describe("tpl", function () {
13+
const m = createAccessorModelProxy<Model>();
14+
15+
describe("string-only form", function () {
16+
it("returns a tpl object", function () {
17+
const result = tpl("{firstName} {lastName}");
18+
assert.deepStrictEqual(result, { tpl: "{firstName} {lastName}" });
19+
});
20+
});
21+
22+
describe("accessor chain form", function () {
23+
it("formats with a single accessor", function () {
24+
const selector = tpl(m.firstName, "Hello, {0}!");
25+
assert.strictEqual(selector({ firstName: "John" }), "Hello, John!");
26+
});
27+
28+
it("formats with two accessors", function () {
29+
const selector = tpl(m.firstName, m.lastName, "{0} {1}");
30+
assert.strictEqual(selector({ firstName: "John", lastName: "Doe" }), "John Doe");
31+
});
32+
33+
it("formats with three accessors", function () {
34+
const selector = tpl(m.firstName, m.lastName, m.age, "{0} {1} is {2} years old");
35+
assert.strictEqual(
36+
selector({ firstName: "John", lastName: "Doe", age: 30 }),
37+
"John Doe is 30 years old",
38+
);
39+
});
40+
41+
it("formats with four accessors", function () {
42+
const selector = tpl(m.firstName, m.lastName, m.age, m.city, "{0} {1}, {2}, from {3}");
43+
assert.strictEqual(
44+
selector({ firstName: "John", lastName: "Doe", age: 30, city: "NYC" }),
45+
"John Doe, 30, from NYC",
46+
);
47+
});
48+
49+
it("handles null values", function () {
50+
const selector = tpl(m.firstName, m.lastName, "{0} {1}");
51+
assert.strictEqual(selector({ firstName: "John", lastName: null }), "John ");
52+
});
53+
54+
it("handles undefined values", function () {
55+
const selector = tpl(m.firstName, m.lastName, "{0} {1}");
56+
assert.strictEqual(selector({ firstName: undefined, lastName: "Doe" }), " Doe");
57+
});
58+
59+
it("supports formatting in template", function () {
60+
const selector = tpl(m.age, "Age: {0:n;0}");
61+
assert.strictEqual(selector({ age: 30 }), "Age: 30");
62+
});
63+
64+
it("supports null text in template", function () {
65+
const selector = tpl(m.firstName, "Name: {0|N/A}");
66+
assert.strictEqual(selector({ firstName: null }), "Name: N/A");
67+
});
68+
});
69+
});

packages/cx/src/ui/tpl.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,18 @@
1-
export function tpl(text: string) {
2-
return {
3-
tpl: text
4-
}
1+
import { computable, ComputableSelector } from "../data/computable";
2+
import { MemoSelector } from "../data/Selector";
3+
import { StringTemplate } from "../data/StringTemplate";
4+
import { Tpl } from "./Prop";
5+
6+
export function tpl(text: string): Tpl;
7+
export function tpl<T extends ComputableSelector[]>(...args: [...T, string]): MemoSelector<string>;
8+
export function tpl(...args: any[]): any {
9+
if (args.length === 1)
10+
return {
11+
tpl: args[0],
12+
};
13+
14+
let template = args[args.length - 1];
15+
let formatter = StringTemplate.get(template);
16+
let selectors = args.slice(0, -1);
17+
return computable(...selectors, (...values: any[]) => formatter(values));
518
}

0 commit comments

Comments
 (0)