Friday, May 13, 2016

Uberfire - The Unanswered Questions: Part 7

Good News Everyone!

...is what I would have said if there had been good news. The news is that, as of June 8, Uberfire has migrated from wildfly 8.1 to wildfly 10 and GWT's SuperDevMode will no longer work with 8.1. Well, it actually is good news because, considering version 10 has been around for more than a year, Uberfire has finally caught up. The "not so good news, everyone" is that we'll have to make some minor changes to our maven pom files to use the new wildfly version so we can use SuperDevMode to debug our web app.


This Week's Blog

In this installment I will finally introduce Uberfire WorkbenchEditors, and explain how they differ from Screens. This will require some additional GWT and Uberfire components that were not included in previous versions of our UFTasks tutorial, which means changes to some of the build files.

I'll also introduce some new concepts that are unique to editors, and I'll point out some restrictions imposed by the Uberfire framework for launching and passing information to/from editors. Let's get started...


pom /ˈpäm/
noun
one half of a pom-pom





First let's address the wildfly 10 change: in the pom.xml file located in the uftasks-webapp directory, look for the <as.version> property at the top of the file and change its value to 10.0.0.Final. So, you should have this:

    <as.version>10.0.0.Final</as.version>

As long as we're mucking around in here, let's also add the necessary dependencies to support our WorkbenchEditor; add the following dependencies to the pom.xml:

    <dependency>
      <groupId>org.uberfire</groupId>
      <artifactId>uberfire-workbench-processors</artifactId>
      <scope>provided</scope>
    </dependency>

    <dependency>
      <groupId>org.uberfire</groupId>
      <artifactId>uberfire-commons-editor-client</artifactId>
    </dependency>

    <dependency>
      <groupId>org.uberfire</groupId>
      <artifactId>uberfire-commons-editor-api</artifactId>
    </dependency>

    <dependency>
      <groupId>org.uberfire</groupId>
      <artifactId>uberfire-widgets-commons</artifactId>
    </dependency>

    <dependency>
      <groupId>org.uberfire</groupId>
      <artifactId>uberfire-widgets-core-client</artifactId>
    </dependency>

and in the <compileSourcesArtifacts> section, add these lines:

  <compileSourcesArtifact>org.uberfire:uberfire-commons-editor-api</compileSourcesArtifact>
  <compileSourcesArtifact>org.uberfire:uberfire-commons-editor-client</compileSourcesArtifact>
  <compileSourcesArtifact>org.uberfire:uberfire-widgets-core-client</compileSourcesArtifact>
  <compileSourcesArtifact>org.uberfire:uberfire-widgets-commons</compileSourcesArtifact>
  <compileSourcesArtifact>org.uberfire:uberfire-widgets-table</compileSourcesArtifact>
  <compileSourcesArtifact>org.uberfire:uberfire-widgets-properties-editor-api</compileSourcesArtifact>
  <compileSourcesArtifact>org.uberfire:uberfire-widgets-properties-editor-client</compileSourcesArtifact>
  <compileSourcesArtifact>org.uberfire:uberfire-widgets-service-api</compileSourcesArtifact>

That should be all that is needed; rebuild the entire application and test to make sure that it still works, and that it now downloads and installs wildfly 10.

Place Manager and Place Requests

The original UFTasks tutorial briefly mentioned the Place Manager and the concept of "places". We saw this service used in the ShowcaseEntryPoint class of our application to open different Perspectives. The Place Manager is used in general to open any kind of "managed window", and tracks which are currently active. The term "managed window" refers to any one of a number of different types of windows decorated with one of the annotations defined in the org.uberfire.client.annotations package; some of these you already know, e.g. WorkbenchScreen, WorkbenchEditor and Perspective. There are other types of managed windows, which I'll discuss in future blogs, but for now let's concentrate on the ones we know about.

The Place Manager keeps an internal lookup table of all currently open managed windows. The lookup key varies, depending on the type of window.

When your application wants to open a Screen, Perspective, or an Editor, it needs to construct a PlaceRequest. For Screens (and Perspectives and certain other types of managed windows), the PlaceRequest is simple: it only needs the Screen identifier, declared in the @WorkbenchScreen annotation as in, for example:

@WorkbenchScreen(identifier = "ProjectsPresenter")

This is because your application can only have one instance of a Screen type open at a time. The PlaceRequest is then simply:

PlaceRequest pr = new DefaultPlaceRequest("ProjectsPresenter");

Workbench Editors, however can have multiple instances of the same type open at the same time, so the Place Request uses the file Path being edited as the lookup table key. In this case we need to use a PathPlaceRequest, like so:

Path path = getFilePathToEdit();
PlaceRequest pr = new PathPlaceRequest(path);

We'll see how this works later.

The Workbench Editor

A WorkbenchEditor is constructed the same as any of our other Presenter classes, except it uses the @WorkbenchEditor annotation:

@WorkbenchEditor(identifier = "TaskEditor", supportedTypes = { TaskResourceType.class })

The "supportedTypes" attribute tells the Place Manager the file types (actually the filename extensions) to associate with this editor.

An Editor also needs to provide some additional UI and lifecycle bits, which are identified by annotations. These are:

  • @PostConstruct - method that is called immediately after construction of the editor Presenter class to initialize the editor's View class
  • @OnStartup - startup method that is called after the @PostConstruct method, which is responsible for initializing internal data structures and loading the editor content
  • @WorkbenchPartTitle - a method that returns the title text for the editor
  • @WorkbenchPartTitleDecoration - optional method that returns the editor title widget
  • @WorkbenchPartView - method that returns the View for the editor
  • @WorkbenchMenu - method that returns an editor-specific menu, which may be null

I won't go into the excruciatingly boring details of the implementations of each of these methods. As always, the current code can be downloaded from the git repository.

Resource Types

A Resource Type simply identifies the content of a file, similar in concept to the Eclipse Content Types, except that Uberfire associates only the file extension with the Resource Type - there is no "peeking" inside the file to determine what's in it.

When defining a Workbench Editor, you must provide one or more Resource Types that the editor can handle. For example, a text editor may declare that it can be used for both a Text Resource Type and an XML Resource Type. The editor may also declare a "priority" to help Uberfire determine the best editor to use for a particular Resource Type. Thus, if your application contains both an advanced XML editor with syntax highlighting and tag completion and a bunch of other cool stuff, along with a plain text editor, Uberfire will choose the XML editor if it declares a higher priority in the @WorkbenchEditor annotation.

For our Task Editor we will create a new ResourceType to handle Task objects. The ResourceType definition looks like this:

@ApplicationScoped
public class TaskResourceType implements ClientResourceType {

    @Override
    public String getShortName() {
        return "task";
    }

    @Override
    public String getDescription() {
        return "TO-DO Task file";
    }

    @Override
    public String getPrefix() {
        return "";
    }

    @Override
    public String getSuffix() {
        return "task";
    }

    @Override
    public int getPriority() {
        return 0;
    }

    @Override
    public String getSimpleWildcardPattern() {
        return "*.task";
    }

    @Override
    public boolean accept(Path path) {
        return path.getFileName().endsWith( "." + getSuffix() );
    }

    @Override
    public IsWidget getIcon() {
        return null;
    }

}

This is pretty straightforward: it simply defines a file extension of "task" and provides some descriptive text and an icon for use with, for example, a file browser screen if one were provided. Now when we ask the Place Manager to open a VFS file with a ".task" extension, it will know to instantiate our Task Editor.

The View

Surprisingly, the Task editor view has not changed very much from its previous incarnation, which was hosted in a popup modal dialog box. Obviously the show() and hide() methods, which controlled the visibility of the modal dialog, have gone away. The TaskEditorView interface now looks like this (recall that this interface is defined internally in TaskEditorPresenter):

    public interface View extends UberView<TaskEditorPresenter> {
        IsWidget getTitleWidget();
        void setContent(final TaskWithNotes content);
        TaskWithNotes getContent();
        boolean isDirty();
    }

The getTitleWidget() method satisfies the @WorkbenchPartTitleDecoration requirement for the Presenter. The setContent() and getContent() methods deserve some explanation (see Argh! More Model Changes?) and the isDirty() method is used by the Presenter to determine if the Task object has changed and needs to be persisted.

Firing up the Editor

In the previous incantation of this code, we opened the modal dialog popup used to edit the Task, from the TasksPresenter. Recall that the associated View had an "edit" button for each task which, when clicked, caused the dialog to show. We'll still keep this method for opening the Task Editor, but instead we'll use a Place Manager request to open our Workbench Editor. Our showTaskEditor() method in TasksPresenter now looks like this:

    public void showTaskEditor(final Task task) {
        this.task = task;
        ufTasksService.call(new RemoteCallback<String>() {
            @Override
            public void callback(final String response) {
                if (response!=null) {
                    String filename = response.replaceFirst(".*/", "");
                    Path path = PathFactory.newPath(filename, response);
                    placeRequest = new PathPlaceRequest(path);
                    placeManager.goTo(placeRequest);
                }
                else 
                    GWT.log("UFTasksService is unable to load tasks file");
            }
        }).getFilePath(user.getIdentifier(), task);
    }

    public PathPlaceRequest getPlaceRequest() {
        return placeRequest;
    }
    
    public Task getTask() {
        return task;
    }

Here, we have added a new method to the UFTasksService called getFilePath(). This returns a file name string for the task notes file (the "*.task" ResourceType.) Since the UFTasksService already knows the file system location of the Tasks list file ("tasks.json") it seemed like a logical place to provide the task notes file name as well. The task notes file name is simply a concatenation of the User ID, Task ID and the ".task" file name suffix.

Notice that the placeRequest object is a TasksPresenter class field, which is provided to the TaskEditorPresenter by way of getPlaceRequest(). This is required so that the Editor can properly close the window when requested by the user. Apparently Place Manager requires the same PlaceRequest object that opened an editor, to be used to close it as well. This is because it uses the hashcode of the PlaceRequest object as the key in its internal lookup table. It beats the heck outta me why this is so, but...there it is. Maybe one of the Uberfire gurus can explain why it's done this way instead of using the PlaceRequest identifier and/or Path value. This brings me to my next topic:

Parameter Passing

Place Manager only allows String name/value pairs to be passed into an Editor. This can be done either in one of the PlaceRequest constructors (DefaultPlaceRequest(identifier, Map<String,String> parameters)) or using the addParameter() method after construction. This means that if you want to pass an object into your Editor, you'll have to serialize the object as String name/value pairs, or as JSON - bummer :(

Notice that I chose to provide the Task object being edited by way of TasksPresenter#getTask() instead of serializing it and passing it through the Place Request parameters map. If there's a better way of doing this, I'd be interested to find out.

Argh! More Model Changes?

Oh what, you thought we were done hacking around in the model? First, we haven't even defined the additional bits of information that we wanted to include in a Task object (priority, due date, etc.) and second, the Presenter-Model interaction will require some additional support from the Model.

Besides the aforementioned bits, we also want the Task to have an arbitrarily long Rich Text "notes" attribute which the user can view and update inside our Task Editor, but we don't want to incur the penalty of having to marshal this potentially large amount of data across the wire for every task displayed by the TasksPresenter/TasksView list. Instead we only want to load the notes when the user opens the Task Editor. This is accomplished using a separate text file associated with a Task instance. The text file name is constructed using the Task's "id" field suffixed with a ".task" file extension. Thus, when we issue a Place Request with this file name, Place Manager will locate and open our Editor.

Our new Task object now contains the following fields (along with getters and setters):

public class Task extends TreeNode<Folder, TreeNode> {
    private String name;
    private boolean done;
    private int priority;
    private Date dueDate;
    private String id;

    public Task(@MapsTo("name") String name) {
        this.name = name;
        this.done = false;
        priority = 0;
        dueDate = new Date();
        
        // Yes we should probably use a UUID here to ensure uniqueness,
        // but this is good enough for our purposes...
        this.id = Long.toString(System.currentTimeMillis());
    }
    
    public Task(Task that) {
        set(that);
    }

    ...

    public boolean equals(Object obj) {
        if (obj instanceof Task) {
            Task that = (Task)obj;
            if (!this.getName().equals(that.getName()))
                return false;
            if (this.isDone() != that.isDone())
                return false;
            if (this.getPriority() != that.getPriority())
                return false;
            if (!this.getDueDate().equals(that.getDueDate()))
                return false;
            return true;
        }
        return super.equals(obj);
    }

    public void set(Task that) {
        this.name = that.name;
        this.done = that.done;
        this.priority = that.priority;
        this.dueDate = that.dueDate;
        this.id = that.id;
    }

Note the equals() override allows us to determine if a Task has changed (is dirty) in the Editor.

To simplify handling of the additional Rich Text field, I have defined a new Model object named TaskWithNotes. This is for client-side consumption only, so there's no need to provide any kind of marshalling support as we did with the other Model objects. And, here it is:

public class TaskWithNotes extends Task {

    private String notes = "";
     
    public TaskWithNotes(Task that) {
        super(that);
    }

    public void setNotes(String notes) {
        this.notes = notes;
    }
    
    public String getNotes() {
        return notes;
    }

    @Override
    public boolean equals(Object obj) {
        if (obj instanceof TaskWithNotes) {
            TaskWithNotes other = (TaskWithNotes)obj;
            if (!this.getNotes().equals(other.getNotes()))
                return false;
        }
        return super.equals(obj);
    }
    
    public Task asTask() {
        return new Task(this);
    }
}

The constructor initializes the Task base class from a given Task object. An equals() override is used by the Presenter to determine if the object has changed (is dirty).

Now, finally back to our TaskEditorView, which is responsible for setting the Task content into its various widgets. Here are the relevant bits of code:

    @Override
    public void setContent(TaskWithNotes content) {
        taskWithNotes = content;
        nameTextBox.setText(taskWithNotes.getName());
        doneCheckBox.setValue(taskWithNotes.isDone());
        notesTextArea.setHTML(taskWithNotes.getNotes());
        for (int index=0; index<priorityListBox.getItemCount(); ++index) {
            int v = Integer.parseInt(priorityListBox.getValue(index));
            if (v == taskWithNotes.getPriority()) {
                priorityListBox.setSelectedIndex(index);
                break;
            }
        }
        dueDatePicker.setValue(taskWithNotes.getDueDate(), true);
        dueDatePicker.setCurrentMonth(taskWithNotes.getDueDate());
    }

    @Override
    public TaskWithNotes getContent() {
        TaskWithNotes content = new TaskWithNotes(taskWithNotes);
        content.setName(nameTextBox.getText());
        content.setDone(doneCheckBox.getValue());
        content.setNotes(notesTextArea.getHTML());
        content.setPriority(Integer.parseInt(priorityListBox.getSelectedValue()));
        content.setDueDate(dueDatePicker.getValue());
        return content;
    }

The setContent() method saves the original TaskWithNotes object, for comparison with the (possibly) changed values, and then initializes the relevant widgets with the Task values.

The getContent() method reads the widget values and builds a TaskWithNotes object. This can be used to determine if anything has changed, and to serialize the changes to our VFS.

Returning Values

As before, the Editor still has the "OK" and "Cancel" buttons which are used to save/close the Editor. The question now is: how do we get the changed Task values back to the other Presenters/Views in our application? I've chosen to use a notification event, TaskChanged, which simply contains the updated Task object. The event is fired from the Task Editor if anything has changed. This is identical to the other event notification patterns we have already seen being used (TaskCreated, TaskDone, etc.)

The ProjectsPresenter and TasksPresenter Screens both listen for this event: ProjectsPresenter updates the Task in TasksRoot and serializes the entire tree to the "tasks.json" file. TasksPresenter simply updates its View to show the changed values.

Good things don't come easy...or do they?

Wow, that was a little more involved than I originally thought, but totally worth it. I'd be interested to hear if there are better ways to accomplish what I've hacked together here. Comments welcome!

2 comments: