Card Components#

Beginning in Arches 4.3, Cards are rendered using Card Components, allowing them to be composed and nested arbitrarily in various contexts within the Arches UI. Arches comes with a default Card Component that should suit most needs, but you can also create and register custom Card Components to extend the front-end behavior of Arches.

Before exploring how do make customized Cards, please review documentation about available Card Types standard with Arches.

Developing Card Components is very similar to developing Widgets. A Card Component consists of a Django template and Knockout.js JavaScript file. To register your component, you’ll also need a JSON file specifying its initial configuration.

To develop your new card, you’ll place files like so in your project:

project_name/templates/views/components/cards/my-new-card.htm project_name/media/js/views/components/cards/my-new-card.js

To register and configure the Component, you’ll need a JSON configuration file:

project_name/cards/my-new-card.json

Creating a Card Component#

The default template and Knockout files illustrate everything a Card Component needs, and you’ll be extending this functionality. Your template will provide conditional markup for various contexts (‘editor-tree’, ‘designer-tree’, ‘permissions-tree’, ‘form’, and ‘report’), render all the card’s Widgets, and display other information.

Here’s the template for the default Card Component:

{% load i18n %}
<!-- ko foreach: { data: [$data], as: 'self' } -->

<!-- ko if: state === 'editor-tree' -->
<li role="treeitem card-treeitem" class="jstree-node" data-bind="css: {'jstree-open': (card.tiles().length > 0 && card.expanded()), 'jstree-closed' : (card.tiles().length > 0 && !card.expanded()), 'jstree-leaf': card.tiles().length === 0}, scrollTo: card.scrollTo, container: '.resource-editor-tree'">
    <i class="jstree-icon jstree-ocl" role="presentation" data-bind="click: function(){card.expanded(!card.expanded())}"></i>
    <a class="jstree-anchor" href="#" tabindex="-1" data-bind="css:{'filtered': card.highlight(), 'jstree-clicked': card.selected, 'child-selected': card.isChildSelected()}, click: function () { card.canAdd() ? card.selected(true) : card.tiles()[0].selected(true) },">
        <i class="fa fa-file-o" role="presentation" data-bind="css:{'filtered': card.highlight(), 'has-provisional-edits fa-file': card.doesChildHaveProvisionalEdits()}"></i>
        <span style="padding-right: 5px;" data-bind="text: card.model.name"></span>
        <!-- ko if: card.canAdd() -->
        <i class="fa fa-plus-circle add-new-tile" role="presentation" data-bind="css:{'jstree-clicked': card.selected}" data-toggle="tooltip" data-original-title="{% trans "Add New" %}"></i>
        <!-- /ko -->
    </a>
    <ul class="jstree-children" aria-expanded="true">
        <div data-bind="sortable: {
            data: card.tiles,
            beforeMove: self.beforeMove,
            afterMove: card.reorderTiles
        }">
            <li role="treeitem" class="jstree-node" data-bind="css: {'jstree-open': (cards.length > 0 && expanded), 'jstree-closed' : (cards.length > 0 && !expanded()), 'jstree-leaf': cards.length === 0}">
                <i class="jstree-icon jstree-ocl" role="presentation" data-bind="click: function(){expanded(!expanded())}"></i>
                <a class="jstree-anchor" href="#" tabindex="-1" data-bind="click: function () { self.form.selection($data) }, css:{'jstree-clicked': selected, 'child-selected': isChildSelected(), 'filtered-leaf': card.highlight()}">
                    <i class="fa fa-file" role="presentation" data-bind="css:{'has-provisional-edits': doesChildHaveProvisionalEdits() || $data.hasprovisionaledits()}"></i>
                    <strong style="margin-right: 10px;">
                        <!-- ko if: card.widgets().length > 0 -->
                        <span data-bind="text: card.widgets()[0].label || card.model.name"></span>:
                        <div style="display: inline;" data-bind="component: {
                            name: self.form.widgetLookup[card.widgets()[0].widget_id()].name,
                            params: {
                                tile: $data,
                                node: self.form.nodeLookup[card.widgets()[0].node_id()],
                                config: self.form.widgetLookup[card.widgets()[0].widget_id()].config,
                                label: self.form.widgetLookup[card.widgets()[0].widget_id()].label,
                                value: $data.data[card.widgets()[0].node_id()],
                                type: 'resource-editor',
                                state: 'display_value'
                            }
                        }"></div>
                        <!-- /ko -->
                        <!-- ko if: card.widgets().length === 0 -->
                        <span data-bind="text: card.model.name"></span>
                        <!-- /ko -->
                    </strong>
                </a>
                <!-- ko if: cards.length > 0 -->
                <ul class="jstree-children" aria-expanded="true" data-bind="foreach: {
                        data: cards,
                        as: 'card'
                    }">
                    <!-- ko component: {
                        name: self.form.cardComponentLookup[card.model.component_id()].componentname,
                        params: {
                            state: 'editor-tree',
                            card: card,
                            tile: null,
                            loading: self.loading,
                            form: self.form
                        }
                    } --> <!-- /ko -->
                </ul>
                <!-- /ko -->
            </li>
        </div>
        <!-- /ko -->
    </ul>
</li>
<!-- /ko -->

<!-- ko if: state === 'designer-tree' -->
<li role="treeitem card-treeitem" class="jstree-node" data-bind="css: {'jstree-open': ((card.cards().length > 0 || card.widgets().length > 0) && card.expanded()), 'jstree-closed' : ((card.cards().length > 0 || card.widgets().length > 0) && !card.expanded()), 'jstree-leaf': card.cards().length === 0 && card.widgets().length === 0}, scrollTo: card.scrollTo, container: '.designer-card-tree'">
    <i class="jstree-icon jstree-ocl" role="presentation" data-bind="click: function(){card.expanded(!card.expanded())}"></i>
    <a class="jstree-anchor" href="#" tabindex="-1" data-bind="css:{'filtered': card.highlight(), 'jstree-clicked': card.selected, 'child-selected': card.isChildSelected()}, click: function () { card.selected(true) },">
        <i class="fa fa-file-o" role="presentation"></i>
        <span style="padding-right: 5px;" data-bind="text: card.model.name"></span>
    </a>
    <!-- ko if: card.cards().length > 0 || card.widgets().length > 0 -->
    <ul class="jstree-children card-designer-tree" aria-expanded="true">
        <div data-bind="sortable: {
                data: card.widgets,
                as: 'widget',
                beforeMove: self.beforeMove,
                afterMove: function() { card.model.save() }
            }">
            <li role="treeitem" class="jstree-node jstree-leaf" data-bind="css: {
                    'jstree-last': $index() === (card.widgets().length - 1) && card.cards().length === 0
                }">
                <i class="jstree-icon jstree-ocl" role="presentation"></i>
                <a class="jstree-anchor" href="#" tabindex="-1" data-bind="click: function() { widget.selected(true) }, css:{'jstree-clicked': widget.selected, 'hover': widget.hovered}, event: { mouseover: function(){ widget.hovered(true) }, mouseout: function(){ widget.hovered(null) } }">
                    <i data-bind="css: widget.datatype.iconclass" role="presentation"></i>
                    <strong style="margin-right: 10px;" >
                        <span data-bind="text: !!(widget.label()) ? widget.label() : widget.node.name"></span>
                    </strong>
                </a>
            </li>
        </div>
        <div data-bind="sortable: {
                data: card.cards,
                as: 'childCard',
                beforeMove: self.beforeMove,
                afterMove: function() {
                    card.reorderCards();
                }
            }">
            <div data-bind="css: {
                    'jstree-last': ($index() === (card.cards().length - 1))
                }">
                <!-- ko component: {
                        name: self.form.cardComponentLookup[childCard.model.component_id()].componentname,
                        params: {
                        state: 'designer-tree',
                        card: childCard,
                        tile: null,
                        loading: self.loading,
                        form: self.form
                    }
                } --> <!-- /ko -->
            </div>
        </div>
    </ul>
    <!-- /ko -->
</li>
<!-- /ko -->


<!-- ko if: state === 'permissions-tree' -->
<li role="treeitem card-treeitem" class="jstree-node" data-bind="css: {'jstree-open': ((card.cards().length > 0 || card.widgets().length > 0) && card.expanded()), 'jstree-closed' : ((card.cards().length > 0 || card.widgets().length > 0) && !card.expanded()), 'jstree-leaf': card.cards().length === 0 && card.widgets().length === 0}">
    <i class="jstree-icon jstree-ocl" role="presentation" data-bind="click: function(){card.expanded(!card.expanded())}"></i>
    <a class="jstree-anchor permissions-card" href="#" tabindex="-1" data-bind="css:{'jstree-clicked': card.selected, 'child-selected': card.isChildSelected()}, click: function () { card.selected(true) },">
        <i class="fa fa-file-o" role="presentation"></i>
        <span style="padding-right: 5px;" data-bind="text: card.model.name, css:{'filtered': card.highlight()}">
        </span>
        <span class="node-permissions">
            <!--ko if: card.perms -->
            <!-- ko foreach: card.perms() -->
            <i class="node-permission-icon" data-bind="css: $data.icon"></i>
            <!-- /ko -->
            <!-- /ko -->
        </span>
    </a>
    <!-- ko if: card.cards().length > 0 || card.widgets().length > 0 -->
    <ul class="jstree-children" aria-expanded="true">
        <div data-bind="sortable: {
                data: card.widgets,
                as: 'widget',
                beforeMove: self.beforeMove,
                afterMove: function() { card.model.save() }
            }">
            <li role="treeitem" class="jstree-node jstree-leaf" data-bind="css: {
                    'jstree-last': $index() === (card.widgets().length - 1) && card.cards().length === 0
                }">
                <i class="jstree-icon jstree-ocl" role="presentation"></i>
                <a class="jstree-anchor permissions-widget" href="#" tabindex="-1">
                    <i class="fa fa-file" role="presentation" ></i>
                    <strong style="margin-right: 10px;" >
                        <span data-bind="text: !!(widget.label()) ? widget.label() : widget.node.name"></span>
                    </strong>
                </a>
            </li>
        </div>
        <div data-bind="foreach: {
                data: card.cards,
                as: 'card'
            }">
            <!-- ko component: {
                name: self.form.cardComponentLookup[card.model.component_id()].componentname,
                params: {
                    state: 'permissions-tree',
                    card: card,
                    tile: null,
                    loading: self.loading,
                    form: self.form,
                    multiselect: true
                }
            } --> <!-- /ko -->
        </div>
    </ul>
    <!-- /ko -->
</li>
<!-- /ko -->


<!-- ko if: state === 'form' -->
<div class="card-component">

    <!--ko if: reviewer && provisionalTileViewModel.selectedProvisionalEdit() -->
    <div class="edit-message-container">
        <span>{% trans 'Currently showing edits by' %}</span>
        <span class="edit-message-container-user" data-bind="text: provisionalTileViewModel.selectedProvisionalEdit().username() + '.'"></span>
        <!--ko if: !provisionalTileViewModel.tileIsFullyProvisional() -->
        <a class="reset-authoritative" href='' data-bind="click: function(){provisionalTileViewModel.resetAuthoritative();}">{% trans 'Return to approved edits' %}</a>
        <!--/ko-->
        <!--ko if: provisionalTileViewModel.selectedProvisionalEdit().isfullyprovisional -->
        <span>{% trans ' This is a new contribution by a provisional editor.' %}</span>
        <!--/ko-->
    </div>
    <!--/ko-->

    <!--ko if: reviewer && provisionalTileViewModel.provisionaledits().length > 0 && !provisionalTileViewModel.selectedProvisionalEdit()-->
    <div class="edit-message-container approved">
        <div>{% trans 'Currently showing the most recent approved edits' %}</div>
    </div>
    <!--/ko-->



    <div class="new-provisional-edit-card-container">
        <!--ko if: reviewer && provisionalTileViewModel.provisionaledits().length > 0 -->
        <!--ko if: !provisionalTileViewModel.tileIsFullyProvisional() -->
        <div class='new-provisional-edits-list'>
            <div class='new-provisional-edits-header'>
                <div class='new-provisional-edits-title'>{% trans 'Provisional Edits' %}</div>
                <div class="btn btn-shim btn-danger btn-labeled btn-xs fa fa-trash new-provisional-edits-delete-all" style="padding: 3px;" data-bind="click: function(){provisionalTileViewModel.deleteAllProvisionalEdits()}">{% trans 'Delete all edits' %}</div>
            </div>
            <!--ko foreach: { data: provisionalTileViewModel.provisionaledits(), as: 'pe' } -->
            <div class='new-provisional-edit-entry' data-bind="css: {'selected': pe === $parent.provisionalTileViewModel.selectedProvisionalEdit()}, click: function(){$parent.provisionalTileViewModel.selectProvisionalEdit(pe)}">
                <div class='title'>
                    <div class='field'>
                        <span data-bind="text : pe.username"></span>
                    </div>
                    <a href='' class='field fa fa-times-circle new-delete-provisional-edit' data-bind="click : function(){$parent.provisionalTileViewModel.rejectProvisionalEdit(pe)}"></a>
                </div>
                <div class="field timestamp">
                    <span data-bind="text : pe.displaydate">@</span>
                    <span data-bind="text : pe.displaytimestamp"></span>
                </div>
            </div>
            <!-- /ko -->
        </div>
        <!--/ko-->
        <!--/ko-->


        <div class="card">

            <h4 data-bind="text: card.model.name"></h4>
            <h5 data-bind="text: card.model.instructions"></h5>

            <!-- ko if: card.widgets().length > 0 -->
            <form class="widgets" style="margin-bottom: 20px;">
                <div data-bind="foreach: {
                        data:card.widgets, as: 'widget'
                    }">
                    <div data-bind='component: {
                        name: self.form.widgetLookup[widget.widget_id()].name,
                        params: {
                            formData: self.tile.formData,
                            tile: self.tile,
                            form: self.form,
                            config: widget.configJSON,
                            label: widget.label(),
                            value: self.tile.data[widget.node_id()],
                            node: self.form.nodeLookup[widget.node_id()],
                            expanded: self.expanded,
                            graph: self.form.graph,
                            type: "resource-editor"
                        }
                    }, css:{ "active": widget.selected, "hover": widget.hovered, "widget-preview": self.preview
                }, click: function(data, e) { if (!widget.selected() && self.preview) {widget.selected(true);}
            }, event: { mouseover: function(){ if (self.preview){widget.hovered(true) } }, mouseout: function(){ if (self.preview){widget.hovered(null)} } }'></div>
                </div>
            </form>
            <!-- /ko -->
            <!-- ko if: card.widgets().length === 0 -->
            <ul class="card-summary-section" data-bind="css: {disabled: !tile.tileid}">
                <!-- ko foreach: { data: tile.cards, as: 'card' } -->
                <li class="card-summary">
                    <a href="javascript:void(0)" data-bind="click: function () {
                        if (card.parent.tileid) {
                            card.canAdd() ? card.selected(true) : card.tiles()[0].selected(true);
                        }
                    }">
                        <h4 class="card-summary-name">
                            <span data-bind="text: card.model.name"></span>
                            <!-- ko if: card.canAdd() && card.parent.tileid -->
                            <i class="fa fa-plus-circle card-summary-add"></i>
                            <!-- /ko -->
                        </h4>
                    </a>
                    <ul class="tile-summary-item" data-bind="foreach: {
                            data: card.tiles,
                            as: 'tile'
                        }">
                        <li class="tile-summary">
                            <a href="#" data-bind="click: function () { tile.selected(true) }">
                                <!-- ko if: card.widgets().length > 0 -->
                                <span data-bind="text: card.widgets()[0].label || card.model.name" class="tile-summary-label"></span>:
                                <div style="display: inline;" data-bind="component: {
                                    name: self.form.widgetLookup[card.widgets()[0].widget_id()].name,
                                    params: {
                                        tile: tile,
                                        node: self.form.nodeLookup[card.widgets()[0].node_id()],
                                        config: self.form.widgetLookup[card.widgets()[0].widget_id()].config,
                                        label: self.form.widgetLookup[card.widgets()[0].widget_id()].label,
                                        value: tile.data[card.widgets()[0].node_id()],
                                        type: 'resource-editor',
                                        state: 'display_value'
                                    }
                                }"></div>
                                <!-- /ko -->
                                <!-- ko if: card.widgets().length === 0 -->
                                <span data-bind="text: card.model.name"></span>
                                <!-- /ko -->
                            </a>
                        </li>
                    </ul>
                </li>
                <!-- /ko -->
            </ul>
            <!-- /ko -->
            <div class="install-buttons">
                <!-- ko if: tile.tileid -->
                <button class="btn btn-shim btn-warning btn-labeled btn-lg fa fa-trash" data-bind="click: function () { self.form.deleteTile(tile); }">{% trans 'Delete this record' %}</button>
                <!-- /ko -->
                <!-- ko if: tile.dirty() -->
                <!-- ko if: provisionalTileViewModel && !provisionalTileViewModel.tileIsFullyProvisional() -->
                <button class="btn btn-shim btn-danger btn-labeled btn-lg fa fa-times" data-bind="click: tile.reset">{% trans 'Cancel edit' %}</button>
                <!-- /ko -->
                    <!-- ko if: tile.tileid -->
                    <button class="btn btn-shim btn-mint btn-labeled btn-lg fa fa-plus" data-bind="click: function () { self.form.saveTile(tile); }">{% trans 'Save edit' %}</button>
                    <!-- /ko -->
                <!-- /ko -->
                <!-- ko if: !tile.tileid -->
                <button class="btn btn-shim btn-mint btn-labeled btn-lg fa fa-plus" data-bind="click: function () { self.form.saveTile(tile); }">{% trans 'Add'  %}</button>
                <!-- /ko -->
            </div>

        </div>
    </div>
</div>
<!-- /ko -->

<!-- ko if: state === 'report' -->
<div class="rp-card-section">
    <span class="rp-tile-title" data-bind="text: card.model.get('name')"></span>
    <!-- ko foreach: { data: card.tiles, as: 'tile' } -->
        <div class="rp-card-section">
            <!-- ko if: card.model.get('widgets')().length > 0 -->
                <div class="rp-report-tile" data-bind="attr: { id: tile.tileid }">
                    <dl class="dl-horizontal">
                        <!-- ko foreach: { data: card.model.get('widgets'), as: 'widget' } -->
                            <!-- ko component: {
                                name: widget.widgetLookup[widget.get("widget_id")()].name,
                                params: {
                                    config: configJSON,
                                    label: widget.get("label")(),
                                    node: widget.node,
                                    value: tile.data[widget.node.nodeid],
                                    state: "report"
                                }
                            } --><!-- /ko -->
                        <!-- /ko -->
                    </dl>
                </div>
            <!-- /ko -->

            <div class="rp-report-container-tile" data-bind="visible: card.cards().length > 0">
                <!-- ko foreach: { data: tile.cards, as: 'card' } -->
                        <!-- ko component: {
                            name: card.model.cardComponentLookup[card.model.component_id()].componentname,
                            params: {
                                state: 'report',
                                card: card
                            }
                        } --> <!-- /ko -->
                <!-- /ko -->
            </div>
        </div>
    <!-- /ko -->

    <!-- ko if: card.tiles().length === 0 -->
    <div class="row rp-report-tile rp-no-data">
        <!-- ko ifnot: card.model.get('cardid') -->
        {% trans "Sorry, you don't have access to this information" %}
        <!-- /ko -->
        <!-- ko if: card.model.get('cardid') -->
        {% trans "No data added yet for" %} "<span data-bind="text: card.model.get('name')"></span>"
        <!-- /ko -->
    </div>
    <!-- /ko -->
</div>
<!-- /ko -->

<!-- /ko -->

And here’s the Knockout file:

define([
    'knockout',
    'templates/views/components/cards/default.htm',
    'bindings/scrollTo'
], function(ko, defaultCardTemplate) {
    var viewModel = function(params) {
        this.state = params.state || 'form';
        this.preview = params.preview;
        this.loading = params.loading || ko.observable(false);
        this.card = params.card;
        this.tile = params.tile;
        if (this.preview) {
            if (!this.card.newTile) {
                this.card.newTile = this.card.getNewTile();
            }
            this.tile = this.card.newTile;
        }
        this.form = params.form;
        this.provisionalTileViewModel = params.provisionalTileViewModel;
        this.reviewer = params.reviewer;
        this.expanded = ko.observable(true);
        this.beforeMove = function(e) {
            e.cancelDrop = (e.sourceParent!==e.targetParent);
        };
    };
    return ko.components.register('default-card', {
        viewModel: viewModel,
        template: defaultCardTemplate,
    });
});

Registering your Card Component#

To register your Component, you’ll need a JSON configuration file looking a lot like this sample:

{
  "name": "My New Card",
  "componentid": "eea17d6c-0c32-4536-8a01-392df734de1c",
  "component": "/views/components/cards/my-new-card",
  "componentname": "my-new-card",
  "description": "An awesome new card that does wonderful things.",
  "defaultconfig": {}
}
componentid:

Optional A UUID4 for your Component. Feel free to generate one in advance if that fits your workflow; if not, Arches will generate one for you and print it to STDOUT when you register the Component.

name:

Required The name of your new Card Component, visible in the drop-down list of card components in the Arches Designer.

description:

Required A brief description of your component.

component:

Required The path to the component view you have developed. Example: views/components/cards/sample-datatype

componentname:

Required Set this to the last part of component above.

defaultconfig:

Required You can provide user-defined default configuration here. Make it a JSON dictionary of keys and values. An empty dictionary is acceptable.

Card Commands#

To register your Card Component, use this command:

python manage.py card_component register --source /Documents/projects/mynewproject/mynewproject/cards/new-card-component.json

The command will confirm your Component has been registered, and you can also see it with:

python manage.py card_component list

If you make an update to your Card Component, you can load the changes to Arches with:

python manage.py card_component update --source /Documents/projects/mynewproject/mynewproject/cards/new-card-component.json

All the Card Component commands are detailed in Command Line Reference - Card Component Commands.