Skip to content

Commit 19c1c1a

Browse files
committed
Type improvements
1 parent 95b2f59 commit 19c1c1a

File tree

4 files changed

+211
-27
lines changed

4 files changed

+211
-27
lines changed

packages/cx/src/ui/ContentResolver.spec.tsx

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { createTestRenderer } from "../util/test/createTestRenderer";
44
import assert from "assert";
55
import { bind } from "./bind";
66
import { createAccessorModelProxy } from "../data/createAccessorModelProxy";
7+
import { Prop } from "./Prop";
78

89
interface TestModel {
910
user: {
@@ -427,4 +428,133 @@ describe("ContentResolver", () => {
427428
],
428429
});
429430
});
431+
432+
it("supports simple Prop params (non-structured)", () => {
433+
const model = createAccessorModelProxy<TestModel>();
434+
435+
// Test using a single AccessorChain as params instead of an object
436+
let widget = (
437+
<cx>
438+
<div>
439+
<ContentResolver
440+
params={model.user.name}
441+
onResolve={(name) => {
442+
// name should be typed as string (from AccessorChain<string>)
443+
const typedName: string = name;
444+
return <span text={`Hello, ${typedName}!`} />;
445+
}}
446+
/>
447+
</div>
448+
</cx>
449+
);
450+
451+
let store = new Store({
452+
data: {
453+
user: {
454+
name: "Alice",
455+
age: 25,
456+
active: true,
457+
},
458+
},
459+
});
460+
461+
const component = createTestRenderer(store, widget);
462+
463+
let tree = component.toJSON();
464+
assert.deepEqual(tree, {
465+
type: "div",
466+
props: {},
467+
children: [
468+
{
469+
type: "span",
470+
props: {},
471+
children: ["Hello, Alice!"],
472+
},
473+
],
474+
});
475+
});
476+
477+
it("supports simple bind() as params", () => {
478+
let widget = (
479+
<cx>
480+
<div>
481+
<ContentResolver
482+
params={bind("count")}
483+
onResolve={(count) => {
484+
return <span text={`Count: ${count}`} />;
485+
}}
486+
/>
487+
</div>
488+
</cx>
489+
);
490+
491+
let store = new Store({
492+
data: {
493+
count: 42,
494+
},
495+
});
496+
497+
const component = createTestRenderer(store, widget);
498+
499+
let tree = component.toJSON();
500+
assert.deepEqual(tree, {
501+
type: "div",
502+
props: {},
503+
children: [
504+
{
505+
type: "span",
506+
props: {},
507+
children: ["Count: 42"],
508+
},
509+
],
510+
});
511+
});
512+
513+
it("resolves Prop<string> to string (not any)", () => {
514+
const model = createAccessorModelProxy<TestModel>();
515+
516+
// When params is typed as Prop<string>, onResolve should receive string (not any)
517+
// This tests that the union type Prop<T> correctly extracts T
518+
const typedParam: Prop<string> = model.user.name;
519+
520+
let widget = (
521+
<cx>
522+
<div>
523+
<ContentResolver
524+
params={typedParam}
525+
onResolve={(name) => {
526+
// name should be typed as string, not any
527+
const typedName: string = name;
528+
return <span text={`Hello, ${typedName}!`} />;
529+
}}
530+
/>
531+
</div>
532+
</cx>
533+
);
534+
535+
let store = new Store({
536+
data: {
537+
user: {
538+
name: "Bob",
539+
age: 35,
540+
active: false,
541+
},
542+
},
543+
});
544+
545+
const component = createTestRenderer(store, widget);
546+
547+
let tree = component.toJSON();
548+
assert.deepEqual(tree, {
549+
type: "div",
550+
props: {},
551+
children: [
552+
{
553+
type: "span",
554+
props: {},
555+
children: ["Hello, Bob!"],
556+
},
557+
],
558+
});
559+
});
430560
});

packages/cx/src/ui/ContentResolver.ts

Lines changed: 57 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,62 @@
11
import { PureContainerBase, PureContainerConfig } from "./PureContainer";
22
import { isPromise } from "../util/isPromise";
33
import { RenderingContext } from "./RenderingContext";
4-
import { BooleanProp, StructuredProp, ResolveStructuredProp } from "./Prop";
4+
import { BooleanProp, StructuredProp, ResolveStructuredProp, Bind, Tpl, Expr, GetSet } from "./Prop";
5+
import { Selector } from "../data/Selector";
6+
import { AccessorChain } from "../data/createAccessorModelProxy";
57
import { Instance } from "./Instance";
68

9+
/**
10+
* Helper type that extracts the value type from typed props (Selector, AccessorChain, GetSet).
11+
* Returns never for bindings (Bind, Tpl, Expr) since they don't carry type information.
12+
* This is used to properly resolve Prop<T> unions where we want to extract T.
13+
*/
14+
type ExtractTypedPropValue<P> = P extends Selector<infer T>
15+
? T
16+
: P extends AccessorChain<infer T>
17+
? T
18+
: P extends GetSet<infer T>
19+
? T
20+
: P extends Bind
21+
? never
22+
: P extends Tpl
23+
? never
24+
: P extends Expr
25+
? never
26+
: P extends Record<string, any>
27+
? never
28+
: P;
29+
30+
/**
31+
* Utility type that resolves params which can be either a simple Prop<T> or a StructuredProp.
32+
* - For Prop<T> unions (including Selector, AccessorChain, GetSet, bindings), extracts T
33+
* - For plain bindings (Bind, Expr) used directly, resolves to any
34+
* - For structured props (objects), applies ResolveStructuredProp to resolve each property
35+
*/
36+
type ResolveParams<P> = ExtractTypedPropValue<P> extends never
37+
? P extends Bind
38+
? any
39+
: P extends Tpl
40+
? string
41+
: P extends Expr
42+
? any
43+
: P extends Record<string, any>
44+
? ResolveStructuredProp<P>
45+
: P
46+
: ExtractTypedPropValue<P>;
47+
748
/**
849
* Configuration for ContentResolver widget.
950
*
1051
* The params type parameter enables type inference for the onResolve callback:
1152
* - Literal values (numbers, strings, booleans) preserve their types
1253
* - AccessorChain<T> resolves to T
1354
* - Bind/Tpl/Expr resolve to any (type cannot be determined at compile time)
55+
* - Structured props (objects) have each property resolved individually
1456
*
1557
* @example
1658
* ```typescript
59+
* // Structured params (object)
1760
* <ContentResolver
1861
* params={{
1962
* count: 42, // number
@@ -23,18 +66,26 @@ import { Instance } from "./Instance";
2366
* // params.count is number, params.name is string
2467
* }}
2568
* />
69+
*
70+
* // Simple param (single value)
71+
* <ContentResolver
72+
* params={model.user.name} // AccessorChain<string>
73+
* onResolve={(name) => {
74+
* // name is string
75+
* }}
76+
* />
2677
* ```
2778
*/
28-
export interface ContentResolverConfig<P extends StructuredProp = StructuredProp> extends PureContainerConfig {
29-
/** Parameters that trigger content resolution when changed. */
79+
export interface ContentResolverConfig<P = StructuredProp> extends PureContainerConfig {
80+
/** Parameters that trigger content resolution when changed. Can be a structured object or a single Prop. */
3081
params?: P;
3182

3283
/**
3384
* Callback function that resolves content based on params. Can return content directly or a Promise.
3485
* The params type is inferred from the params property - literal values and AccessorChain<T>
3586
* preserve their types, while bindings (bind/tpl/expr) resolve to `any`.
3687
*/
37-
onResolve?: string | ((params: ResolveStructuredProp<P>, instance: Instance) => any);
88+
onResolve?: string | ((params: ResolveParams<P>, instance: Instance) => any);
3889

3990
/** How to combine resolved content with initial content. Default is 'replace'. */
4091
mode?: "replace" | "prepend" | "append";
@@ -43,15 +94,13 @@ export interface ContentResolverConfig<P extends StructuredProp = StructuredProp
4394
loading?: BooleanProp;
4495
}
4596

46-
export class ContentResolver<P extends StructuredProp = StructuredProp> extends PureContainerBase<
47-
ContentResolverConfig<P>
48-
> {
97+
export class ContentResolver<P = StructuredProp> extends PureContainerBase<ContentResolverConfig<P>> {
4998
constructor(config?: ContentResolverConfig<P>) {
5099
super(config);
51100
}
52101

53102
declare mode: "replace" | "prepend" | "append";
54-
declare onResolve?: string | ((params: ResolveStructuredProp<P>, instance: Instance) => any);
103+
declare onResolve?: string | ((params: ResolveParams<P>, instance: Instance) => any);
55104
declare initialItems: any;
56105

57106
declareData(...args: any[]): void {

packages/cx/src/ui/Text.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,30 @@
1-
import { Widget } from './Widget';
1+
import { Widget, WidgetConfig } from './Widget';
2+
import { StringProp } from './Prop';
23

3-
export class Text extends Widget {
4+
export interface TextConfig extends WidgetConfig {
5+
/** The value to be rendered as text. */
6+
value?: StringProp;
7+
8+
/** Template string for the text value. */
9+
tpl?: string;
10+
11+
/** Expression for the text value. */
12+
expr?: string;
13+
14+
/** Binding path for the text value. */
15+
bind?: string;
16+
}
17+
18+
export class Text extends Widget<TextConfig> {
419
declare value?: any;
520
declare tpl?: string;
621
declare expr?: any;
722
declare bind?: string;
823

24+
constructor(config?: TextConfig) {
25+
super(config);
26+
}
27+
928
init() {
1029
if (!this.value && (this.tpl || this.expr || this.bind))
1130
this.value = {

packages/cx/src/widgets/Icon.ts

Lines changed: 3 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Widget, VDOM, WidgetConfig } from "../ui/Widget";
1+
import { Widget, VDOM, WidgetConfig, WidgetStyleConfig } from "../ui/Widget";
22
import {
33
registerIcon,
44
registerIconFactory,
@@ -10,25 +10,11 @@ import {
1010
import "./icons/index";
1111
import { RenderingContext } from "../ui/RenderingContext";
1212
import { Instance } from "../ui/Instance";
13-
import { StringProp, ClassProp, StyleProp } from "../ui/Prop";
13+
import { StringProp } from "../ui/Prop";
1414

15-
export interface IconConfig extends WidgetConfig {
15+
export interface IconConfig extends WidgetConfig, WidgetStyleConfig {
1616
/** Name under which the icon is registered. */
1717
name?: StringProp;
18-
19-
/** Additional CSS classes to be applied to the field.
20-
* If an object is provided, all keys with a "truthy" value will be added to the CSS class list. */
21-
className?: ClassProp;
22-
23-
/** Additional CSS classes to be applied to the field.
24-
* If an object is provided, all keys with a "truthy" value will be added to the CSS class list. */
25-
class?: ClassProp;
26-
27-
/** Style object applied to the wrapper div. Used for setting the dimensions of the field. */
28-
style?: StyleProp;
29-
30-
/** Base CSS class to be applied to the element. Default is `icon`. */
31-
baseClass?: string;
3218
}
3319

3420
export class Icon extends Widget<IconConfig> {

0 commit comments

Comments
 (0)