diff --git a/reviewboard/static/rb/js/configForms/base.js b/reviewboard/static/rb/js/configForms/base.js
new file mode 100644
index 0000000000000000000000000000000000000000..e01dee88872defcb0a6641138d07079ef7210707
--- /dev/null
+++ b/reviewboard/static/rb/js/configForms/base.js
@@ -0,0 +1 @@
+RB.Config = {};
diff --git a/reviewboard/static/rb/js/configForms/models/resourceListItemModel.js b/reviewboard/static/rb/js/configForms/models/resourceListItemModel.js
new file mode 100644
index 0000000000000000000000000000000000000000..9688d08e8af09b4fb31b9b78aabf76b55c25b5ec
--- /dev/null
+++ b/reviewboard/static/rb/js/configForms/models/resourceListItemModel.js
@@ -0,0 +1,91 @@
+/*
+ * A list item representing a resource in the API.
+ *
+ * This item will be backed by a resource model, which will be used for
+ * all synchronization with the API. It will work as a proxy for requests
+ * and events, and synchronize attributes between the resource and the list
+ * item. This allows callers to work directly with the list item instead of
+ * digging down into the resource.
+ */
+RB.Config.ResourceListItem = Djblets.Config.ListItem.extend({
+    defaults: _.defaults({
+        resource: null
+    }, Djblets.Config.ListItem.prototype.defaults),
+
+    /* A list of attributes synced between the ListItem and the Resource. */
+    syncAttrs: [],
+
+    /*
+     * Initializes the list item.
+     *
+     * This will begin listening for events on the resource, updating
+     * the state of the icon based on changes.
+     */
+    initialize: function(options) {
+        var resource = this.get('resource');
+
+        if (resource) {
+            this.set(_.pick(resource.attributes, this.syncAttrs));
+        } else {
+            /*
+             * Create a resource using the attributes provided to this list
+             * item.
+             */
+            resource = this.createResource(_.extend(
+                {
+                    id: this.get('id')
+                },
+                _.pick(this.attributes, this.syncAttrs)));
+
+            this.set('resource', resource);
+        }
+
+        this.resource = resource;
+
+        Djblets.Config.ListItem.prototype.initialize.call(this, options);
+
+        /* Forward on a couple events we want the caller to see. */
+        this.listenTo(resource, 'request', function() {
+            this.trigger('request');
+        });
+
+        this.listenTo(resource, 'sync', function() {
+            this.trigger('sync');
+        });
+
+        /* Destroy this item when the resource is destroyed. */
+        this.listenTo(resource, 'destroy', this.destroy);
+
+        /*
+         * Listen for each synced attribute change so we can update this
+         * list item.
+         */
+        _.each(this.syncAttrs, function(attr) {
+            this.listenTo(resource, 'change:' + attr, function(model, value) {
+                this.set(attr, value);
+            });
+        }, this);
+    },
+
+    /*
+     * Creates the Resource for this list item, with the given attributes.
+     */
+    createResource: function(attrs) {
+        console.assert(false, 'createResource must be implemented');
+    },
+
+    /*
+     * Destroys the list item.
+     *
+     * This will just emit the 'destroy' signal. It is typically called when
+     * the resource itself is destroyed.
+     */
+    destroy: function(options) {
+        this.stopListening(this.resource);
+        this.trigger('destroy', this, this.collection, options);
+
+        if (options && options.success) {
+            options.success(this, null, options);
+        }
+    }
+});
diff --git a/reviewboard/static/rb/js/configForms/models/tests/resourceListItemModelTests.js b/reviewboard/static/rb/js/configForms/models/tests/resourceListItemModelTests.js
new file mode 100644
index 0000000000000000000000000000000000000000..4615f6705f1209f9a72971a382988ab6e784e450
--- /dev/null
+++ b/reviewboard/static/rb/js/configForms/models/tests/resourceListItemModelTests.js
@@ -0,0 +1,86 @@
+suite('reviewboard/configForms/models/ResourceListItem', function() {
+    var TestListItem,
+        resource,
+        listItem;
+
+    TestListItem = RB.Config.ResourceListItem.extend({
+        syncAttrs: ['name', 'fileRegex'],
+
+        createResource: function(attrs) {
+            return new RB.DefaultReviewer(attrs);
+        }
+    });
+
+    beforeEach(function() {
+        resource = new RB.DefaultReviewer({
+            name: 'my-name',
+            fileRegex: '.*'
+        });
+    });
+
+    describe('Synchronizing attributes', function() {
+        it('On resource attribute change', function() {
+            listItem = new TestListItem({
+                resource: resource
+            });
+
+            resource.set('name', 'foo');
+
+            expect(listItem.get('name')).toBe('foo');
+        });
+
+        describe('On creation', function() {
+            it('With existing resource', function() {
+                listItem = new TestListItem({
+                    resource: resource,
+                    name: 'dummy',
+                    fileRegex: '/foo/.*'
+                });
+
+                expect(listItem.get('name')).toBe('my-name');
+                expect(listItem.get('fileRegex')).toBe('.*');
+            });
+
+            it('With created resource', function() {
+                listItem = new TestListItem({
+                    id: 123,
+                    name: 'new-name',
+                    fileRegex: '/foo/.*'
+                });
+
+                expect(listItem.get('name')).toBe('new-name');
+                expect(listItem.get('fileRegex')).toBe('/foo/.*');
+
+                resource = listItem.get('resource');
+                expect(resource.id).toBe(123);
+                expect(resource.get('name')).toBe('new-name');
+                expect(resource.get('fileRegex')).toBe('/foo/.*');
+            });
+        });
+    });
+
+    describe('Event mirroring', function() {
+        beforeEach(function() {
+            listItem = new TestListItem({
+                resource: resource
+            });
+
+            spyOn(listItem, 'trigger');
+        });
+
+        it('destroy', function() {
+            resource.trigger('destroy');
+            expect(listItem.trigger).toHaveBeenCalledWith('destroy');
+        });
+
+        it('request', function() {
+            resource.trigger('request');
+            expect(listItem.trigger).toHaveBeenCalledWith('request');
+        });
+
+        it('sync', function() {
+            resource.trigger('sync');
+            expect(listItem.trigger).toHaveBeenCalledWith('sync');
+        });
+    });
+});
diff --git a/reviewboard/staticbundles.py b/reviewboard/staticbundles.py
index 6b634552d40c8216b80410fd8d08756e95f48109..6a92574d9e65ec6ecdc79341a3d11304be854c66 100644
--- a/reviewboard/staticbundles.py
+++ b/reviewboard/staticbundles.py
@@ -32,6 +32,7 @@ PIPELINE_JS = dict({
             'lib/js/jasmine-html-1.3.1.js',
             'lib/js/jasmine.suites-1.0.js',
             'rb/js/collections/tests/filteredCollectionTests.js',
+            'rb/js/configForms/models/tests/resourceListItemModelTests.js',
             'rb/js/diffviewer/models/tests/diffFileModelTests.js',
             'rb/js/diffviewer/models/tests/diffReviewableModelTests.js',
             'rb/js/diffviewer/models/tests/diffRevisionModelTests.js',
@@ -146,6 +147,13 @@ PIPELINE_JS = dict({
         ),
         'output_filename': 'rb/js/account-page.min.js',
     },
+    'config-forms': {
+        'source_filenames': (
+            'rb/js/configForms/base.js',
+            'rb/js/configForms/models/resourceListItemModel.js',
+        ),
+        'output_filename': 'rb/js/config-forms.min.js',
+    },
     'dashboard': {
         'source_filenames': (
             'rb/js/dashboard/models/dashboardModel.js',
diff --git a/reviewboard/templates/js/tests.html b/reviewboard/templates/js/tests.html
index 1486cef960431c1756c68568c211415f7237dff0..ee66df130323847c0a7243a07c9ca21c88c4014f 100644
--- a/reviewboard/templates/js/tests.html
+++ b/reviewboard/templates/js/tests.html
@@ -11,6 +11,7 @@
 
 {% block scripts-post %}
 {%  compressed_js "djblets-config-forms" %}
+{%  compressed_js "config-forms" %}
 {%  compressed_js "reviews" %}
 {%  compressed_js "newReviewRequest" %}
 {%  compressed_js "js-tests" %}
