17. Relationships

Punch List App: Next Steps

Let's look at further requirements for our Punch List app:

App Design: Construction Punch List

When more and more punches are captured in the punch list app, it will become problematic to browse all the punches in a single list. We want to allow users to organize punches into categories, and then to filter the lists of punches by category.

Relationships

Therefore, we want to make it possible for categories to be created in our database, and then each punch list item must be assigned to a particular category. In order to implement this functionality, we'll make use of Relationships in our Data Model. Relationships allow us to define how our different object types relate to each other. If we create a new Category object type, we'll say that each category has many punch list items that belong to that category. This is called a one-to-many relationship.

Add a New Object Type

Let's head over to the Data Model workspace and update the schema.xml to include a new Category model. This model will only have one field for now, a field that will store the 'name' of the category - we will also use this field as the default display value. Like this.

<model name="category" label="Category">
    <field name="name" label="Name" type="text" />
    
    <display>{name}</display>
</model>

Add a New Relationship

Next, we'll create the one-to-many relationship between the two object types. This is done by adding a belongs-to tag to the Item object type, and a has-many to the Category object type as shown below. Relationship tags should be listed after all the fields in a model but before the <display> tag. Note that the name="" property on the has-many allows you to specify a name for the whole collection of items that belong to a category. Using the pluralized form of the object type (in this case, "items") is a good convention to use for the collection name. On the belongs-to side you can also specify a name="" for the relationship, but if you don't the system will default the name to the name of the associated model - in our case category.

<model name="item" label="Item">
    <field name="comments" label="Comments" type="text" />
    <field name="photo" label="Photo" type="photo" />
    <field name="status" label="Status" type="single-choice">
        <option key="Open">Open</option>
        <option key="Closed">Closed</option>
    </field>
    <field name="gps_location" label="Gps Location" type="location" />
    <field name="created_at" label="Created At" type="datetime" />

    <belongs-to model="category" />
    <display>{status} - {comments}</display>
</model>

<model name="category" label="Category">
    <field name="name" label="Name" type="text" />
    
    <has-many model="item" name="items" />
    <display>{name}</display>
</model>

Your ERD should also visualize these changes, like this.

While we are at it, let's also add a relationship between user and item to signify which user created which punch items. To do this we add another belongs-to tag on our item model, this time pointing to the user model, and we add the has-many tag to the user model. For this belongs-to relationship let's not use the system default name, and instead specify it as name="created_by". At this point your schema.xml file should look like this.

schema.xml
<?xml version="1.0" encoding="UTF-8"?>
<data-model>
    <!-- Do not remove this - it is used to store information about your app's users: -->
    <model name="user" label="User">
        <field name="name" label="Name" type="text:name" />

        <has-many model="item" name="items" />
        <display>{name}</display>
    </model>

    <!-- Used for Push Notifications - to send a push notification, create a 'push_notification' object -->
    <!--                               and specify the recipient in the 'belongs to user' relationship -->
    <!-- For more details, refer to: http://resources.journeyapps.com/v4/push-notifications -->
    <model name="push_notification" label="Push Notification">
        <field name="message" label="Message" type="text" />
        <field name="received_at" label="Received At" type="datetime" />
        <field name="created_at" label="Created At" type="datetime" />

        <belongs-to model="user" />
        <display>{message} ({user})</display>
        <notify-user message="{message}" received-field="received_at" recipient-field="user" />
    </model>

    <!-- ADD MODELS HERE: -->
    
    <model name="item" label="Item">
        <field name="comments" label="Comments" type="text" />
        <field name="photo" label="Photo" type="photo" />
        <field name="status" label="Status" type="single-choice">
            <option key="Open">Open</option>
            <option key="Closed">Closed</option>
        </field>
        <field name="gps_location" label="Gps Location" type="location" />
        <field name="created_at" label="Created At" type="datetime" />

        <belongs-to model="category" />
        <belongs-to model="user" name="created_by" />
        <display>{status} - {comments}</display>
    </model>
    
    <model name="category" label="Category">
        <field name="name" label="Name" type="text" />
        
        <has-many model="item" name="items" />
        <display>{name}</display>
    </model>
</data-model>

See Changes in Data Browser

If you go to the Data Browser for the "Testing" environment, you'll notice that there is a new Category object type shown on the left, and that clicking on an Item shows a Belongs to Category field and a Belongs to User - created_by field at the bottom (if your Data Browser was already open in a different tab then you will need to first refresh that browser tab before you will see the changes):

Click on the Category tab on the left and let's create a couple of categories:

Set Category on "Add New Item" Screen

Now let's add the category selection to the Add New Item view. Under the "Variables go here" section of the add_new.view.xml file, add a new variable called all_categories which will be of type query:category. Like this.

<!-- Variables go here: -->
<var name="item" type="item" />
<var name="all_categories" type="query:category" />

Then change the init() function to populate the categories using a Query and also populate the relationship to the user model. To set the belongs-to relationship in JS we use a helper method that is created for us on the model in question, this method will use the value of name that is specified in the in the Data Model in the <belongs-to> tag. So in our case, we specified the relationship as <belongs-to model="user" name="created_by" /> and so we will use the created_by method to set the relationship to the user model, like this.

// This function is called when the app navigates to this view (using a link)
function init() {
    // initialize any data here that should be available when the view is shown
    view.all_categories = DB.category.all().orderBy("name");
    view.item = DB.item.create();
    view.item.status = "Open";
    view.item.created_at = new Date();
    view.item.created_by(user);
}

Finally, add a <object-dropdown> View Component, above the <text-input> component, allowing the user to select the category. Note that:

  • We specify query="all_categories" so that the list will show all the categories that we've pulled using our Query.

  • We specify bind="item.category" so that the category that the user selects will be stored in the category relationship on our item variable.

<?xml version="1.0" encoding="UTF-8"?>
<view title="Add New Punch List Item">
    <!-- Parameters go here: -->

    <!-- Variables go here: -->
    <var name="item" type="item" />
    <var name="all_categories" type="query:category" />

    <!-- Components go here: -->

    <columns>
        <column>
            <capture-photo bind="item.photo" required="true" />
        </column>
        <column>
            <object-dropdown query="all_categories" bind="item.category" label="Category" empty-message="Your items will appear here" required="false" />
            <text-input bind="item.comments" required="true" />
            <button label="Save Item" on-press="saveItem" validate="true" style="solid" />
        </column>
    </columns>
    <capture-coordinates bind="item.gps_location" />
</view>

Test on Mobile Device

At "Add New Item", you should now see a drop-down list to select a category:

View Relationship on App Backend Data Browser

After you've added a new item, if you open up the Data Browser for the "Testing" environment again, you'll see "Belongs to Category" for the item shows the category that you've selected. Go ahead and update the other punch items and make each of them belong-to a category as well. Like this:

Display Item Category on "Main" and "View Punch Item"

Let's update the list component on our Main view to display the category inside of the <content> tag, and while we are at it, let's also add the created_at timestamp in the <footer> tag. So, in your main.view.xml, update the list component like this.

<list empty-message="Your items will appear here">
    <list-item query="open_punches">
        <header>{comments}</header>
        <content>{category}</content>
        <footer>{created_at}</footer>
        <accent label="{status}" color="info" />
        <asset src="{photo}" />
        <action on-press="$:selectItem($selection)" />
    </list-item>
</list>

If we want to show the category on the View punch Item view, we could adjust our table component there as follows:

<info-table>
    <row label="Item Comments" bind="item.comments" />
    <row label="Category" bind="item.category" />
    <row label="Item Status" bind="item.status" />
    <row label="Current Time" value="{$:showTime()}" />
</info-table>

Test on Device

Enable Filtering by Category

Lastly, we want the user to be able to browse punch items by category. We will do this in a new view called browse_items, but first let's add a button on our Main view to allow the user to navigate to the Browse Items view. So, on your main.view.xml, add a new button to the <button-group> below the 'Add New Item' button, and hook it up like this:

<button-group>
    <button label="Add New Item" icon="fa-plus" on-press="goToNew" validate="false" style="solid" />
    <button label="Browse by Category" on-press="$:navigate.link('browse_items')" icon="fa-filter" validate="false" style="solid" />
</button-group>

You will notice we have included the necessary JS navigation logic directly in the on-press attribute by using the $: notation to execute raw JS from our View XML.

Next, let's create a new View with the name browse_items (remember, you can create views using the command palette, ctrl+shift+p / cmd+shift+p to open the command palette and then start typing create and choose the Create view action), and then once created update the <view title="Browse Punch Items by Category"> as before.

Now we can add some components to our new view. Firstly, we are going to need to present the user with a list of all categories (we will need a query for this), let the user select a category (we will need a category variable for this), and then show the user all the punch items that belong to the selected category (we will need another query for this). So, in your newly created browse_items.view.xml specify all these variables, like this.

<?xml version="1.0" encoding="UTF-8"?>
<view title="Browse Punch Items by Category">
    <!-- Parameters go here: -->

    <!-- Variables go here: -->
    <var name="all_categories" type="query:category" />
    <var name="selected_category" type="category" />
    <var name="filtered_items" type="query:item" />

    <!-- Components go here: -->

</view>

Now let's add the necessary view components. We want to display all the the categories to the user and then once they select one, show all the related punch items for the selected category. A nice way to do this is to make use of the JourneyApps show-if and hide-if logic available to any view component (syntax reference for show-if and hide-if available here). We will display an <object-list> for all the categories but once the user selects one we will hide the <object-list> and display a <list> of related items instead (re-using the list we defined on our main view). So, in your browse_items.view.xml, add the following code:

<!-- Components go here: -->
<object-list hide-if="selected_category" query="all_categories" bind="selected_category" label="Please select a category to view related punch items" empty-message="Your items will appear here" required="false" />

<heading show-if="selected_category">{selected_category.name}: Open Punch Items</heading>
<list show-if="selected_category" empty-message="Your items will appear here">
    <list-item query="filtered_items">
        <header>{comments}</header>
        <content>{category}</content>
        <footer>{created_at}</footer>
        <accent label="{status}" color="info" />
        <asset src="{photo}" />
        <action on-press="$:selectItem($selection)" />
    </list-item>
</list>

Next we need to add a way for the user to clear their selection (we will use a button for that - using show-if to only display the button if the user has already made a selection). After that we also need to populate the various queries from our JS. Populating the all_categories variable can be done from the init() function, but we can only populate filtered_items once the user makes a selection, and we need to re-populate it every time the user makes a new selection. To do this we will make use of the on-change="" attribute of our <object-list> component. (Note, the on-change="" attribute is available on all input components, but note that for capture-photo and scan-barcode it is referenced as on-capture and on-scan instead). So, update your browse_items.view.xml to the following:

<?xml version="1.0" encoding="UTF-8"?>
<view title="Browse Punch Items by Category">
    <!-- Parameters go here: -->

    <!-- Variables go here: -->
    <var name="all_categories" type="query:category" />
    <var name="selected_category" type="category" />
    <var name="filtered_items" type="query:item" />

    <!-- Components go here: -->
    <object-list on-change="filterCategories" hide-if="selected_category" query="all_categories" bind="selected_category" label="Please select a category to view related punch items" empty-message="Your items will appear here" required="false" />

    <heading show-if="selected_category">{selected_category.name}: Open Punch Items</heading>
    <list show-if="selected_category" empty-message="Your items will appear here">
        <list-item query="filtered_items">
            <header>{comments}</header>
            <content>{category}</content>
            <footer>{created_at}</footer>
            <accent label="{status}" color="info" />
            <asset src="{photo}" />
            <action on-press="$:selectItem($selection)" />
        </list-item>
    </list>

    <button-group>
        <button label="Back" on-press="dismiss" icon="fa-arrow-left" validate="false" style="outline" />
        <button show-if="selected_category" label="Clear Selection" on-press="clearSelection" icon="fa-times" validate="false" style="solid" />
    </button-group>
</view>

Let's update our browse_items.js file next to populate our queries and handle the on-change event, as well as the clearSelection and selectItem function calls (let's reuse the selectItem functions from the main view). Like this

browse_items.js
// This function is called when the app navigates to this view (using a link)
function init() {
    // initialize any data here that should be available when the view is shown
    view.all_categories = DB.category.all().orderBy("name");
}

// This function is called when the user returns to this view from another view
function resume(from) {
    // from.back       (true/false) if true, the user pressed the "Back" button to return to this view
    // from.dismissed  (true/false) if true, the app dismissed to return to this view
    // from.path       contains the path of the view that the user returned from
    // if any data needs to be refreshed when the user returns to this view, you can do that here:
}

function filterCategories(selectedCategory) {
    if (selectedCategory) { // we add the null check to catch the situation where the user 'clears' their selection - which will trigger the on-change
        view.filtered_items = selectedCategory.items;
    }
}

function clearSelection() {
    view.selected_category = null;
}

function selectItem(selectedItem) {
    var userOptions = ["View Item Details", "Delete Selected Item"];
    var userSelection = optionList(userOptions);
    if (userSelection === 0) { // user selected 'View Item Details'
        viewItem(selectedItem);
    } else if (userSelection === 1) { // user selected 'Delete Selected Item'
        deleteItem(selectedItem);
    } else { // user cancelled or clicked away
        return; // exit the function pro-actively
    }
}

function viewItem(item) {
    navigate.link('view_item', item);
}

function deleteItem(item) {
    if (confirmDialog("Delete Item?", "Are you sure?", "YES", "NO")) {
        item.destroy();
    }
}

Test on Device

Let's test these changes. Your app should now look like this:

Well done, you have effectively completed the Punch List App for the scope we originally envisioned. A user can view open punches, capture new punches, view details for existing punches and mark them as closed. Next up we are going to add a new user role to our app, an App Admin, that should be able to easily manage all the data in the application.

Last updated