Tapestry (5.7.2) - Refreshing an outer zone from a internal component via XHR

485 views Asked by At

we are encountering an issue with Tapestry (version 5.7.2) and zone refreshing from components.

We have a page that contains a loop of (zoned) components, where each component has a async event (see code).

What we want to achieve, is that via the XHR refresh, we refresh both the component zone and another zone that is contained by the page, which we get via an interface.

In this code example, when we click on the first component, it refreshes the zone but 'forgets' the @Persist annotated field, which takes the value of the second component.

If we click on the second component instead, it refreshes the first one too.

What are we doing wrong? seems trivial zone refreshing but we didn't get it, we tried different approaches but must fallback to handle this part in a less elegant way.

Page code:

  @InjectComponent
  private Zone listingZone;

  @Persist
  private String[] names;

  @Property
  private String lastRefreshZoneName;
  
  @Property
  private String _name;
  
  Object onActivate() throws Exception {
    names = new String[]{"first","second"}; //eg. loaded from DB
    return null;
  }

  public LocalDateTime getTime() {
    return LocalDateTime.now();
  }
  
  public String[] getNames() {
    return names;
  }
  
  @Override
  public Zone getOuterZone() {
    return listingZone;
  }

  @Override
  public void onTriggerOn(String name) {
    lastRefreshZoneName = name;
  }

with a simple TML as such:

    <t:zone t:id="pageZone">
        Page zone: ${time}<br/>
        <hr/>

        <t:loop source="names" value="name">
            <t:attribute.zonedcomponent t:parameter="${name}"/>
        </t:loop>

        <hr/>

        <t:zone t:id="listingZone">
            Listing zone, last refresh: ${time}<br/>
            Last refresh zone name: ${lastRefreshZoneName}
        </t:zone>

    </t:zone>

for this example, ZonedComponent is a component with a zone and a event link as such:

  @Inject
  private Request request;

  @Inject
  private AjaxResponseRenderer ajaxResponseRenderer;

  @Inject
  private ComponentResources resources;

  @InjectComponent
  private Zone componentZone;

  @Parameter(defaultPrefix = BindingConstants.LITERAL)
  private String parameter;

  @Persist
  @Property
  private String name;

  void setupRender() {
    this.name = parameter;
  }

  public LocalDateTime getTime() {
    return LocalDateTime.now();
  }

  void onTrigger(String name) {
    if (request.isXHR()) {
      // this.name = name; // if we don't uncomment this, then it doesnt even propagate the 'name' correctly
      SomeInterface page = (SomeInterface)resources.getPage();
      page.onTriggerOn(this.name);
      ajaxResponseRenderer.addRender(componentZone)
                          .addRender(page.getOuterZone());
    }
  }

  public static interface SomeInterface {

    ClientBodyElement getOuterZone();

    void onTriggerOn(String name);
  }

with the tml zone as such:

<t:zone t:id="componentZone" style="border:1px solid black">
        Component name: ${name}<br/>

        Component zone: ${time}<br/>

        <t:eventlink t:event="trigger" t:context="${name}" async="true">
            async event from ${name}
        </t:eventlink>
    </t:zone>
1

There are 1 answers

0
user6708591 On

There are three key points to learn from your example.

  1. Component (server-side) Ids vs. Client Ids - When working with Ajax and Zones, the component event handlers need to know the client-side element id. You can simply hard-code one in the template file. However, when using the component within a loop, the id is no longer unique and the event handler has no way of knowing which client-side element (not) to update. One solution is to use the JavaScriptSupport service to allocate a client id.

  2. Event bubbling - Components events don't have do be handled within the component they were triggered. They can actually 'bubble up' from nested components to outer components/pages. It is also possible to handle the event in both places. This allows you to simplify your code quite a bit: no need to get the containing component/page an invoke a method of an interface you had to introduce to make it work. See the Event Bubbling section on the Components Events page in the Tapestry docs for more details.

  3. Component parameters - Component parameters have a Java type. When passing parameter values, simply refer to a property. Use the ${...} syntax only where you need an expression converted to string. See section 'Don't use the ${...} syntax!' on the Component Parameters page in the Tapestry docs.

Having learned the above your example can be rewritten as follows.

The page class:

@Property
private String[] names;

@Property
private String name;

@Property
private String lastRefreshZoneName;

@Inject
AjaxResponseRenderer ajaxResponseRenderer;

@InjectComponent
private Zone listingZone;

void onActivate() {
    names = new String[] { "first", "second" }; // eg. loaded from DB
}

public LocalDateTime getTime() {
    return LocalDateTime.now();
}

public void onTrigger(String name) {
    lastRefreshZoneName = name;
    ajaxResponseRenderer.addRender(listingZone);
}

The page template:

Page rendered at: ${time}
<br />
<hr />


<t:loop source="names" value="name">
    <t:zonedComponent t:name="name" />
</t:loop>

<hr />

<t:zone t:id="listingZone" id="listingZone">
    Listing zone, last refresh: ${time}
    <br />
    Last refresh zone name: ${lastRefreshZoneName}
</t:zone>

The component class:

@Parameter(defaultPrefix = BindingConstants.PROP)
@Property
private String name;

@Inject
private Request request;

@Inject
private AjaxResponseRenderer ajaxResponseRenderer;

@InjectComponent
private Zone componentZone;

@Inject
JavaScriptSupport jsSupport;

@Inject
private ComponentResources resources;

@Property
String clientId;

void setupRender() {
    clientId = jsSupport.allocateClientId(resources);
}

public LocalDateTime getTime() {
    return LocalDateTime.now();
}

boolean onTrigger(String name, String clientId) {
    if (request.isXHR()) {
        // Since the fields were cleared after the original rendering of the
        // page, they need to be assigned again so that they are available
        // when the XHR response is rendered.
        this.name = name;
        this.clientId = clientId;
        
        // Queue only this component's zone. The containing page takes care
        // of its one zone.
        ajaxResponseRenderer.addRender(componentZone);
    }
    return false; // Bubble up: allow containing page/component to do some
                  // more handling
}

}

The component template:

    <t:zone t:id="componentZone" id="${clientId}" style="border:1px solid black" >
    Client id: ${clientId}
    <br />
    Component name: ${name}
    <br />
    Component zone: ${time}
    <br />

    <t:eventlink t:id="link" t:event="trigger" t:context="[name,clientId]"  async="true">
        async event from ${name}
    </t:eventlink>
</t:zone>