groowt/view-components/spec.md
2024-05-03 12:01:45 +02:00

9.6 KiB

View Components Spec

The View Components work roughly as follows.

First, one defines a component by at a minimum implementing the ViewComponent interface. Additionally, for ease of use, one can extend one of the built-in helpers, such as AbstractViewComponent or GStringTemplateViewComponent. For example, here might be a web-style component:

class MyComponent extends GStringTemplateViewComponent {
    String greeting
    
    MyComponent(Map<String, Object> attr) {
        super(new File('someTemplate.gst')) // TODO: figure out what args this actually takes
        greeting = atr.greeting
    }
}

Its associated template could be as simple as:

Hello. Here is a friendly greeting: ${greeting}.

However, it could be something more complex such as a JSON document:

{
    "greeting": "<%= greeting %>"
}

Extending this last example, say our MyComponent class had this method:

def getGreetingInQuotes() {
   '"' + greeting + '"' 
}

Then we could simply do in our JSON document:

{
    "greeting": $greetingInQuotes // or <%= greetingInQuotes %>, whichever you prefer
}

And so on.

Now we have to find a way to invoke our component. We could of course do so programmatically, like:

def myComponent = new MyComponent(greeting: 'hello from everywhere!')
def rendered = myComponent.render()
assert rendered == 'Hello. Here is a friendly greeting: hello from everywhere!'

However, this is basically the same as using GStringTemplateView (from the Views module). For the real power of the View Components, let's see how we can call them from other templates and components.

Using Web Components

Continuing with the MyComponent from above, let's create our basic template which will include a rendering of the component. It's as simple as this, say in a file called myComponentPage.gst:

<MyComponent greeting="Hello, World!" />

To render this template, we need to do the following:

import MyComponent

def pageViewComponent = new PageViewComponent(new File('myComponentPage.gst')).configure {
    context {
        registry {
            rootScope {
                addWithMapArg(MyComponent)
            }
        }
    }
}
def rendered = pageViewComponent.render()
assert rendered == 'Hello. Here is a friendly greeting: Hello, World!'

Internally, the PageComponent will compile its template to something like the following Groovy script:

import static com.jessebrault.groowt.view.component.runtime.Helpers.*

def getScript() {
    return { Writer __writer0 ->
        resolveOrThrow(context, 'MyComponent')([greeting: 'Hello, World!']).renderTo(__writer0)
    }
}

The script above has the single method getScript() which returns a single closure. The PageComponent class will call this method, receive the closure, set itself as the closure's delegate, and call it with a Writer.

More Advanced Web Component Example

Here is quite a broad sketch of how a set of HTML form components might work.

ViewComponent Groovy classes:

// This trait should be included in the HTML lib
trait HTMLComponent {
    String orElseEmpty(boolean cond, Closure lazyOnTrue, Closure<String> format) {
        cond ? format(lazyOnTrue()) : ''
    }

    String orElseEmpty(boolean cond, String onTrue) {
        cond ? onTrue : ''
    }

    String orElseEmpty(boolean cond, Closure<String> onTrue) {
        cond ? onTrue() : ''
    }

    String inQuotes(Object value) {
        /"$value"/
    }
    
    String attr(String name, Object value) {
        /$name="$value"/
    }
    
    String attr(Closure cl) {
        new RenderAttrClosure(this, cl)() // RenderAttrClosure would be in the lib
    }

    String joinAttr(Map<String, Object> attr) {
        orElseEmpty(!attr.isEmpty) {
            attr.collect(this.&attr).join(' ')
        }
    }
    
    String tag(String tagName, Map<String, Object> attr, Object inner) {
        "<$tagName ${joinAttr(attr)}>$inner</$tagName>"
    }
}

// Our enhanced form class
class FormWithModel extends GStringTemplateViewComponent implements HTMLComponent {
    final String id
    final Object model
    final String action
    final boolean wrap
    final Map<String, Object> customAttr
    
    private final Closure children

    FormWithModel(Map<String, Object> attr, Closure children) {
        super(new File('formWithModel.gst'))
        this.id = id
        this.model = attr.model
        this.action = attr.action
        this.wrap = attr.wrap != null ? attr.wrap : false
        this.children = children
    }

    @Override
    protected Closure getChildren() {
        this.children
    }

    @Override
    protected ComponentRegistry.Scope getChildrenScope() {
        new SimpleScope().tap {
            addWithContextAndMapArgs(Input)
        }
    }

    boolean hasModelProperty(String name) {
        this.model.metaClass.properties.find { it.name == name } != null
    }

    Object getModelProperty(String name) {
        this.model.getProperty(name)
    }
}

class Input extends GStringTemplateViewComponent implements HTMLComponent {

    private static String getDefaultType(Object model, String name) {
        def value = model.getProperty(name)
        return switch (value) {
            case String -> 'text'
            default -> throw new UnsupportedOperationException('String model properties not supported yet')
        }
    }
    
    final String name
    final String type
    final boolean wrap
    final boolean putLabel
    final String label
    
    private final Map<String, Object> customAttr

    Input(ComponentContext context, Map<String, Object> attr) {
        super(new File('input.gst'))
        this.context = context
        this.name = requireNonNull(attr.name)
        this.type = attr.type ? attr.type : getDefaultType(this.getForm().model, this.name)
        this.wrap = attr.wrap != null ? attr.wrap : this.getForm().wrap
        this.putLabel = attr.putLabel != null ? attr.putLabel : true
        this.label = attr.label != null ? attr.label : ''
        this.customAttr = attr.findAll { keyHolder, value -> value != null && !(keyHolder in ['name', 'wrap']) }
    }

    private FormWithModel getForm() {
        def form = this.context.findNearestAncestorByClass(FormWithModel)
        if (form == null) {
            throw new ComponentException("An Input can only be used inside a FormWithModel")
        } else {
            return form
        }
    }

    boolean hasValue() {
        this.form.hasModelProperty(this.name)
    }

    Object getValue() {
        this.form.getModelProperty(this.name)
    }
}

// Should be in the HTML lib
class SurroundIf extends GStringTemplateViewComponent implements HTMLComponent {
    
    static Closure<Tuple> tag(String name, Map<String, Object> attr) {
        return { SurroundIf self ->
            Tuple.of("<$name ${self.joinAttr(attr)}>", "</$name>")
        }
    }

    private static final String template = '''
<On cond={condition}>
    <%= tag[0] %>
        <%= renderChildren() %>
    <%= tag[1] %>
</On>
'''

    final boolean condition
    final Tuple tag

    private final Closure children

    SurroundIf(Map<String, Object> attr, Closure children) {
        super(template.trim())
        this.condition = attr.condition
        switch (attr.tag) {
            case null -> { this.tag = Tuple.of('', '') }
            case Closure -> { this.tag = attr.tag.call(this) }
            case String -> { this.tag = Tuple.of("<$attr.tag>", "</$attr.tag>") }
            default -> { throw new IllegalArgumentException() }
        }
        this.children = children
    }

    @Override
    protected Closure getChildren() {
        this.children
    }
}

And our template files:

// formWithModel.gst
<form (id, name)>
    ${ renderChildren() }
</form>
// input.gst

<SurroundOn cond={wrap} tag={<div class="form-control">${ renderChildren() }</div>}>
    <On cond={putLabel} out={<label for={ name.capitalize() }>$label</label>} />
    <Switch expr={type}>
        <When is="text">
            <input (name, type, *customAttr) value={ hasValue() ? value : null } />
        </When>
        <When is="textarea">
            <textarea (name, *customAttr)>
                <On cond={ hasValue() } out={value} />
            </textarea>
        </When>
        <Default do={ throw new UnsupportedOperationException() } />
    </Switch>
</SurroundOn>

Our basic model:

class MessageModel {
    String from
    String to
    String message
}

Now here is our target page:

// target.gst
<FormWithModel id="message_form" model={message} action="/sendMessage" wrap={true}>
    <Input name="from" />
    <Input name="to" putLabel={false} />
    <Input name="message" type="textarea" wrap={false} label="Write your message here: " />
</FormWithModel>

Now let's render it:

def message = new MessageModel(from: 'Jesse', to: 'Jeanna', message: 'Hello, World!')
def root = new HTMLRootViewComponent(new File('target.gst')).configure {
    context {
        registry {
            rootScope {
                addWithMapAndChildrenArgs(SurroundIf, FormWithModel)
            }
        }
    }
}
root.message = message
def rendered = root.render()
assert rendered == '''
<form id="message_form" action="/sendMessage">
    <div class="form-control">
        <label for="from">From</label>
        <input name="from" type="text" value="Jesse" />
    </div>
    <div class="form-control">
        <input name="to" type="text" value="Jeanna" />
    </div>
    <label for"name">Write your message here:</label>
    <textarea name="message">Hello, world!</textarea>
</form>
'''.trim() // may not be slightly correct with indentation, but close enough