diff --git a/packages/babel-plugin-wallace/src/consolidation/ComponentDefinitionData.ts b/packages/babel-plugin-wallace/src/consolidation/ComponentDefinitionData.ts index d19a22e..6e0a8d3 100644 --- a/packages/babel-plugin-wallace/src/consolidation/ComponentDefinitionData.ts +++ b/packages/babel-plugin-wallace/src/consolidation/ComponentDefinitionData.ts @@ -33,7 +33,7 @@ export class ComponentDefinitionData { html: Expression; watches: Array = []; dynamicElements: Expression[] = []; - baseComponent: Expression | undefined; + baseComponent?: Expression; lookups: Map = new Map(); refs: string[] = []; parts: Array = []; diff --git a/packages/babel-plugin-wallace/src/constants.ts b/packages/babel-plugin-wallace/src/constants.ts index c81fa53..48580eb 100644 --- a/packages/babel-plugin-wallace/src/constants.ts +++ b/packages/babel-plugin-wallace/src/constants.ts @@ -13,7 +13,8 @@ export enum IMPORTABLES { onEvent = "onEvent", SequentialRepeater = "SequentialRepeater", KeyedRepeater = "KeyedRepeater", - toDateString = "toDateString" + toDateString = "toDateString", + watch = "watch" } /** diff --git a/packages/babel-plugin-wallace/src/contexts/parameters.ts b/packages/babel-plugin-wallace/src/contexts/parameters.ts index b28d4f3..1a83920 100644 --- a/packages/babel-plugin-wallace/src/contexts/parameters.ts +++ b/packages/babel-plugin-wallace/src/contexts/parameters.ts @@ -110,6 +110,7 @@ function extractFinalPropsName(path: NodePath): PropsMap { return propVariableMap; } +// TODO: this really needs to be done on a per-directive basis as it can vary. function mapAndRenameXargs(path: NodePath, component: Component) { const renameMapping: { [key: string]: string } = {}; renameMapping[XARGS.event] = XARGS.event; diff --git a/packages/babel-plugin-wallace/src/directives.ts b/packages/babel-plugin-wallace/src/directives.ts index 68c4b12..8a0980d 100644 --- a/packages/babel-plugin-wallace/src/directives.ts +++ b/packages/babel-plugin-wallace/src/directives.ts @@ -34,6 +34,16 @@ class ApplyDirective extends Directive { } } +class AssignDirective extends Directive { + static attributeName = "assign"; + static valueMode: ValueMode = ValueMode.EitherRequired; + static qualifierMode: QualifierMode = QualifierMode.SetsValue; + static mustBeOnRoot = true; + apply(node: TagNode, value: NodeValue, qualifier: Qualifier, _base: string) { + node.component.assignTo = value.expression || value.value; + } +} + class BindDirective extends Directive { static attributeName = "bind"; static valueMode: ValueMode = ValueMode.ExpressionRequired; @@ -87,25 +97,6 @@ class CtrlDirective extends Directive { } } -/** - * This is a hack to enable a Proxy of a Date to be passed to `valueAsDate`. - * It is not a documented directive. - */ -class ValueAsDateDirective extends Directive { - static attributeName = "valueAsDate"; - apply(node: TagNode, value: NodeValue, _qualifier: Qualifier, _base: string) { - if (value.type === "expression") { - node.requiredImport(IMPORTABLES.toDateString); - node.watchAttribute( - "value", - t.callExpression(t.identifier(IMPORTABLES.toDateString), [value.expression]) - ); - } else if (value.type === "string") { - node.addFixedAttribute("valueAsDate", value.value); - } - } -} - class EventDirective extends Directive { static attributeName = "event"; static valueMode: ValueMode = ValueMode.StringRequired; @@ -267,18 +258,48 @@ class UniqueDirective extends Directive { static attributeName = "unique"; static valueMode: ValueMode = ValueMode.NotAllowed; static qualifierMode: QualifierMode = QualifierMode.NotAllowed; + static mustBeOnRoot = true; apply(node: TagNode, _value: NodeValue, _qualifier: Qualifier, _base: string) { node.component.unique = true; } } +/** + * This is a hack to enable a Proxy of a Date to be passed to `valueAsDate`. + * It is not a documented directive. + */ +class ValueAsDateDirective extends Directive { + static attributeName = "valueAsDate"; + apply(node: TagNode, value: NodeValue, _qualifier: Qualifier, _base: string) { + if (value.type === "expression") { + node.requiredImport(IMPORTABLES.toDateString); + node.watchAttribute( + "value", + t.callExpression(t.identifier(IMPORTABLES.toDateString), [value.expression]) + ); + } else if (value.type === "string") { + node.addFixedAttribute("valueAsDate", value.value); + } + } +} + +class WatchDirective extends Directive { + static attributeName = "watch"; + static valueMode: ValueMode = ValueMode.ExpressionOptional; + static qualifierMode: QualifierMode = QualifierMode.NotAllowed; + static mustBeOnRoot = true; + apply(node: TagNode, value: NodeValue, _qualifier: Qualifier, _base: string) { + node.component.watchProps = { callback: value.expression }; + } +} + export const builtinDirectives = [ ApplyDirective, + AssignDirective, BindDirective, ClassDirective, CssDirective, CtrlDirective, - ValueAsDateDirective, EventDirective, FixedDirective, HideDirective, @@ -292,5 +313,7 @@ export const builtinDirectives = [ ShowDirective, StyleDirective, ToggleDirective, - UniqueDirective + UniqueDirective, + ValueAsDateDirective, + WatchDirective ]; diff --git a/packages/babel-plugin-wallace/src/errors.ts b/packages/babel-plugin-wallace/src/errors.ts index 94392c2..1241654 100644 --- a/packages/babel-plugin-wallace/src/errors.ts +++ b/packages/babel-plugin-wallace/src/errors.ts @@ -41,6 +41,9 @@ export const ERROR_MESSAGES = { DIRECTIVE_NO_VALUE_ALLOWED: (directive: string) => { return `The \`${directive}\` directive does not allow a value.`; }, + DIRECTIVE_MUST_BE_ON_ROOT_ELEMENT: (directive: string) => { + return `The \`${directive}\` directive may only be used on the root element.`; + }, EVENT_USED_WITHOUT_BIND: "The `event` directive must be used with the `bind` directive.", FLAG_REQUIRED: (flag: string) => { diff --git a/packages/babel-plugin-wallace/src/models/component.ts b/packages/babel-plugin-wallace/src/models/component.ts index 3de8c5f..2485380 100644 --- a/packages/babel-plugin-wallace/src/models/component.ts +++ b/packages/babel-plugin-wallace/src/models/component.ts @@ -6,7 +6,7 @@ import type { JSXText, Expression } from "@babel/types"; -import { stringLiteral } from "@babel/types"; +import { stringLiteral, LVal } from "@babel/types"; import type { Scope } from "@babel/traverse"; import { HTML_SPLITTER } from "../constants"; import { buildConcat, getPlaceholderExpression } from "../ast-helpers"; @@ -45,7 +45,7 @@ export class Component { #currentNodeAddress: Array = []; module: Module; scope: Scope; - baseComponent: Expression | undefined; + baseComponent?: Expression; rootElement: HTMLElement; extractedNodes: ExtractedNode[] = []; propsIdentifier: Identifier; @@ -53,6 +53,8 @@ export class Component { xargMapping: { [key: string]: string } = {}; htmlExpressions: Expression[] = []; unique: boolean = false; + assignTo?: LVal | string; + watchProps?: { callback?: Expression }; constructor( module: Module, scope: Scope, @@ -103,6 +105,9 @@ export class Component { this.#addElement(node.getElement(), path, tracker); this.extractedNodes.push(node); } + needsCustomSetMethod() { + return this.assignTo || this.watchProps; + } processJSXElement( path: NodePath, tracker: WalkTracker, diff --git a/packages/babel-plugin-wallace/src/models/directive.ts b/packages/babel-plugin-wallace/src/models/directive.ts index 7d40315..3e3d5ea 100644 --- a/packages/babel-plugin-wallace/src/models/directive.ts +++ b/packages/babel-plugin-wallace/src/models/directive.ts @@ -46,10 +46,13 @@ export class Directive { static allowOnNested = false; static allowOnRepeated = false; static allowOnNormalElement = true; + static mustBeOnRoot = false; static mayAccessComponent = true; static mayAccessElement = false; static mayAccessEvent = false; - apply(node: TagNode, value: NodeValue, qualifier: Qualifier, base: string) {} + apply(node: TagNode, value: NodeValue, qualifier: Qualifier, base: string) { + throw new Error("apply not implemented on directive"); + } validate( node: TagNode, value: NodeValue, @@ -59,7 +62,7 @@ export class Directive { ) { const constructor = this.constructor as typeof Directive; this.validateTypeAndQualifier(node, value, qualifier, constructor); - this.validateNestedAndRepeat(node, constructor); + this.validateLocation(node, constructor); this.validateScopeVariablAccess(node, value, constructor, component); } validateTypeAndQualifier( @@ -156,8 +159,8 @@ export class Directive { break; } } - validateNestedAndRepeat(node: TagNode, constructor: typeof Directive) { - const { attributeName, allowOnRepeated, allowOnNested } = constructor; + validateLocation(node: TagNode, constructor: typeof Directive) { + const { attributeName, allowOnRepeated, allowOnNested, mustBeOnRoot } = constructor; if (!allowOnRepeated && node.isRepeatedComponent) { error( node.path, @@ -170,6 +173,9 @@ export class Directive { ERROR_MESSAGES.DIRECTIVE_NOT_ALLOWED_ON_NESTED_ELEMENT(attributeName) ); } + if (mustBeOnRoot && node.parent) { + error(node.path, ERROR_MESSAGES.DIRECTIVE_MUST_BE_ON_ROOT_ELEMENT(attributeName)); + } } /* Ensures the expression only accesses the scope variables it is allowed to. diff --git a/packages/babel-plugin-wallace/src/writers/defineComponent.ts b/packages/babel-plugin-wallace/src/writers/defineComponent.ts index b6fb89a..05eb84c 100644 --- a/packages/babel-plugin-wallace/src/writers/defineComponent.ts +++ b/packages/babel-plugin-wallace/src/writers/defineComponent.ts @@ -1,6 +1,6 @@ import * as t from "@babel/types"; -import type { CallExpression } from "@babel/types"; -import { Component } from "../models"; +import type { CallExpression, ExpressionStatement, LVal } from "@babel/types"; +import { Component, Module } from "../models"; import { wallaceConfig } from "../config"; import { COMPONENT_PROPERTIES, IMPORTABLES } from "../constants"; import type { @@ -165,6 +165,62 @@ function buildConstructor( ); } +function assignExpression(left: LVal, right: Expression): ExpressionStatement { + return t.expressionStatement(t.assignmentExpression("=", left, right)); +} + +function buildWatchCall(module: Module, name: string, watch?: { callback?: Expression }) { + module.requireImport(IMPORTABLES.watch); + const callback = + watch.callback || + t.arrowFunctionExpression( + [], + t.callExpression(t.memberExpression(t.thisExpression(), t.identifier("update")), []) + ); + return t.callExpression(t.identifier(IMPORTABLES.watch), [ + t.identifier(name), + callback + ]); +} + +function getComponentPropertyAssignment( + module: Module, + name: string, + watch?: { callback?: Expression } +) { + const left = t.memberExpression(t.thisExpression(), t.identifier(name)); + const right = watch ? buildWatchCall(module, name, watch) : t.identifier(name); + return assignExpression(left, right); +} + +// TOOD: revisit once we rename per directive. +function buildSetFunction(componentDefinition: ComponentDefinitionData) { + const component = componentDefinition.component; + const { assignTo, watchProps, module } = component; + + const statements: any[] = [ + t.variableDeclaration("const", [ + t.variableDeclarator(component.componentIdentifier, t.identifier("this")), + t.variableDeclarator(component.propsIdentifier, t.identifier("props")) + ]), + getComponentPropertyAssignment(module, "props", watchProps) + ]; + if (assignTo) { + let actualValue: LVal; + if (typeof assignTo === "string") { + actualValue = t.memberExpression(component.propsIdentifier, t.identifier(assignTo)); + } else { + actualValue = assignTo; + } + statements.push(assignExpression(actualValue, t.identifier("this"))); + } + return t.functionExpression( + null, + [t.identifier("props"), t.identifier("ctrl")], + t.blockStatement(statements) + ); +} + function buildDismountKeys(componentDefinition: ComponentDefinitionData) { return t.arrayExpression( componentDefinition.dismountKeys.map(key => t.numericLiteral(key)) @@ -173,12 +229,17 @@ function buildDismountKeys(componentDefinition: ComponentDefinitionData) { export function buildDefineComponentCall(component: Component): CallExpression { const componentDefinition = consolidateComponent(component); + const args: any[] = [ componentDefinition.html, buildWatchesArg(componentDefinition), buildLookupsArg(componentDefinition), buildConstructor(componentDefinition) ]; + const setFunctionArg = componentDefinition.component.needsCustomSetMethod() + ? buildSetFunction(componentDefinition) + : t.numericLiteral(0); + args.push(setFunctionArg); if (wallaceConfig.flags.allowDismount) { args.push(buildDismountKeys(componentDefinition)); } diff --git a/packages/wallace/lib/component.js b/packages/wallace/lib/component.js index 51ea819..3b00069 100644 --- a/packages/wallace/lib/component.js +++ b/packages/wallace/lib/component.js @@ -1,10 +1,14 @@ const throwAway = document.createElement("template"); const NO_LOOKUP = "__"; +const defaultSetFunction = function (props, /* #INCLUDE-IF: allowCtrl */ ctrl) { + this.props = props; + /* #INCLUDE-IF: allowCtrl */ this.ctrl = ctrl; +}; + const ComponentPrototype = { render: function (props, /* #INCLUDE-IF: allowCtrl */ ctrl) { - this.props = props; - /* #INCLUDE-IF: allowCtrl */ this.ctrl = ctrl; + this.set(props, /* #INCLUDE-IF: allowCtrl */ ctrl); this.update(); }, @@ -152,12 +156,14 @@ export const defineComponent = ( watches, queries, contructor, + setFunction, /* #INCLUDE-IF: allowDismount */ dismountKeys, inheritFrom ) => { const ComponentDefinition = initConstructor(contructor, inheritFrom || ComponentBase); const proto = ComponentDefinition.prototype; throwAway.innerHTML = html; + proto.set = setFunction || defaultSetFunction; proto._w = watches; proto._q = queries; proto._t = throwAway.content.firstChild; diff --git a/packages/wallace/lib/types.d.ts b/packages/wallace/lib/types.d.ts index 93dc741..7dad85e 100644 --- a/packages/wallace/lib/types.d.ts +++ b/packages/wallace/lib/types.d.ts @@ -821,6 +821,7 @@ declare module "wallace" { export const Router: Router; } +type OptionalExpression = T | boolean; type MustBeExpression = Exclude; /** @@ -849,6 +850,37 @@ interface DirectiveAttributes extends AllDomEvents { */ apply?: MustBeExpression; + /** + * ## Wallace directive: assign + * + * Assigns the component instance to a value during `render`, typically on the props + * or the controller. + * + * You can either use an expression: + * + * ``` + * const MyComponent = ({ id }, { ctrl }) => ( + *
+ *
+ * ); + * ``` + * + * Or use a qualifier, which is read as being a field on the props: + * + * ``` + * const MyComponent = () => ( + *
+ *
+ * ); + * ``` + * + * Be careful not to assign to a watched property which updates this component or a + * parent, as that will create an infinite loop. + * + * May only be used on the root element. Modifies the `set` method. + */ + assign?: ComponentInstance; + /** * ## Wallace directive: bind * @@ -952,6 +984,7 @@ interface DirectiveAttributes extends AllDomEvents { * ### Directives: * * - `apply` runs a callback to modify an element. + * - `assign` assigns the component instance to a value. * - `bind` updates a value when an input is changed. * - `class:xyz` defines a set of classes to be toggled. * - `css` shorthand for `fixed:class`. @@ -971,6 +1004,7 @@ interface DirectiveAttributes extends AllDomEvents { * - `toggle:xyz` toggles `xyz` as defined by `class:xyz` on same element, or class * `xyz`. * - `unique` can be set on components which are only used once for better performance. + * - `watch` watches the props or the controller. * * See more by hovering on a specific directive. * Qualifiers like `class:danger` break the tool tip. Try `class x:danger`. @@ -1103,8 +1137,38 @@ interface DirectiveAttributes extends AllDomEvents { * ## Wallace directive: unique * * Performance optimisation that can be applied to a component which is only used once. + * + * May only be used on the root element. */ unique?: boolean; + + /** + * ## Wallace directive: watch + * + * Wraps the props in a `watch` call which updates the component by overriding the + * `set` method: + * + * ``` + * function set(props, ctrl) { + * this.props = watch(props, () => this.update()); + * this.ctrl = ctrl; + * } + * ``` + * + * You can provide a different callback to `watch`: + * + * ``` + * const MyComponent = () => ( + *
foo()}>
+ * ); + * ``` + * + * For more complex use cases, import the `watch` function and use it in an overriden + * `render` method. + * + * May only be used on the root element. Modifies the `set` method. + */ + watch?: OptionalExpression; } // This makes this a module, which is needed to declare global, which is needed to make diff --git a/packages/wallace/tests/functionality/07.directives.11.assign.spec.jsx b/packages/wallace/tests/functionality/07.directives.11.assign.spec.jsx new file mode 100644 index 0000000..4cc116e --- /dev/null +++ b/packages/wallace/tests/functionality/07.directives.11.assign.spec.jsx @@ -0,0 +1,73 @@ +import { extendComponent } from "wallace"; +import { testMount } from "../utils"; + +describe("specification", () => { + test("not allowed on non-root component", () => { + const code = ` + const Foo = () => ( +
+ bar +
+ ) + `; + expect(code).toCompileWithError( + "The `assign` directive may only be used on the root element." + ); + }); + + test("must supply a value", () => { + const code = `const Foo = () =>
`; + expect(code).toCompileWithError("The `assign` directive requires a value."); + }); + + test("may supply a string", () => { + const code = `const Foo = () =>
`; + expect(code).toCompileWithoutError(); + }); + + test("may supply a qualifier", () => { + const code = `const Foo = () =>
`; + expect(code).toCompileWithoutError(); + }); + + test("may supply an expression", () => { + const code = `const Foo = () =>
`; + expect(code).toCompileWithoutError(); + }); +}); + +describe("behaviour", () => { + test("assigns to expression", () => { + let foo; + const MyComponent = () =>
; + const component = testMount(MyComponent); + + expect(component).toStrictEqual(foo); + }); + + test("assigns to props with string", () => { + const props = {}; + const MyComponent = props =>
; + const component = testMount(MyComponent, props); + + expect(component).toStrictEqual(props.x); + }); + + test("assigns to props with qualifier", () => { + const props = {}; + const MyComponent = props =>
; + const component = testMount(MyComponent, props); + + expect(component).toStrictEqual(props.x); + }); + + test("assignment is not inherited", () => { + const props = {}; + + const Parent = props =>
; + const MyComponent = extendComponent(Parent, () =>
); + testMount(MyComponent, props); + + expect(props.x).toBeUndefined(); + }); +}); diff --git a/packages/wallace/tests/functionality/07.directives.12.watch.spec.jsx b/packages/wallace/tests/functionality/07.directives.12.watch.spec.jsx new file mode 100644 index 0000000..ff67119 --- /dev/null +++ b/packages/wallace/tests/functionality/07.directives.12.watch.spec.jsx @@ -0,0 +1,73 @@ +import { extendComponent } from "wallace"; +import { testMount } from "../utils"; + +describe("specification", () => { + test("not allowed on non-root component", () => { + const code = ` + const Foo = () => ( +
+ bar +
+ ) + `; + expect(code).toCompileWithError( + "The `watch` directive may only be used on the root element." + ); + }); + + test("may supply no value", () => { + const code = `const Foo = () =>
`; + expect(code).toCompileWithoutError(); + }); + + test("may supply an expression", () => { + const code = `const Foo = () =>
`; + expect(code).toCompileWithoutError(); + }); + + test("may not supply string", () => { + const code = `const Foo = () =>
`; + expect(code).toCompileWithError( + "The `watch` directive requires a value of type `expression`." + ); + }); + + test("may not supply qualifier", () => { + const code = `const Foo = () =>
`; + expect(code).toCompileWithError("The `watch` directive may not have a qualifier."); + }); +}); + +describe("behaviour", () => { + test("watches udpates component by default", () => { + const props = { foo: 1 }; + const MyComponent = props =>
{props.foo}
; + const component = testMount(MyComponent, props); + expect(component).toRender(`
1
`); + component.props.foo = 2; + expect(component).toRender(`
2
`); + }); + + test("can specify callback", () => { + const props = { foo: 1 }; + let callCount = 0; + const MyComponent = props =>
callCount++}>{props.foo}
; + const component = testMount(MyComponent, props); + expect(component).toRender(`
1
`); + expect(callCount).toBe(0); + component.props.foo = 2; + expect(component).toRender(`
1
`); + expect(callCount).toBe(1); + }); + + test("watch is not inherited", () => { + const props = { x: 1 }; + + const Parent = props =>
; + const MyComponent = extendComponent(Parent, props =>
{props.x}
); + const component = testMount(MyComponent, props); + expect(component).toRender(`
1
`); + component.props.x = 2; + expect(component).toRender(`
1
`); + }); +});