Working With Project Resources
Project Resource - Common
As a general pattern, your actual definition of a resource class should ideally be stored in a common scope, so that it can be serialized and deserialzed on the Gateway and in the Designer. You should also define a ResourceType
that can be reused in dependent scopes. The ResourceType
is a tuple of your module's ID and a unique ID for the actual resource type.
Project Resource - Designer
In the Designer scope, you will register your resource workspace and a project browser node. It is generally recommended to subclass TabbedResourceWorkspace
to give end users the most familiar editing paradigm. TabbedResourceWorkspace
will automatically create instances of your ResourceEditor
subclass and manage bookkeeping for you.
Project Resource - Gateway
On the Gateway, we register a new ProjectLifecycleFactory
that will automatically handle the bookeeping for restarting out long-lived Gateway class whenever the project or project resources change.
Project Resource - Examples
The DesignerHook
and GatewayHook
are considered the "entry point" into any module in the Designer and Gateway scope, respectively.
public class DesignerHook extends AbstractDesignerModuleHook {
public static final Icon RESOURCE_ICON;
...
}
DesignerHook and Initialization
Upon selection and startup of a project in the Designer, each available module will initialize, calling into the DesignerHook
class. Within the DesignerHook
class, a workspace (which we will call PythonResourceWorkspace
) is constructed. Afterwards, PythonResourceWorkspace
is registered with the overall Designer context, allowing users to perform actions such as editing and building.
@Override
public void startup(DesignerContext context, LicenseState activationState) throws Exception {
this.context = context;
PythonResourceWorkspace workspace = new PythonResourceWorkspace(context);
context.registerResourceWorkspace(workspace);
BundleUtil.get().addBundle("pr", DesignerHook.class, "designer");
context.registerSearchProvider(new HandlerSearchProvider(context, workspace));
}
Working With Workspaces
We will first create our PythonResourceWorkspace
class, where we will call the TabbedResourceWorkspace
superclass and pass the context and DESCRIPTOR (ResourceDescriptor) to it.
...
public PythonResourceWorkspace(DesignerContext context) {
super(context, DESCRIPTOR);
}
...
The DESCRIPTOR tells the superclass that it is a workspace and provides meta information for the resourceType
, such as:
- The name of the resource workspace
- The icons to use
- Other information to help make the UI more presentable
public class PythonResourceWorkspace extends TabbedResourceWorkspace {
public static final ResourceDescriptor DESCRIPTOR = ResourceDescriptor.builder()
.resourceType(PythonResource.RESOURCE_TYPE)
.nounKey("pr.handler.noun")
.icon(DesignerHook.RESOURCE_ICON)
.rootFolderText("Custom Event Handlers")
.rootIcon(DesignerHook.RESOURCE_ICON)
.build();
...
}
Using the TabbedResourceWorkspace
superclass is recommended, as it is commonly used by muliple modules in the Designer. Some modules that use TabbedResourceWorkspace
are:
- Vision
- Perspective
- Reporting
Additionally, extending from the TabbedResourceWorkspace
superclass will help implement features and behaviors that users are accustomed to more easily, such as tabs to help navigate the workspace.
TabbedResourceWorkspace Method: Resource Editor
One of most important methods defined in our TabbedResourceWorkspace
superclass is newResourceEditor
. In the example below, we are creating a new resource editor for our PythonResource
. One of the parameters for newResourceEditor
is the ResourcePath
, which locates a resource based off its path.
@Override
protected ResourceEditor<PythonResource> newResourceEditor(ResourcePath resourcePath) {
return new PythonResourceEditor(this, resourcePath);
}
When given a new ResourcePath
, the ResourceWorkspace
will know that it needs to create a new ResourceEditor
, and call into our code to see what editor to use for our particular resource.
ResourcePath
itself is comprised of a resourceType and a path. The following items are examples of resourceType(s):
- Vision Windows
- Vision Templates
- Perspective Views
- Scripts
- Reports
Meanwhile, the path is where the resource is located, such as
//folderA/folderB/ResourceName
Resource Context Menu Actions
In our PythonResourceWorkspace
, you can use an addNewResourceActions
method to add actions to a resource's right-clicked context menu. In the example below, we are adding a single context item called NewPythonResourceAction
.
...
private static class NewPythonResourceAction extends NewResourceAction {
public NewPythonResourceAction(TabbedResourceWorkspace workspace, ResourceFolderNode folder) {
super(workspace, folder, defaultPythonResource());
}
...
}
...
@Override
public void addNewResourceActions(ResourceFolderNode resourceFolderNode, JPopupMenu jPopupMenu) {
jPopupMenu.add(new NewPythonResourceAction(this, resourceFolderNode));
}
...
Using the Vision module as an example, opening the context menu on a Vision Window will give options such as:
- Opening the window
- Renaming the window
- Copying the window's path
Implementing Designer Landing Pages for your Module
When opening modules in the Designer, you are greeted with a landing page. These landing pages contain different sections a user may find helpful, such as templates, recently modified resources, and creating a new resource. You can use the existing WorkspaceWelcomePanel
to set up your module's landing page.
...
@Override
protected Optional<JComponent> createWorkspaceHomeTab() {
return Optional.of(new WorkspaceWelcomePanel(
i18n("pr.resource.category"),
null,
null
) {
@Override
protected List<JComponent> createPanels() {
return List.of(
new ResourceBuilderPanel(
context,
i18n("pr.handler.noun"),
PythonResource.RESOURCE_TYPE.rootPath(),
List.of(
ResourceBuilderDelegate.build(defaultPythonResource())
),
PythonResourceWorkspace.this::open
),
new RecentlyModifiedTablePanel(
context,
PythonResource.RESOURCE_TYPE,
i18n("pr.handler.nouns"),
PythonResourceWorkspace.this::open
)
);
}
});
}
...
In the example above, we are using ResourceBuilderPanel
to allow users to create a new resource from the landing page. In a similar fashion, we are using RecentlyModifiedTablePanel
to allow users to open the most recently modified resources.
Modifying Immutable Project Resources
Since project resources are immutable, we can use the defaultPythonResource
function as a workaround to modify project resources.
...private static Consumer<ProjectResourceBuilder> defaultPythonResource() {
return PythonResource.toResource(
new PythonResource("\tpass", true)
);
}
...
The defaultPythonResource
function will return a function that accepts a ProjectResourceBuilder
. The ProjectResourceBuilder
is a modifiable version of your project resource, which you would make changes to, and then build to make a new immutable project resource. The end result will be a default empty resource for your workspace.
The Resource Editor
As briefly mentioned in the Working With Workspaces section, when you open resource nodes in the Project Browser, tabs will also open at the bottom of the workspace, allowing for easier navigation. Inside each tab is our resource editor class, which we are calling PythonResourceEditor
. This is where we will actually make changes to our resources, and more complex systems can exist here, such as:
- Perspective's JxBrowser
- Vision's Drag and Drop Functionality
- Vision's Component Palette
In addition, our PythonResourceEditor
extends from our base ResourceEditor
class.
...
public class PythonResourceEditor extends ResourceEditor<PythonResource> {
private ExtensionFunctionPanel extensionFunctionPanel;
private JCheckBox enabledCheckBox;
public PythonResourceEditor(PythonResourceWorkspace workspace, ResourcePath resourcePath) {
super(workspace, resourcePath);
}
...
}
We can use the following init
method as a resource editor to get our PythonResource
class, negating the need to convert a project resource to our PythonResource
class. We can also add more UI elements, such as
- Checkboxes
- A code editor
...
@Override
protected void init(PythonResource resource) {
removeAll();
setLayout(new MigLayout("ins 16, fill"));
enabledCheckBox = new JCheckBox(i18n("words.enabled"));
add(enabledCheckBox, "wrap");
extensionFunctionPanel = new ExtensionFunctionPanel(ExtensionFunctionPanel.GATEWAY_HINTS);
extensionFunctionPanel.setDescriptor(PythonResource.FUNCTION_DESCRIPTOR);
extensionFunctionPanel.setUserScript(resource.getUserCode());
add(extensionFunctionPanel, "push, grow");
}
...
Deserialization and Serialization
Deserialization and serialization can be thought of as inverse operations; however, they do not necessarily have to mirror the actions of the other. For example, the Perspective module contains thumbnails that gives users a preview of what the view looks like. In order to do this, we can use the getObjectForSave
method and serializeResource
function to store a thumbnail.png
file, along with the byte array that represents the .png file.
...
@Override
protected PythonResource getObjectForSave() {
return new PythonResource(extensionFunctionPanel.getUserScript(), enabledCheckBox.isSelected());
}
@Override
protected void serializeResource(ProjectResourceBuilder builder, PythonResource object) {
PythonResource.toResource(object).accept(builder);
builder.putData( name "thumbnail.png", data)
}
...
On the other hand, the thumbnail itself isn't necessary for Perspective when it is deserializing. Using this knowledge, we can use getObjectForSave
and serializeResource
to store one-way configuration data that is not needed when deserializing.
In general, the example above follows the pattern below:
- Start with the initial state of our project resource that contains information such as where it is lcoated and which project it belongs to.
- Convert the project resource into a mutable object (
ProjectResourceBuilder
). - In our
PythonResourceEditor
, we will call into theserializeResource
function. - Perform any actions or changes you need on
ProjectResourceBuilder
. - Serialize our object (
PythonResource
).
See the Using our Custom Class section to learn more about how we modify the contents in our builder using the toResource
and fromResource
classes.
The deserialize method in your ResourceEditor
will take a common Ignition ProjectResource
class and translate it into the custom Java class we want to use for PythonResource
.
...
@Override
protected PythonResource deserialize(ProjectResource resource) {
return PythonResource.fromResource(resource);
}
...
On the backend, the process is as follows:
deserialize
takes the data and .json file that exists on disk on the Gateway.- The Gateway brings in the data and .json file and stores it so that it knows the information is part of the project.
- The user opens the resource in the Designer to attempt to edit the resource.
- The Designer uses the resource path from the Project Browser and gets the project resource at the specified path.
- The Designer will call into our workspace, at which point our workspace will get the editor for the specified path.
- The editor will use our code to convert the data and .json file into our
PythonResource
class.
The ProjectResource
class itself is comprised of a manifest and at least one data file. Using this, you can get information such as data keys or attributes, using the getDataKeys
or getAttributes
functions, respectively.
Using our Custom Class
To deserialize
or serialize
our PythonResource
object, we can use the fromResource
and toResource
classes, respectively.
Similar to how deserialize
and serialize
can be thought of as inverse operations, fromResource
and toResource
can also be considered inverse operations. The example below uses fromResource
to take a ProjectResource
and return PythonResource
. In other words, we are constructing our module-specific class from an Ignition general-purpose class.
...
public static PythonResource fromResource(ProjectResource resource) {
String code = new String(
Objects.requireNonNull(resource.getData(RESOURCE_FILE)),
StandardCharsets.UTF_8
);
boolean isEnabled = resource.getAttribute("enabled")
.map(JsonElement::getAsBoolean)
.orElse(true);
return new PythonResource(code, isEnabled);
}
...
On the opposite end, the example below uses toResource
in our ProjectResourceBuilder
to modify our ProjectResource
:
...
public static Consumer<ProjectResourceBuilder> toResource(@NotNull PythonResource resource) {
return builder -> builder
.putAttribute("enabled", resource.enabled)
.putData(
RESOURCE_FILE,
resource.getUserCode().getBytes(StandardCharsets.UTF_8)
);
}
...
The data that we retrieve using fromResource
, along with the data we put using toResource
comes from and goes to a file called code.py
. The code.py
file is located in your Ignition directory > data > projects > (your project) > (your module ID) > (your resource type ID) > (your resource path).
Besides the code.py
file, there is also a resource.json
file. Within the resource.json
file, we can find the "enabled" attribute, along with a reference to the code.py
file. The resource.json
is, in essence, the stored representation of our attributes, while code.py
contains the actual code we want to use when deserializing our data.
In summary, the fromResource
and toResource
methods are how we can go from a general Ignition resource that is located on disk, to our PythonResource
, and vice versa.
While we are storing a simple string and a single attribute saying whether the resource is "enabled" or "disabled" in our PythonResource
example, other existing subsystems store different types of data. For example, Perspective can store long.json files, Vision windows can store xml data, and WebDev Python resources can be stored as many separate files. This is possible because ProjectResource
is flexible enough to store files dynamically (many files versus a singular file).