Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add strings support as a return value from state.useValue #16

Merged
merged 1 commit into from
May 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 12 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ attachComponent({ htmlElement: appContainer, component: createElement(App) });
Create Veles tree. Accepts strings for regular valid HTML elements (like `div`, `span`, etc) and functions which are expected to return another Veles tree from `createElement`.

> [!NOTE]
> JSX should be almost fully supported as long as you specify `Veles.createElement` pragma (no `Fragment` support at the moment)
> JSX should be almost fully supported as long as you specify `Veles.createElement` pragma

```js
import { createElement } from "veles";
Expand Down Expand Up @@ -65,9 +65,11 @@ function Counter() {
return createElement("div", {
children: [
createElement("h1", { children: "Counter" }),
counterState.useValue((counterValue) =>
createElement("div", { children: `counter value is: ${counterValue}` })
),
createElement("div", {
children: counterState.useValue(
(counterValue) => `counter value is: ${counterValue}`
),
}),
createElement("button", {
onClick: () => {
counterState.setValue(
Expand Down Expand Up @@ -97,10 +99,12 @@ function App() {
return createElement("div", {
children: [
createElement("h1", { children: "App" }),
taskState.useSelectorValue(
(task) => task.title,
(title) => createElement("div", { children: `task title: ${title}` })
),
createElement("div", {
children: taskState.useSelectorValue(
(task) => task.title,
(title) => `task title: ${title}`
),
}),
],
});
}
Expand Down
72 changes: 72 additions & 0 deletions integration-tests/create-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -491,4 +491,76 @@ describe("createState", () => {
expect(btn).toHaveAttribute("data-testvalue", "2");
expect(spyFn).not.toHaveBeenCalled();
});

test("supports strings as returned value in useValue", async () => {
const user = userEvent.setup();
function StateComponent() {
const valueState = createState(0);
return createElement("div", {
children: [
createElement("button", {
"data-testid": "button",
onClick: () => {
valueState.setValue((currentValue) => currentValue + 1);
},
}),
createElement("div", {
children: valueState.useValue(
(value) => `current value is ${value}`
),
}),
],
});
}

cleanup = attachComponent({
htmlElement: document.body,
component: createElement(StateComponent),
});

expect(await screen.findByText("current value is 0")).toBeVisible();
const btn = screen.getByTestId("button");

await user.click(btn);
expect(await screen.findByText("current value is 1")).toBeVisible();

await user.click(btn);
expect(await screen.findByText("current value is 2")).toBeVisible();
});

test("correctly supports null as a return value in useValue", async () => {
const user = userEvent.setup();
function StateComponent() {
const valueState = createState(0);
return createElement("div", {
children: [
createElement("button", {
"data-testid": "button",
onClick: () => {
valueState.setValue((currentValue) => currentValue + 1);
},
}),
createElement("div", {
children: valueState.useValue((value) =>
value === 0 ? null : `current value is ${value}`
),
}),
],
});
}

cleanup = attachComponent({
htmlElement: document.body,
component: createElement(StateComponent),
});

expect(screen.queryByText("current value is 0")).not.toBeInTheDocument();
const btn = screen.getByTestId("button");

await user.click(btn);
expect(await screen.findByText("current value is 1")).toBeVisible();

await user.click(btn);
expect(await screen.findByText("current value is 2")).toBeVisible();
});
});
27 changes: 27 additions & 0 deletions src/create-element/create-text-element.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/**
* This is an internal helper function to create Text Nodes
* which we need to maintain in the component tree
*/

import type { VelesStringElement } from "../types";

export function createTextElement(
text: string | undefined | null
): VelesStringElement {
const unmountHandlers: Function[] = [];
return {
velesStringElement: true,
// in case there is no text, we create an empty Text node, so we still can
// have a reference to it, replace it, call lifecycle methods, etc
html: document.createTextNode(text || ""),

_privateMethods: {
_addUnmountHandler: (cb: Function) => {
unmountHandlers.push(cb);
},
_callUnmountHandlers: () => {
unmountHandlers.forEach((cb) => cb());
},
},
};
}
23 changes: 21 additions & 2 deletions src/create-element/parse-children.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { getComponentVelesNode } from "../utils";

import type { VelesComponent, VelesElement, VelesElementProps } from "../types";
import type {
VelesComponent,
VelesElement,
VelesStringElement,
VelesElementProps,
} from "../types";

function parseChildren({
children,
Expand All @@ -11,7 +16,11 @@ function parseChildren({
htmlElement: HTMLElement;
velesNode: VelesElement;
}) {
const childComponents: (VelesElement | VelesComponent)[] = [];
const childComponents: (
| VelesElement
| VelesComponent
| VelesStringElement
)[] = [];

if (children === undefined || children === null) {
return childComponents;
Expand Down Expand Up @@ -117,6 +126,16 @@ function parseChildren({
velesElementNode.parentVelesElement = velesNode;
childComponents.push(childComponent);
}
} else if (
typeof childComponent === "object" &&
childComponent &&
"velesStringElement" in childComponent &&
childComponent?.velesStringElement
) {
// TODO: check that it is a valid DOM Node
htmlElement.append(childComponent.html);
childComponent.parentVelesElement = velesNode;
childComponents.push(childComponent);
}
}
);
Expand Down
46 changes: 35 additions & 11 deletions src/hooks/create-state.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import { getComponentVelesNode, identity } from "../utils";
import { onUnmount, onMount } from "./lifecycle";
import { createElement } from "../create-element/create-element";
import { createTextElement } from "../create-element/create-text-element";

import type { VelesElement, VelesComponent } from "../types";
import type {
VelesElement,
VelesComponent,
VelesStringElement,
} from "../types";

type AttributeHelper = {
(htmlElement: HTMLElement, attributeName: string, node: VelesElement): string;
Expand All @@ -15,17 +20,21 @@ export type State<ValueType> = {
options?: { callOnMount?: boolean; skipFirstCall?: boolean }
): void;
useValue(
cb: (value: ValueType) => VelesElement | VelesComponent,
cb: (
value: ValueType
) => VelesElement | VelesComponent | string | undefined | null,
comparator?: (value1: ValueType, value2: ValueType) => boolean
): VelesElement | VelesComponent;
): VelesElement | VelesComponent | VelesStringElement;
useValueSelector<SelectorValueType>(
selector: (value: ValueType) => SelectorValueType,
cb: (value: SelectorValueType) => VelesElement | VelesComponent,
cb: (
value: SelectorValueType
) => VelesElement | VelesComponent | string | undefined | null,
comparator?: (
value1: SelectorValueType,
value2: SelectorValueType
) => boolean
): VelesElement | VelesComponent;
): VelesElement | VelesComponent | VelesStringElement;
useAttribute(cb: (value: ValueType) => string): AttributeHelper;
useValueIterator<Element>(
cb: (props: {
Expand Down Expand Up @@ -71,12 +80,15 @@ function createState<T>(
let value = initialValue;
let previousValue: undefined | T = undefined;
let trackingEffects: { (value: T): void }[] = [];

let trackingSelectorElements: {
cb: (value: any) => VelesElement | VelesComponent;
node: VelesElement | VelesComponent;
cb: (
value: any
) => VelesElement | VelesComponent | string | undefined | null;
selector?: Function;
selectedValue: any;
comparator: (value1: any, value2: any) => boolean;
node: VelesElement | VelesComponent | VelesStringElement;
}[] = [];

let trackingAttributes: {
Expand Down Expand Up @@ -115,19 +127,27 @@ function createState<T>(
},
useValueSelector<F>(
selector: ((value: T) => F) | undefined,
cb: (value: F) => VelesElement | VelesComponent,
cb: (
value: F
) => VelesElement | VelesComponent | string | undefined | null,
comparator: (value1: F, value2: F) => boolean = identity
): VelesElement | VelesComponent {
): VelesElement | VelesComponent | VelesStringElement {
// @ts-expect-error
const selectedValue = selector ? selector(value) : (value as F);
const node = cb(selectedValue);
const returnedNode = cb(selectedValue);
const node =
!returnedNode || typeof returnedNode === "string"
? createTextElement(returnedNode as string)
: returnedNode;

trackingSelectorElements.push({
selector,
selectedValue,
cb,
node,
comparator,
});

node._privateMethods._addUnmountHandler(() => {
trackingSelectorElements = trackingSelectorElements.filter(
(trackingSelectorElement) => trackingSelectorElement.cb !== cb
Expand Down Expand Up @@ -281,7 +301,11 @@ function createState<T>(
return selectorTrackingElement;
}

const newNode = cb(newSelectedValue);
const returnednewNode = cb(newSelectedValue);
const newNode =
!returnednewNode || typeof returnednewNode === "string"
? createTextElement(returnednewNode as string)
: returnednewNode;

const { velesElementNode: oldVelesElementNode } =
getComponentVelesNode(node);
Expand Down
13 changes: 11 additions & 2 deletions src/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export type VelesElement = {

// every element except the most top one should have one
parentVelesElement?: VelesElement;
childComponents: (VelesElement | VelesComponent)[];
childComponents: (VelesElement | VelesComponent | VelesStringElement)[];

// not intended to be used directly
_privateMethods: {
Expand All @@ -25,6 +25,15 @@ export type VelesStringElement = {
velesStringElement: true;
html: Text;
parentVelesElement?: VelesElement;

// not intended to be used directly
// despite being a text component, having same lifecycle
// methods is useful for state changes, to remove tracking
// when the said Text is returned from `useValue` state method
_privateMethods: {
_addUnmountHandler: Function;
_callUnmountHandlers: Function;
};
};

// an internal representation of components in the tree
Expand All @@ -42,7 +51,7 @@ export type VelesComponent = {
};

// all supported child options
type velesChild = string | VelesElement | VelesComponent;
type velesChild = string | VelesElement | VelesComponent | VelesStringElement;
export type VelesChildren = velesChild | velesChild[] | undefined | null;

export type VelesElementProps = {
Expand Down
12 changes: 11 additions & 1 deletion src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,20 @@
import type { VelesComponent, VelesElement, VelesStringElement } from "./types";

function getComponentVelesNode(component: VelesComponent | VelesElement): {
function getComponentVelesNode(
component: VelesComponent | VelesElement | VelesStringElement
): {
velesElementNode: VelesElement | VelesStringElement;
componentsTree: VelesComponent[];
} {
const componentsTree: VelesComponent[] = [];

if ("velesStringElement" in component) {
return {
velesElementNode: component,
componentsTree: [],
};
}

let childNode: VelesComponent | VelesElement = component;
// we can have multiple components nested, we need to get
// to the actual HTML to attach it
Expand Down