Skip to content

Commit 6d3bead

Browse files
authored
Merge pull request #39 from bcdev/forman-38-replace_components
Allow replacing components
2 parents 0fe83e9 + 6bff7d0 commit 6d3bead

File tree

7 files changed

+135
-66
lines changed

7 files changed

+135
-66
lines changed

chartlets.js/CHANGES.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
## Version 0.0.23 (in development)
2+
3+
* Replacing entire components if a related component `StateChange`
4+
has an empty `property`. (#38)
5+
16
## Version 0.0.22 (from 2024/11/19)
27

38
* Improved robustness while rendering the in `Select` component

chartlets.js/src/lib/actions/helpers/applyStateChangeRequests.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { describe, it, expect } from "vitest";
33
import { type ContribPoint } from "@/lib/types/model/extension";
44
import { type StateChangeRequest } from "@/lib/types/model/callback";
55
import {
6+
type BoxState,
67
type ComponentState,
78
type PlotState,
89
} from "@/lib/types/state/component";
@@ -112,4 +113,24 @@ describe("Test that applyComponentStateChange()", () => {
112113
});
113114
expect(newState).toBe(componentTree);
114115
});
116+
117+
it("replaces state if property is empty string", () => {
118+
const value: BoxState = {
119+
type: "Box",
120+
id: "b1",
121+
children: ["Hello", "World"],
122+
};
123+
const newState = applyComponentStateChange(componentTree, {
124+
link: "component",
125+
id: "b1",
126+
property: "",
127+
value,
128+
});
129+
expect(newState).toBe(value);
130+
expect(newState).toEqual({
131+
type: "Box",
132+
id: "b1",
133+
children: ["Hello", "World"],
134+
});
135+
});
115136
});

chartlets.js/src/lib/actions/helpers/applyStateChangeRequests.ts

Lines changed: 41 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -30,39 +30,6 @@ export function applyStateChangeRequests(
3030
applyHostStateChanges(stateChangeRequests);
3131
}
3232

33-
function applyHostStateChanges(stateChangeRequests: StateChangeRequest[]) {
34-
const { configuration } = store.getState();
35-
const { hostStore } = configuration;
36-
if (hostStore) {
37-
const hostStateOld = hostStore.getState();
38-
let hostState: object | undefined = hostStateOld;
39-
stateChangeRequests.forEach((stateChangeRequest) => {
40-
hostState = applyStateChanges(
41-
hostState,
42-
stateChangeRequest.stateChanges.filter(
43-
(stateChange) => stateChange.link === "app",
44-
),
45-
);
46-
});
47-
if (hostState !== hostStateOld) {
48-
hostStore.setState(hostState);
49-
}
50-
}
51-
}
52-
53-
function applyComponentStateChanges(
54-
componentOld: ComponentState | undefined,
55-
stateChanges: StateChange[],
56-
) {
57-
let component = componentOld;
58-
if (component) {
59-
stateChanges.forEach((stateChange) => {
60-
component = applyComponentStateChange(component!, stateChange);
61-
});
62-
}
63-
return component;
64-
}
65-
6633
// we export for testing only
6734
export function applyContributionChangeRequests(
6835
contributionsRecord: Record<ContribPoint, ContributionState[]>,
@@ -101,22 +68,40 @@ export function applyContributionChangeRequests(
10168
return contributionsRecord;
10269
}
10370

71+
function applyComponentStateChanges(
72+
componentOld: ComponentState | undefined,
73+
stateChanges: StateChange[],
74+
) {
75+
let component = componentOld;
76+
if (component) {
77+
stateChanges.forEach((stateChange) => {
78+
component = applyComponentStateChange(component!, stateChange);
79+
});
80+
}
81+
return component;
82+
}
83+
10484
// we export for testing only
10585
export function applyComponentStateChange(
10686
component: ComponentState,
10787
stateChange: StateChange,
10888
): ComponentState {
109-
if (component.id === stateChange.id) {
89+
if (isComponentState(component) && component.id === stateChange.id) {
11090
const property = normalizeObjPath(stateChange.property);
111-
const valueOld = getValue(component, property);
11291
const valueNew = stateChange.value;
92+
if (property.length === 0) {
93+
// Special case: If no property given, replace entire component.
94+
// But only if it is one, otherwise don't change state.
95+
return isComponentState(valueNew) ? valueNew : component;
96+
}
97+
const valueOld = getValue(component, property);
11398
if (
11499
property[property.length - 1] === "children" &&
115100
!Array.isArray(valueNew) &&
116101
valueNew !== null &&
117102
valueNew !== undefined
118103
) {
119-
// Special case if the value of "children" is changed:
104+
// Special case: if the value of "children" is changed:
120105
// convert scalar valueNew into one-element array
121106
return setValue(component, property, [valueNew]);
122107
} else if (valueOld !== valueNew) {
@@ -145,6 +130,26 @@ export function applyComponentStateChange(
145130
return component;
146131
}
147132

133+
function applyHostStateChanges(stateChangeRequests: StateChangeRequest[]) {
134+
const { configuration } = store.getState();
135+
const { hostStore } = configuration;
136+
if (hostStore) {
137+
const hostStateOld = hostStore.getState();
138+
let hostState: object | undefined = hostStateOld;
139+
stateChangeRequests.forEach((stateChangeRequest) => {
140+
hostState = applyStateChanges(
141+
hostState,
142+
stateChangeRequest.stateChanges.filter(
143+
(stateChange) => stateChange.link === "app",
144+
),
145+
);
146+
});
147+
if (hostState !== hostStateOld) {
148+
hostStore.setState(hostState);
149+
}
150+
}
151+
}
152+
148153
// we export for testing only
149154
export function applyStateChanges<S extends object | undefined>(
150155
state: S,

chartlets.py/CHANGES.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
## Version 0.0.22 (in development)
22

3+
* Replacing components is now possible by using an
4+
`Output` with `property` set to an empty string. (#38)
5+
36
* `Component` children can now also be text nodes (of type `string`).
47

58
* `Typography` component has children instead of `text`.

chartlets.py/chartlets/channel.py

Lines changed: 39 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,21 @@
11
from abc import ABC
22
from typing import Any, Literal
33

4-
from .util.assertions import assert_is_instance_of
5-
from .util.assertions import assert_is_none
6-
from .util.assertions import assert_is_one_of
4+
from .util.assertions import (
5+
assert_is_given,
6+
assert_is_instance_of,
7+
assert_is_none,
8+
assert_is_one_of,
9+
)
710

811

912
Link = Literal["component"] | Literal["container"] | Literal["app"]
1013

14+
COMPONENT = ""
15+
"""Special property value that can be used
16+
to refer to the entire component.
17+
"""
18+
1119

1220
class Channel(ABC):
1321
"""Base class for `Input`, `State`, and `Output`.
@@ -53,7 +61,8 @@ class Input(Channel):
5361
Args:
5462
id:
5563
Value of a component's "id" property.
56-
Used only if `source` is `"component"`.
64+
Required, if `source` is `"component"` (the default).
65+
Otherwise, it must not be passed.
5766
property:
5867
Name of the property of a component or state.
5968
To address properties in nested objects or arrays
@@ -82,7 +91,7 @@ class State(Input):
8291
8392
Just like an `Input`, a `State` describes from which property in which state
8493
a parameter value is read, but according state changes
85-
will **not*Ü* trigger callback invocation.
94+
will **not* trigger callback invocation.
8695
8796
Args:
8897
id:
@@ -125,6 +134,8 @@ class Output(Channel):
125134
To address properties in nested objects or arrays
126135
use a dot (`.`) to separate property names and array
127136
indexes.
137+
If `target` is `"component"` the empty string can be used
138+
to refer to entire components.
128139
target: One of `"component"` (the default), `"container"`,
129140
or `"app"`.
130141
"""
@@ -154,22 +165,37 @@ def _validate_input_params(
154165
def _validate_output_params(
155166
target: Link | None, id: str | None, property: str | None
156167
) -> tuple[Link, str | None, str | None]:
157-
return _validate_params("target", target, id, property)
168+
return _validate_params("target", target, id, property, output=True)
158169

159170

160171
# noinspection PyShadowingBuiltins
161172
def _validate_params(
162-
link_name: str, link: Link | None, id: str | None, property: str | None
173+
link_name: str,
174+
link: Link | None,
175+
id: str | None,
176+
property: str | None,
177+
output: bool = False,
163178
) -> tuple[Link, str | None, str | None]:
164-
assert_is_one_of(link_name, link, ("component", "container", "app", None))
165-
if not link or link == "component":
166-
assert_is_instance_of("id", id, (str, NoneType))
179+
if link is None or link == "component":
180+
# Component states require an id
181+
# and property which defaults to "value"
182+
link = "component"
183+
assert_is_given("id", id)
184+
assert_is_instance_of("id", id, str)
167185
assert_is_instance_of("property", id, (str, NoneType))
168-
link = link or "component"
169-
if property is None and id is not None:
186+
if property is None:
187+
# property, if not provided, defaults to "value"
170188
property = "value"
189+
elif not output:
190+
# outputs are allowed to have an empty property value
191+
assert_is_given("property", property)
171192
else:
172-
assert_is_none("id", id)
193+
# Other states require a link and property
194+
# and should have no id
195+
assert_is_given(link_name, link)
196+
assert_is_one_of(link_name, link, ("container", "app"))
197+
assert_is_given("property", property)
173198
assert_is_instance_of("property", property, str)
199+
assert_is_none("id", id)
174200
# noinspection PyTypeChecker
175201
return link, id, property

chartlets.py/chartlets/util/assertions.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,8 @@ def assert_is_instance_of(name: str, value: Any, type_set: Type | tuple[Type, ..
1818
def assert_is_none(name: str, value: Any):
1919
if value is not None:
2020
raise TypeError(f"value of {name!r} must be None, but was {value!r}")
21+
22+
23+
def assert_is_given(name: str, value: Any):
24+
if not value:
25+
raise ValueError(f"value for {name!r} must be given")

chartlets.py/tests/channel_test.py

Lines changed: 21 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -11,54 +11,48 @@ class ChannelTest(unittest.TestCase):
1111
channel_cls: Type[Channel]
1212
link_name: str
1313

14-
def test_no_args(self):
15-
obj = self.channel_cls()
16-
self.assertEqual("component", obj.link)
17-
self.assertEqual(None, obj.id)
18-
self.assertEqual(None, obj.property)
14+
def test_no_args_given(self):
15+
with pytest.raises(ValueError, match="value for 'id' must be given"):
16+
obj = self.channel_cls()
1917

2018
def test_id_given(self):
2119
obj = self.channel_cls("dataset_select")
2220
self.assertEqual("component", obj.link)
2321
self.assertEqual("dataset_select", obj.id)
2422
self.assertEqual("value", obj.property)
2523

26-
def test_app(self):
24+
def test_app_ok(self):
2725
obj = self.channel_cls(property="datasetId", **{self.link_name: "app"})
2826
self.assertEqual("app", obj.link)
2927
self.assertEqual(None, obj.id)
3028
self.assertEqual("datasetId", obj.property)
3129

3230
def test_container_with_id(self):
3331
with pytest.raises(
34-
TypeError,
35-
match="value of 'id' must be None, but was 'test_id'",
32+
ValueError,
33+
match="value for 'property' must be given",
3634
):
3735
self.channel_cls("test_id", **{self.link_name: "container"})
3836

3937
def test_app_no_prop(self):
4038
with pytest.raises(
41-
TypeError,
42-
match=(
43-
"value of 'property' must be an instance"
44-
" of <class 'str'>, but was None"
45-
),
39+
ValueError,
40+
match="value for 'property' must be given",
4641
):
4742
self.channel_cls(**{self.link_name: "app"})
4843

4944
def test_wrong_link(self):
5045
with pytest.raises(
5146
ValueError,
5247
match=(
53-
f"value of '{self.link_name}' must be one of"
54-
f" \\('component', 'container', 'app', None\\),"
55-
f" but was 'host'"
48+
f"value of {self.link_name!r} must be one of"
49+
r" \('container', 'app'\), but was 'host'"
5650
),
5751
):
5852
self.channel_cls(**{self.link_name: "host"})
5953

6054
def test_no_trigger(self):
61-
obj = self.channel_cls()
55+
obj = self.channel_cls("some_id")
6256
if isinstance(obj, State):
6357
self.assertTrue(obj.no_trigger)
6458
else:
@@ -93,6 +87,10 @@ class InputTest(make_base(), unittest.TestCase):
9387
channel_cls = Input
9488
link_name = "source"
9589

90+
def test_disallow_empty_property(self):
91+
with pytest.raises(ValueError, match="value for 'property' must be given"):
92+
self.channel_cls("some_id", "")
93+
9694

9795
class StateTest(make_base(), unittest.TestCase):
9896
channel_cls = State
@@ -102,3 +100,9 @@ class StateTest(make_base(), unittest.TestCase):
102100
class OutputTest(make_base(), unittest.TestCase):
103101
channel_cls = Output
104102
link_name = "target"
103+
104+
def test_allow_empty_property(self):
105+
obj = self.channel_cls("some_id", "")
106+
self.assertEqual("component", obj.link)
107+
self.assertEqual("some_id", obj.id)
108+
self.assertEqual("", obj.property)

0 commit comments

Comments
 (0)