v3 UI should allow custom revisions for A/B testing
authorrniwa@webkit.org <rniwa@webkit.org@268f45cc-cd09-0410-ab3c-d52691b4dbfc>
Thu, 18 Feb 2016 20:07:54 +0000 (20:07 +0000)
committerrniwa@webkit.org <rniwa@webkit.org@268f45cc-cd09-0410-ab3c-d52691b4dbfc>
Thu, 18 Feb 2016 20:07:54 +0000 (20:07 +0000)
https://bugs.webkit.org/show_bug.cgi?id=154379

Reviewed by Chris Dumez.

Added the capability to customize revisions selected in the overview chart and the results viewer.

Newly added CustomizableTestGroupForm is responsible for allowing users to modify the set of revisions in
a new A/B testing group. Unlike TestGroupForm which doesn't know anything about which revisions are selected
for each project/repository, CustomizableTestGroupForm is aware of the list of revisions used in each set.

The list of revisions used in each set is represented by RootSet if users had not customized them, and
CustomRootSet otherwise; the latter was added since regular RootSet object requires CommitLog and other
DataModelObjects which are hard to create without corresponding database entries.

* public/v3/components/customizable-test-group-form.js: Added.
(CustomizableTestGroupForm): Added.
(CustomizableTestGroupForm.prototype.setRootSetMap): Added.
(CustomizableTestGroupForm.prototype._submitted): Overrides the superclass' method.
(CustomizableTestGroupForm.prototype._customize): Ditto. Unlike TestGroupForm's callback, this class'
callback passes in a root set map as the third argument.
(CustomizableTestGroupForm.prototype._computeRootSetMap): Added. Returns this._rootSetMap, which is set by
AnalysisTaskPage if user had not customized the root sets. Otherwise return a new map with CustomRootSet's.
(CustomizableTestGroupForm.prototype.render): Added. Creates a table to allow customization of root sets.
(CustomizableTestGroupForm._constructRevisionRadioButtons): Added.
(CustomizableTestGroupForm._createRadioButton): Added.
(CustomizableTestGroupForm.cssTemplate): Added.
(CustomizableTestGroupForm.formContent): Added. This method is called by TestGroupForm.htmlTemplate.
* public/v3/components/test-group-form.js:
(TestGroupForm): Updated the various methods to not directly mutate DOM. Store the state in instance
variables and update DOM in render() as done elsewhere.
(TestGroupForm.prototype.setNeedsName): Deleted. We no longer need this flag since TestGroupForm which is
used for retries never needs a name and CustomizableTestGroupForm which is used to create a new test group
always requires a name.
(TestGroupForm.prototype.setDisabled):
(TestGroupForm.prototype.setLabel):
(TestGroupForm.prototype.setRepetitionCount):
(TestGroupForm.prototype.render): Added.
(TestGroupForm.prototype._submitted): Moved the code to prevent the default action has been moved to the
constructor since this method is overridden by CustomizableTestGroupForm.
(TestGroupForm.cssTemplate): Added.
(TestGroupForm.htmlTemplate):
(TestGroupForm.formContent): Extracted from htmlTemplate.
* public/v3/index.html:
* public/v3/models/repository.js:
(Repository.sortByNamePreferringOnesWithURL): Added.
* public/v3/models/root-set.js:
(RootSet.prototype.revisionForRepository): Added so that _createTestGroupAfterVerifyingRootSetList can retrieve
the revision information from CustomRootSet without going through CommitLog objects since CustomRootSet doesn't
have associated CommitLog objects.
(CustomRootSet): Added. Used by CustomizableTestGroupForm to create a custom root map since regular RootSet
requires CommitLog and other related objects which are hard to create without database entries.
(CustomRootSet.prototype.setRevisionForRepository): Added.
(CustomRootSet.prototype.repositories): Added.
(CustomRootSet.prototype.revisionForRepository): Added.
* public/v3/pages/analysis-task-page.js:
(AnalysisTaskPage):
(AnalysisTaskPage.prototype.render): Removed the reference to v2 UI since v3 UI is now strictly more powerful
than v2 UI. Also update the root set maps in each form here.
(AnalysisTaskPage.prototype._retryCurrentTestGroup): No longer takes unused name argument as it got removed
from TestGroupForm.
(AnalysisTaskPage.prototype._chartSelectionDidChange): No longer updates the disabled-ness here since it's now
done in render() via setRootSetMap().
(AnalysisTaskPage.prototype._createNewTestGroupFromChart): Now takes rootSetMap as an argument.
(AnalysisTaskPage.prototype._selectedRowInAnalysisResultsViewer): No longer updates the disabled-ness here
since it's now done in render() via setRootSetMap().
(AnalysisTaskPage.prototype._createNewTestGroupFromViewer): Now takes rootSetMap as an argument.
(AnalysisTaskPage.prototype._createTestGroupAfterVerifyingRootSetList): Take a dictionary of root set labels
such as A and B, which maps to a RootSet or a newly-added CustomRootSet.
(AnalysisTaskPage.htmlTemplate): Use customizable-test-group-form for creating a new A/B testing group. Retry
form will continue to use TestGroupForm since customizing revisions is non-sensical in retries.
(AnalysisTaskPage.cssTemplate): Updated the style.

git-svn-id: https://svn.webkit.org/repository/webkit/trunk@196768 268f45cc-cd09-0410-ab3c-d52691b4dbfc

Websites/perf.webkit.org/ChangeLog
Websites/perf.webkit.org/public/v3/components/customizable-test-group-form.js [new file with mode: 0644]
Websites/perf.webkit.org/public/v3/components/test-group-form.js
Websites/perf.webkit.org/public/v3/index.html
Websites/perf.webkit.org/public/v3/models/repository.js
Websites/perf.webkit.org/public/v3/models/root-set.js
Websites/perf.webkit.org/public/v3/pages/analysis-task-page.js

index ba56eaa..7751cff 100644 (file)
@@ -1,3 +1,78 @@
+2016-02-17  Ryosuke Niwa  <rniwa@webkit.org>
+
+        v3 UI should allow custom revisions for A/B testing
+        https://bugs.webkit.org/show_bug.cgi?id=154379
+
+        Reviewed by Chris Dumez.
+
+        Added the capability to customize revisions selected in the overview chart and the results viewer.
+
+        Newly added CustomizableTestGroupForm is responsible for allowing users to modify the set of revisions in
+        a new A/B testing group. Unlike TestGroupForm which doesn't know anything about which revisions are selected
+        for each project/repository, CustomizableTestGroupForm is aware of the list of revisions used in each set.
+
+        The list of revisions used in each set is represented by RootSet if users had not customized them, and
+        CustomRootSet otherwise; the latter was added since regular RootSet object requires CommitLog and other
+        DataModelObjects which are hard to create without corresponding database entries.
+
+        * public/v3/components/customizable-test-group-form.js: Added.
+        (CustomizableTestGroupForm): Added.
+        (CustomizableTestGroupForm.prototype.setRootSetMap): Added.
+        (CustomizableTestGroupForm.prototype._submitted): Overrides the superclass' method.
+        (CustomizableTestGroupForm.prototype._customize): Ditto. Unlike TestGroupForm's callback, this class'
+        callback passes in a root set map as the third argument.
+        (CustomizableTestGroupForm.prototype._computeRootSetMap): Added. Returns this._rootSetMap, which is set by
+        AnalysisTaskPage if user had not customized the root sets. Otherwise return a new map with CustomRootSet's.
+        (CustomizableTestGroupForm.prototype.render): Added. Creates a table to allow customization of root sets.
+        (CustomizableTestGroupForm._constructRevisionRadioButtons): Added.
+        (CustomizableTestGroupForm._createRadioButton): Added.
+        (CustomizableTestGroupForm.cssTemplate): Added.
+        (CustomizableTestGroupForm.formContent): Added. This method is called by TestGroupForm.htmlTemplate.
+        * public/v3/components/test-group-form.js:
+        (TestGroupForm): Updated the various methods to not directly mutate DOM. Store the state in instance
+        variables and update DOM in render() as done elsewhere.
+        (TestGroupForm.prototype.setNeedsName): Deleted. We no longer need this flag since TestGroupForm which is
+        used for retries never needs a name and CustomizableTestGroupForm which is used to create a new test group
+        always requires a name.
+        (TestGroupForm.prototype.setDisabled):
+        (TestGroupForm.prototype.setLabel):
+        (TestGroupForm.prototype.setRepetitionCount):
+        (TestGroupForm.prototype.render): Added.
+        (TestGroupForm.prototype._submitted): Moved the code to prevent the default action has been moved to the
+        constructor since this method is overridden by CustomizableTestGroupForm.
+        (TestGroupForm.cssTemplate): Added.
+        (TestGroupForm.htmlTemplate):
+        (TestGroupForm.formContent): Extracted from htmlTemplate.
+        * public/v3/index.html:
+        * public/v3/models/repository.js:
+        (Repository.sortByNamePreferringOnesWithURL): Added.
+        * public/v3/models/root-set.js:
+        (RootSet.prototype.revisionForRepository): Added so that _createTestGroupAfterVerifyingRootSetList can retrieve
+        the revision information from CustomRootSet without going through CommitLog objects since CustomRootSet doesn't
+        have associated CommitLog objects.
+        (CustomRootSet): Added. Used by CustomizableTestGroupForm to create a custom root map since regular RootSet
+        requires CommitLog and other related objects which are hard to create without database entries.
+        (CustomRootSet.prototype.setRevisionForRepository): Added.
+        (CustomRootSet.prototype.repositories): Added.
+        (CustomRootSet.prototype.revisionForRepository): Added.
+        * public/v3/pages/analysis-task-page.js:
+        (AnalysisTaskPage):
+        (AnalysisTaskPage.prototype.render): Removed the reference to v2 UI since v3 UI is now strictly more powerful
+        than v2 UI. Also update the root set maps in each form here.
+        (AnalysisTaskPage.prototype._retryCurrentTestGroup): No longer takes unused name argument as it got removed
+        from TestGroupForm.
+        (AnalysisTaskPage.prototype._chartSelectionDidChange): No longer updates the disabled-ness here since it's now
+        done in render() via setRootSetMap().
+        (AnalysisTaskPage.prototype._createNewTestGroupFromChart): Now takes rootSetMap as an argument.
+        (AnalysisTaskPage.prototype._selectedRowInAnalysisResultsViewer): No longer updates the disabled-ness here
+        since it's now done in render() via setRootSetMap().
+        (AnalysisTaskPage.prototype._createNewTestGroupFromViewer): Now takes rootSetMap as an argument.
+        (AnalysisTaskPage.prototype._createTestGroupAfterVerifyingRootSetList): Take a dictionary of root set labels
+        such as A and B, which maps to a RootSet or a newly-added CustomRootSet.
+        (AnalysisTaskPage.htmlTemplate): Use customizable-test-group-form for creating a new A/B testing group. Retry
+        form will continue to use TestGroupForm since customizing revisions is non-sensical in retries.
+        (AnalysisTaskPage.cssTemplate): Updated the style.
+
 2016-02-16  Ryosuke Niwa  <rniwa@webkit.org>
 
         v3 UI has the capability to schedule an A/B testing in a specific range
diff --git a/Websites/perf.webkit.org/public/v3/components/customizable-test-group-form.js b/Websites/perf.webkit.org/public/v3/components/customizable-test-group-form.js
new file mode 100644 (file)
index 0000000..849d5ed
--- /dev/null
@@ -0,0 +1,174 @@
+
+class CustomizableTestGroupForm extends TestGroupForm {
+
+    constructor()
+    {
+        super('customizable-test-group-form');
+        this._rootSetMap = null;
+        this._disabled = true;
+        this._renderedRepositorylist = null;
+        this._customized = false;
+        this.content().querySelector('a').onclick = this._customize.bind(this);
+    }
+
+    setRootSetMap(map)
+    {
+        this._rootSetMap = map;
+        this._customized = false;
+        this.setDisabled(!map);
+    }
+
+    _submitted()
+    {
+        if (this._startCallback)
+            this._startCallback(this.content().querySelector('.name').value, this._repetitionCount, this._computeRootSetMap());
+    }
+
+    _customize(event)
+    {
+        event.preventDefault();
+        this._customized = true;
+        this.render();
+    }
+
+    _computeRootSetMap()
+    {
+        console.assert(this._rootSetMap);
+        if (!this._customized)
+            return this._rootSetMap;
+
+        console.assert(this._renderedRepositorylist);
+        var map = {};
+        for (var label in this._rootSetMap) {
+            var customRootSet = new CustomRootSet;
+            for (var repository of this._renderedRepositorylist) {
+                var id = CustomizableTestGroupForm._idForLabelAndRepository(label, repository);
+                var revision = this.content().getElementById(id).value;
+                console.assert(revision);
+                if (revision)
+                    customRootSet.setRevisionForRepository(repository, revision);
+            }
+            map[label] = customRootSet;
+        }
+        return map;
+    }
+
+    render()
+    {
+        super.render();
+        this.content().querySelector('.customize-link').style.display = this._disabled ? 'none' : null;
+
+        if (!this._customized) {
+            this.renderReplace(this.content().querySelector('.custom-table-container'), []);
+            return;
+        }
+        var map = this._rootSetMap;
+        console.assert(map);
+
+        var repositorySet = new Set;
+        var rootSetLabels = [];
+        for (var label in map) {
+            for (var repository of map[label].repositories())
+                repositorySet.add(repository);
+            rootSetLabels.push(label);
+        }
+
+        this._renderedRepositorylist = Repository.sortByNamePreferringOnesWithURL(Array.from(repositorySet.values()));
+
+        var element = ComponentBase.createElement;
+        this.renderReplace(this.content().querySelector('.custom-table-container'),
+            element('table', {class: 'custom-table'}, [
+                element('thead',
+                    element('tr',
+                        [element('td', 'Repository'), rootSetLabels.map(function (label) {
+                            return element('td', {colspan: rootSetLabels.length + 1}, label);
+                        })])),
+                element('tbody',
+                    this._renderedRepositorylist.map(function (repository) {
+                        var cells = [element('th', repository.label())];
+                        for (var label in map)
+                            cells.push(CustomizableTestGroupForm._constructRevisionRadioButtons(map, repository, label));
+                        return element('tr', cells);
+                    }))]));
+    }
+
+    static _idForLabelAndRepository(label, repository) { return label + '-' + repository.id(); }
+
+    static _constructRevisionRadioButtons(rootSetMap, repository, rowLabel)
+    {
+        var id = this._idForLabelAndRepository(rowLabel, repository);
+        var groupName = id + '-group';
+        var element = ComponentBase.createElement;
+        var revisionEditor = element('input', {id: id});
+
+        var nodes = [];
+        for (var labelToChoose in rootSetMap) {
+            var commit = rootSetMap[labelToChoose].commitForRepository(repository);
+            var checked = labelToChoose == rowLabel;
+            var radioButton = this._createRadioButton(groupName, revisionEditor, commit, checked);
+            if (checked)
+                revisionEditor.value = commit ? commit.revision() : '';
+            nodes.push(element('td', element('label', [radioButton, labelToChoose])));
+        }
+        nodes.push(element('td', revisionEditor));
+
+        return nodes;
+    }
+
+    static _createRadioButton(groupName, revisionEditor, commit, checked)
+    {
+        var button = ComponentBase.createElement('input', {
+            type: 'radio',
+            name: groupName + '-radio',
+            onchange: function () { revisionEditor.value = commit ? commit.revision() : ''; },
+        });
+        if (checked) // FIXME: createElement should be able to set boolean attribute properly.
+            button.checked = true;
+        return button;
+    }
+
+    static cssTemplate()
+    {
+        return super.cssTemplate() + `
+            .customize-link {
+                color: #333;
+            }
+
+            .customize-link a {
+                color: inherit;
+            }
+
+            .custom-table {
+                margin: 1rem 0;
+            }
+
+            .custom-table,
+            .custom-table td,
+            .custom-table th {
+                font-weight: inherit;
+                border-collapse: collapse;
+                border-top: solid 1px #ddd;
+                border-bottom: solid 1px #ddd;
+                padding: 0.4rem 0.2rem;
+                font-size: 0.9rem;
+            }
+
+            .custom-table thead td,
+            .custom-table th {
+                text-align: center;
+            }
+            `;
+    }
+
+    static formContent()
+    {
+        return `
+            <input class="name" type="text" placeholder="Test group name">
+            ${super.formContent()}
+            <span class="customize-link">(<a href="">Customize</a>)</span>
+            <div class="custom-table-container"></div>
+        `;
+    }
+}
+
+ComponentBase.defineElement('customizable-test-group-form', CustomizableTestGroupForm);
index 25dffce..f158a8a 100644 (file)
@@ -1,52 +1,70 @@
 
 class TestGroupForm extends ComponentBase {
 
-    constructor()
+    constructor(name)
     {
-        super('test-group-form');
+        super(name || 'test-group-form');
         this._startCallback = null;
-        this._repetitionCountControl = this.content().querySelector('.repetition-count');
-        this._repetitionCountControl.value = 4;
-        this._buttonControl = this.content().querySelector('button');
+        this._disabled = false;
+        this._label = undefined;
+        this._repetitionCount = 4;
+
         this._nameControl = this.content().querySelector('.name');
-        this.content().querySelector('form').onsubmit = this._submitted.bind(this);
+        this._repetitionCountControl = this.content().querySelector('.repetition-count');
+        var self = this;
+        this._repetitionCountControl.onchange = function () {
+            self._repetitionCount = self._repetitionCountControl.value;
+        }
+
+        var boundSubmitted = this._submitted.bind(this);
+        this.content().querySelector('form').onsubmit = function (event) {
+            event.preventDefault();
+            boundSubmitted();
+        }
     }
 
     setStartCallback(callback) { this._startCallback = callback; }
-    setNeedsName(needsName) { this._nameControl.style.display = needsName ? null : 'none'; }
-    setDisabled(disabled) { this._buttonControl.disabled = disabled; }
+    setDisabled(disabled) { this._disabled = !!disabled; }
+    setLabel(label) { this._label = label; }
+    setRepetitionCount(count) { this._repetitionCount = count; }
 
-    setLabel(label) { this._buttonControl.textContent = label; }
-    setRepetitionCount(count) { this._repetitionCountControl.value = count; }
+    render()
+    {
+        var button = this.content().querySelector('button');
+        if (this._label)
+            button.textContent = this._label;
+        button.disabled = this._disabled;
+        this._repetitionCountControl.value = this._repetitionCount;
+    }
 
-    _submitted(event)
+    _submitted()
     {
-        event.preventDefault();
         if (this._startCallback)
-            this._startCallback(this._nameControl.value, this._repetitionCountControl.value);
+            this._startCallback(this._repetitionCount);
     }
 
     static htmlTemplate()
     {
+        return `<form><button type="submit">Start A/B testing</button>${this.formContent()}</form>`;
+    }
+
+    static formContent()
+    {
         return `
-            <form>
-                <button type="submit">Start A/B testing</button>
-                <input class="name" type="text">
-                with
-                <select class="repetition-count">
-                    <option>1</option>
-                    <option>2</option>
-                    <option>3</option>
-                    <option>4</option>
-                    <option>5</option>
-                    <option>6</option>
-                    <option>7</option>
-                    <option>8</option>
-                    <option>9</option>
-                    <option>10</option>
-                </select>
-                iterations per set
-            </form>
+            with
+            <select class="repetition-count">
+                <option>1</option>
+                <option>2</option>
+                <option>3</option>
+                <option>4</option>
+                <option>5</option>
+                <option>6</option>
+                <option>7</option>
+                <option>8</option>
+                <option>9</option>
+                <option>10</option>
+            </select>
+            iterations per set
         `;
     }
 
index d2738e8..6f919d5 100644 (file)
@@ -76,6 +76,7 @@ Run tools/bundle-v3-scripts to speed up the load time for production.`);
         <script src="components/analysis-results-viewer.js"></script>
         <script src="components/test-group-results-table.js"></script>
         <script src="components/test-group-form.js"></script>
+        <script src="components/customizable-test-group-form.js"></script>
         <script src="components/chart-styles.js"></script>
         <script src="components/chart-pane-base.js"></script>
         <script src="pages/page.js"></script>
index 0b4c753..d09b11d 100644 (file)
@@ -19,4 +19,20 @@ class Repository extends LabeledObject {
     {
         return (this._blameUrl || '').replace(/\$1/g, from).replace(/\$2/g, to);
     }
+
+    static sortByNamePreferringOnesWithURL(repositories)
+    {
+        return repositories.sort(function (a, b) {
+            if (!!a._blameUrl == !!b._blameUrl) {
+                if (a.name() > b.name())
+                    return 1;
+                else if (a.name() < b.name())
+                    return -1;
+                return 0;
+            } else if (b._blameUrl) // a > b
+                return 1;
+            return -1;
+        });
+    }
+
 }
index e4c2e1d..fc31cdd 100644 (file)
@@ -24,6 +24,12 @@ class RootSet extends DataModelObject {
     repositories() { return this._repositories; }
     commitForRepository(repository) { return this._repositoryToCommitMap[repository.id()]; }
 
+    revisionForRepository(repository)
+    {
+        var commit = this._repositoryToCommitMap[repository.id()];
+        return commit ? commit.revision() : null;
+    }
+
     latestCommitTime()
     {
         if (this._latestCommitTime == null) {
@@ -81,3 +87,22 @@ class MeasurementRootSet extends RootSet {
         return RootSet.findById(rootSetId) || (new MeasurementRootSet(rootSetId, revisionList));
     }
 }
+
+class CustomRootSet {
+
+    constructor()
+    {
+        this._revisionListByRepository = new Map;
+    }
+
+    setRevisionForRepository(repository, revision)
+    {
+        console.assert(repository instanceof Repository);
+        this._revisionListByRepository.set(repository, revision);
+    }
+
+    repositories() { return Array.from(this._revisionListByRepository.keys()); }
+    revisionForRepository(repository) { return this._revisionListByRepository.get(repository); }
+
+}
+
index 1cbddb1..e457f72 100644 (file)
@@ -63,16 +63,14 @@ class AnalysisTaskPage extends PageWithHeading {
         this._bugTrackerControl = this.content().querySelector('.bug-tracker-control');
         this._bugNumberControl = this.content().querySelector('.bug-number-control');
 
-        this._newTestGroupFormForChart = this.content().querySelector('.overview-chart test-group-form').component();
+        this._newTestGroupFormForChart = this.content().querySelector('.overview-chart customizable-test-group-form').component();
         this._newTestGroupFormForChart.setStartCallback(this._createNewTestGroupFromChart.bind(this));
 
-        this._newTestGroupFormForViewer = this.content().querySelector('.analysis-results-view test-group-form').component();
+        this._newTestGroupFormForViewer = this.content().querySelector('.analysis-results-view customizable-test-group-form').component();
         this._newTestGroupFormForViewer.setStartCallback(this._createNewTestGroupFromViewer.bind(this));
-        this._selectedRowInAnalysisResultsViewer();
 
         this._retryForm = this.content().querySelector('.test-group-retry-form').firstChild.component();
         this._retryForm.setStartCallback(this._retryCurrentTestGroup.bind(this));
-        this._retryForm.setNeedsName(false);
     }
 
     title() { return this._task ? this._task.label() : 'Analysis Task'; }
@@ -198,12 +196,6 @@ class AnalysisTaskPage extends PageWithHeading {
 
         this.content().querySelector('.error-message').textContent = this._errorMessage || '';
 
-        if (this._task) {
-            var v2URL = `/v2/#/analysis/task/${this._task.id()}`;
-            this.content().querySelector('.error-message').innerHTML =
-                `<p>To schedule a custom A/B testing, use <a href="${v2URL}">v2 UI</a>.</p>`;
-        }
-
         this._chartPane.render();
 
         var element = ComponentBase.createElement;
@@ -255,14 +247,23 @@ class AnalysisTaskPage extends PageWithHeading {
                 }));
         }
 
+        var selectedRange = this._analysisResultsViewer.selectedRange();
+        var a = selectedRange['A'];
+        var b = selectedRange['B'];
+        this._newTestGroupFormForViewer.setRootSetMap(a && b ? {'A': a.rootSet(), 'B': b.rootSet()} : null);
+        this._newTestGroupFormForViewer.render();
+
+        this._renderTestGroupList();
+        this._renderTestGroupDetails();
+
+        var points = this._chartPane.selectedPoints();
+        this._newTestGroupFormForChart.setRootSetMap(points && points.length >= 2 ?
+                {'A': points[0].rootSet(), 'B': points[points.length - 1].rootSet()} : null);
         this._newTestGroupFormForChart.render();
 
         this._analysisResultsViewer.setCurrentTestGroup(this._currentTestGroup);
         this._analysisResultsViewer.render();
 
-        this._renderTestGroupList();
-        this._renderTestGroupDetails();
-
         this._testGroupResultsTable.render();
 
         Instrumentation.endMeasuringTime('AnalysisTaskPage', 'render');
@@ -417,68 +418,70 @@ class AnalysisTaskPage extends PageWithHeading {
         });
     }
 
-    _retryCurrentTestGroup(unusedName, repetitionCount)
+    _retryCurrentTestGroup(repetitionCount)
     {
         console.assert(this._currentTestGroup);
         var testGroup = this._currentTestGroup;
         var newName = this._createRetryNameForTestGroup(testGroup.name());
         var rootSetList = testGroup.requestedRootSets();
-        var rootSetLabels = rootSetList.map(function (rootSet) { return testGroup.labelForRootSet(rootSet); });
-        return this._createTestGroupAfterVerifyingRootSetList(newName, repetitionCount, rootSetList, rootSetLabels);
+
+        var rootSetMap = {};
+        for (var rootSet of rootSetList)
+            rootSetMap[testGroup.labelForRootSet(rootSet)] = rootSet;
+
+        return this._createTestGroupAfterVerifyingRootSetList(newName, repetitionCount, rootSetMap);
     }
 
     _chartSelectionDidChange()
     {
-        var points = this._chartPane.selectedPoints();
-        this._newTestGroupFormForChart.setDisabled(!points || points.length < 2);
+        this.render();
     }
 
-    _createNewTestGroupFromChart(name, repetitionCount)
+    _createNewTestGroupFromChart(name, repetitionCount, rootSetMap)
     {
-        var points = this._chartPane.selectedPoints();
-        console.assert(points && points.length >= 2);
-        return this._createTestGroupAfterVerifyingRootSetList(name, repetitionCount,
-            [points[0].rootSet(), points[points.length - 1].rootSet()], ['A', 'B']);
+        return this._createTestGroupAfterVerifyingRootSetList(name, repetitionCount, rootSetMap);
     }
 
     _selectedRowInAnalysisResultsViewer()
     {
-        var selectedRange = this._analysisResultsViewer.selectedRange();
-        this._newTestGroupFormForViewer.setDisabled(!selectedRange['A'] || !selectedRange['B']);
+        this.render();
     }
 
-    _createNewTestGroupFromViewer(name, repetitionCount)
+    _createNewTestGroupFromViewer(name, repetitionCount, rootSetMap)
     {
-        var selectedRange = this._analysisResultsViewer.selectedRange();
-        console.assert(selectedRange && selectedRange['A'] && selectedRange['B']);
-        return this._createTestGroupAfterVerifyingRootSetList(name, repetitionCount,
-            [selectedRange['A'].rootSet(), selectedRange['B'].rootSet()], ['A', 'B']);
+        return this._createTestGroupAfterVerifyingRootSetList(name, repetitionCount, rootSetMap);
     }
 
-    _createTestGroupAfterVerifyingRootSetList(testGroupName, repetitionCount, rootSetList, rootSetLabels)
+    _createTestGroupAfterVerifyingRootSetList(testGroupName, repetitionCount, rootSetMap)
     {
         if (this._hasDuplicateTestGroupName(testGroupName))
             alert(`There is already a test group named "${testGroupName}"`);
 
         var rootSetsByName = {};
-        for (var repository of rootSetList[0].repositories())
-            rootSetsByName[repository.name()] = [];
+        var firstLabel;
+        for (firstLabel in rootSetMap) {
+            var rootSet = rootSetMap[firstLabel];
+            for (var repository of rootSet.repositories())
+                rootSetsByName[repository.name()] = [];
+            break;
+        }
 
         var setIndex = 0;
-        for (var rootSet of rootSetList) {
+        for (var label in rootSetMap) {
+            var rootSet = rootSetMap[label];
             for (var repository of rootSet.repositories()) {
                 var list = rootSetsByName[repository.name()];
                 if (!list) {
-                    alert(`Set ${rootSetLabels[setIndex]} specifies ${repository.label()} but set ${rootSetLabels[0]} does not.`);
+                    alert(`Set ${label} specifies ${repository.label()} but set ${firstLabel} does not.`);
                     return null;
                 }
-                list.push(rootSet.commitForRepository(repository).revision());
+                list.push(rootSet.revisionForRepository(repository));
             }
             setIndex++;
             for (var name in rootSetsByName) {
                 var list = rootSetsByName[name];
                 if (list.length < setIndex) {
-                    alert(`Set ${rootSetLabels[0]} specifies ${repository.label()} but set ${rootSetLabels[setIndex]} does not.`);
+                    alert(`Set ${firstLabel} specifies ${repository.label()} but set ${label} does not.`);
                     return null;
                 }
             }
@@ -555,11 +558,11 @@ class AnalysisTaskPage extends PageWithHeading {
                 </div>
                 <section class="overview-chart">
                     <analysis-task-chart-pane></analysis-task-chart-pane>
-                    <div class="new-test-group-form"><test-group-form></test-group-form></div>
+                    <div class="new-test-group-form"><customizable-test-group-form></customizable-test-group-form></div>
                 </section>
                 <section class="analysis-results-view">
                     <analysis-results-viewer></analysis-results-viewer>
-                    <div class="new-test-group-form"><test-group-form></test-group-form></div>
+                    <div class="new-test-group-form"><customizable-test-group-form></customizable-test-group-form></div>
                 </section>
                 <section class="test-group-view">
                     <ul class="test-group-list"></ul>
@@ -615,7 +618,9 @@ class AnalysisTaskPage extends PageWithHeading {
             .analysis-task-status {
                 margin: 0;
                 display: flex;
-                margin-bottom: 1.5rem;
+                padding-bottom: 1rem;
+                margin-bottom: 1rem;
+                border-bottom: solid 1px #ccc;
             }
 
             .analysis-task-status > section {
@@ -650,8 +655,12 @@ class AnalysisTaskPage extends PageWithHeading {
                 overflow-y: scroll;
             }
 
+
             .analysis-results-view {
-                margin: 2.5rem 1rem;
+                border-top: solid 1px #ccc;
+                border-bottom: solid 1px #ccc;
+                margin: 1rem 0;
+                padding: 1rem;
             }
 
             .test-configuration h3 {