Perf dashboard should allow renaming analysis tasks and test groups
authorrniwa@webkit.org <rniwa@webkit.org@268f45cc-cd09-0410-ab3c-d52691b4dbfc>
Fri, 12 Feb 2016 23:33:18 +0000 (23:33 +0000)
committerrniwa@webkit.org <rniwa@webkit.org@268f45cc-cd09-0410-ab3c-d52691b4dbfc>
Fri, 12 Feb 2016 23:33:18 +0000 (23:33 +0000)
https://bugs.webkit.org/show_bug.cgi?id=154200

Reviewed by Chris Dumez.

Allow editing names of analysis tasks and A/B testing groups in the v3 UI.

Added the support for updating the name to the privileged API at /privileged-api/update-analysis-task
and added a new prevailed API to update A/B testing groups at /privileged-api/update-test-group.

* public/privileged-api/update-analysis-task.php: Added the support for renaming the analysis task.
(main):

* public/privileged-api/update-test-group.php: Added. Supports updating the test group's name.
(main):

* public/v3/components/editable-text.js: Added.
(EditableText): Added. A new editable text label control. It looks like a text node with "(Edit)" link
at the end which allow users to go into the "editing mode", which reveals an input element.
The user can exit the editing mode by either moving the focus away from the control or clicking on
"(Save)" at the end. It calls _updateCallback in the latter case.
(EditableText.prototype.editedText): Returns the current value of the input element user.
(EditableText.prototype.setText): Sets the label. This does not live-update the input element until
the user exists the current editing mode and re-enters it.
(EditableText.prototype.setStartedEditingCallback): Sets a callback which gets called when the user
requested to enter the editing mode. Since EditableText relies on AnalysisTaskPage to render, this
callback only exits to call EditableText.render() in AnalysisTask._didStartEditingTaskName.
(EditableText.prototype.setUpdateCallback): Sets a callback which gets called when the user exits
the editing mode by activating the "(Save)" link. This callback MUST return a promise upon resolution
of which the control gets out of the editing mode. While the promise is in flight, the input element
becomes readonly.
(EditableText.prototype.render): Updates various states of the elements. When _updatingPromise is not
falsy, we make the input element readonly and show '(...)' on the link. Don't show the action link
if the label is empty (e.g. analysis task or test group is still being fetched).
(EditableText.prototype._didClick): Called when the user clicked on the action link. Enter the editing
mode or save the edited label via _updateCallback.
(EditableText.prototype._didBlur): Exit the editing mode without saving if the input element is not
focused, there is no inflight promise returned by _updateCallback, and the action link "(Save)" does
not have the focus.
(EditableText.prototype._didUpdate): Called when exiting the editing mode.
(EditableText.htmlTemplate):
(EditableText.cssTemplate):

* public/v3/index.html: Include newly added editable-text.js.

* public/v3/models/analysis-task.js:
(AnalysisTask.prototype.updateSingleton): Added.
(AnalysisTask.prototype.updateName): Added. Uses PrivilegedAPI to update the name and re-fetches
the analysis task from the sever.
(AnalysisTask._constructAnalysisTasksFromRawData): Use ensureSingleton instead of manually calling
findById since we need to update the name of the singleton object we found (via updateSingleton).

* public/v3/models/bug.js:
(Bug.ensureSingleton): Moved the code to compute the synthetic id from AnalysisTask's
_constructAnalysisTasksFromRawData.
(Bug.prototype.updateSingleton): Added. Just assert that nothing changes.

* public/v3/models/build-request.js:
(BuildRequest.prototype.updateSingleton): Added. Assert that the intrinsic values of a build request
doesn't change and update status text, status url, and build id as they could change.

* public/v3/models/commit-log.js:
(CommitLog): Made the constructor argument conform to the convention of id, object pair so that we can
use DataModelObject.ensureSingleton.
(CommitLog.ensureSingleton):
(CommitLog.prototype.updateSingleton): Extracted from CommitLog.ensureSingleton.

* public/v3/models/data-model.js:
(DataModelObject.ensureSingleton): Call newly added updateSingleton.
(DataModelObject.prototype.updateSingleton):
(LabeledObject): Removed the name map since it's never used (findByName is never called anywhere).
(LabeledObject.prototype.updateSingleton): Added. Updates _name.
(LabeledObject.findByName): Deleted.

* public/v3/models/test-group.js:
(TestGroup.prototype.updateName): Added. Uses PrivilegedAPI to update the name and re-fetches
the test group from the sever.
(TestGroup._createModelsFromFetchedTestGroups): Removed bogus code. A root set doesn't have a test
group associated with it since multiple test groups can share a single root set (this property doesn't
even exist).

* public/v3/pages/analysis-task-page.js:
(AnalysisTaskPage): Removed useless _taskId and added this._testGroupLabelMap and this._taskNameLabel.
(AnalysisTaskPage.prototype.updateFromSerializedState): Cleanup.
(AnalysisTaskPage.prototype._didFetchTask): Assert that this function is called exactly once.
(AnalysisTaskPage.prototype.render): Use this._task.id() to show the v2 link. Use EditableText to show
the names of the analysis task and the associated test groups. Hide the overview chart and the list of
test groups (along with the retry/confirm button) when the analysis task failed to fetch. We always
update the names of the analysis task and the associated test groups since they could be updated by
the server.
(AnalysisTaskPage.prototype._didStartEditingTaskName): Added.
(AnalysisTaskPage.prototype._updateTaskName): Added.
(AnalysisTaskPage.prototype._updateTestGroupName): Added.
(AnalysisTaskPage.htmlTemplate): Updated the style.

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

12 files changed:
Websites/perf.webkit.org/ChangeLog
Websites/perf.webkit.org/public/privileged-api/update-analysis-task.php
Websites/perf.webkit.org/public/privileged-api/update-test-group.php [new file with mode: 0644]
Websites/perf.webkit.org/public/v3/components/editable-text.js [new file with mode: 0644]
Websites/perf.webkit.org/public/v3/index.html
Websites/perf.webkit.org/public/v3/models/analysis-task.js
Websites/perf.webkit.org/public/v3/models/bug.js
Websites/perf.webkit.org/public/v3/models/build-request.js
Websites/perf.webkit.org/public/v3/models/commit-log.js
Websites/perf.webkit.org/public/v3/models/data-model.js
Websites/perf.webkit.org/public/v3/models/test-group.js
Websites/perf.webkit.org/public/v3/pages/analysis-task-page.js

index f80dd2e..6d9b00e 100644 (file)
@@ -1,3 +1,100 @@
+2016-02-12  Ryosuke Niwa  <rniwa@webkit.org>
+
+        Perf dashboard should allow renaming analysis tasks and test groups
+        https://bugs.webkit.org/show_bug.cgi?id=154200
+
+        Reviewed by Chris Dumez.
+
+        Allow editing names of analysis tasks and A/B testing groups in the v3 UI.
+
+        Added the support for updating the name to the privileged API at /privileged-api/update-analysis-task
+        and added a new prevailed API to update A/B testing groups at /privileged-api/update-test-group.
+
+        * public/privileged-api/update-analysis-task.php: Added the support for renaming the analysis task.
+        (main):
+
+        * public/privileged-api/update-test-group.php: Added. Supports updating the test group's name.
+        (main):
+
+        * public/v3/components/editable-text.js: Added.
+        (EditableText): Added. A new editable text label control. It looks like a text node with "(Edit)" link
+        at the end which allow users to go into the "editing mode", which reveals an input element.
+        The user can exit the editing mode by either moving the focus away from the control or clicking on
+        "(Save)" at the end. It calls _updateCallback in the latter case.
+        (EditableText.prototype.editedText): Returns the current value of the input element user.
+        (EditableText.prototype.setText): Sets the label. This does not live-update the input element until
+        the user exists the current editing mode and re-enters it.
+        (EditableText.prototype.setStartedEditingCallback): Sets a callback which gets called when the user
+        requested to enter the editing mode. Since EditableText relies on AnalysisTaskPage to render, this
+        callback only exits to call EditableText.render() in AnalysisTask._didStartEditingTaskName.
+        (EditableText.prototype.setUpdateCallback): Sets a callback which gets called when the user exits
+        the editing mode by activating the "(Save)" link. This callback MUST return a promise upon resolution
+        of which the control gets out of the editing mode. While the promise is in flight, the input element
+        becomes readonly.
+        (EditableText.prototype.render): Updates various states of the elements. When _updatingPromise is not
+        falsy, we make the input element readonly and show '(...)' on the link. Don't show the action link
+        if the label is empty (e.g. analysis task or test group is still being fetched).
+        (EditableText.prototype._didClick): Called when the user clicked on the action link. Enter the editing
+        mode or save the edited label via _updateCallback.
+        (EditableText.prototype._didBlur): Exit the editing mode without saving if the input element is not
+        focused, there is no inflight promise returned by _updateCallback, and the action link "(Save)" does
+        not have the focus.
+        (EditableText.prototype._didUpdate): Called when exiting the editing mode.
+        (EditableText.htmlTemplate):
+        (EditableText.cssTemplate):
+
+        * public/v3/index.html: Include newly added editable-text.js.
+
+        * public/v3/models/analysis-task.js:
+        (AnalysisTask.prototype.updateSingleton): Added.
+        (AnalysisTask.prototype.updateName): Added. Uses PrivilegedAPI to update the name and re-fetches
+        the analysis task from the sever.
+        (AnalysisTask._constructAnalysisTasksFromRawData): Use ensureSingleton instead of manually calling
+        findById since we need to update the name of the singleton object we found (via updateSingleton).
+
+        * public/v3/models/bug.js:
+        (Bug.ensureSingleton): Moved the code to compute the synthetic id from AnalysisTask's
+        _constructAnalysisTasksFromRawData.
+        (Bug.prototype.updateSingleton): Added. Just assert that nothing changes.
+
+        * public/v3/models/build-request.js:
+        (BuildRequest.prototype.updateSingleton): Added. Assert that the intrinsic values of a build request
+        doesn't change and update status text, status url, and build id as they could change.
+
+        * public/v3/models/commit-log.js:
+        (CommitLog): Made the constructor argument conform to the convention of id, object pair so that we can
+        use DataModelObject.ensureSingleton.
+        (CommitLog.ensureSingleton): 
+        (CommitLog.prototype.updateSingleton): Extracted from CommitLog.ensureSingleton.
+
+        * public/v3/models/data-model.js:
+        (DataModelObject.ensureSingleton): Call newly added updateSingleton.
+        (DataModelObject.prototype.updateSingleton):
+        (LabeledObject): Removed the name map since it's never used (findByName is never called anywhere).
+        (LabeledObject.prototype.updateSingleton): Added. Updates _name.
+        (LabeledObject.findByName): Deleted.
+
+        * public/v3/models/test-group.js:
+        (TestGroup.prototype.updateName): Added. Uses PrivilegedAPI to update the name and re-fetches
+        the test group from the sever.
+        (TestGroup._createModelsFromFetchedTestGroups): Removed bogus code. A root set doesn't have a test
+        group associated with it since multiple test groups can share a single root set (this property doesn't
+        even exist).
+
+        * public/v3/pages/analysis-task-page.js:
+        (AnalysisTaskPage): Removed useless _taskId and added this._testGroupLabelMap and this._taskNameLabel.
+        (AnalysisTaskPage.prototype.updateFromSerializedState): Cleanup.
+        (AnalysisTaskPage.prototype._didFetchTask): Assert that this function is called exactly once.
+        (AnalysisTaskPage.prototype.render): Use this._task.id() to show the v2 link. Use EditableText to show
+        the names of the analysis task and the associated test groups. Hide the overview chart and the list of
+        test groups (along with the retry/confirm button) when the analysis task failed to fetch. We always
+        update the names of the analysis task and the associated test groups since they could be updated by
+        the server.
+        (AnalysisTaskPage.prototype._didStartEditingTaskName): Added.
+        (AnalysisTaskPage.prototype._updateTaskName): Added.
+        (AnalysisTaskPage.prototype._updateTestGroupName): Added.
+        (AnalysisTaskPage.htmlTemplate): Updated the style.
+
 2016-02-11  Ryosuke Niwa  <rniwa@webkit.org>
 
         Land the change that was supposed to be the part of r196463.
index e0b0643..4919a84 100644 (file)
@@ -11,6 +11,9 @@ function main() {
 
     $values = array();
 
+    if (array_key_exists('name', $data))
+        $values['name'] = $data['name'];
+
     if (array_key_exists('result', $data)) {
         require_match_one_of_values('Result', $data['result'], array(null, 'progression', 'regression', 'unchanged', 'inconclusive'));
         $values['result'] = $data['result'];
diff --git a/Websites/perf.webkit.org/public/privileged-api/update-test-group.php b/Websites/perf.webkit.org/public/privileged-api/update-test-group.php
new file mode 100644 (file)
index 0000000..19a9928
--- /dev/null
@@ -0,0 +1,35 @@
+<?php
+
+require_once('../include/json-header.php');
+
+function main() {
+    $data = ensure_privileged_api_data_and_token();
+
+    $test_group_id = array_get($data, 'group');
+    if (!$test_group_id)
+        exit_with_error('TestGroupNotSpecified');
+
+    $values = array();
+
+    if (array_key_exists('name', $data))
+        $values['name'] = $data['name'];
+
+    if (!$values)
+        exit_with_error('NothingToUpdate');
+
+    $db = connect();
+    $db->begin_transaction();
+
+    if (!$db->update_row('analysis_test_groups', 'testgroup', array('id' => $test_group_id), $values)) {
+        $db->rollback_transaction();
+        exit_with_error('FailedToUpdateTestGroup', array('id' => $test_group_id, 'values' => $values));
+    }
+
+    $db->commit_transaction();
+
+    exit_with_success();
+}
+
+main();
+
+?>
diff --git a/Websites/perf.webkit.org/public/v3/components/editable-text.js b/Websites/perf.webkit.org/public/v3/components/editable-text.js
new file mode 100644 (file)
index 0000000..8356501
--- /dev/null
@@ -0,0 +1,123 @@
+
+class EditableText extends ComponentBase {
+
+    constructor(text)
+    {
+        super('editable-text');
+        this._text = text;
+        this._inEditingMode = false;
+        this._startedEditingCallback = null;
+        this._updateCallback = null;
+        this._updatingPromise = null;
+        this._actionLink = this.content().querySelector('.editable-text-action a');
+        this._actionLink.onclick = this._didClick.bind(this);
+        this._actionLink.onmousedown = this._didClick.bind(this);
+        this._textField = this.content().querySelector('.editable-text-field');
+        this._textField.onblur = this._didBlur.bind(this);
+        this._label = this.content().querySelector('.editable-text-label');
+    }
+
+    editedText() { return this._textField.value; }
+    setText(text) { this._text = text; }
+
+    setStartedEditingCallback(callback) { this._startedEditingCallback = callback; }
+    setUpdateCallback(callback) { this._updateCallback = callback; }
+
+    render()
+    {
+        this._label.textContent = this._text;
+        this._actionLink.textContent = this._inEditingMode ? (this._updatingPromise ? '...' : 'Save') : 'Edit';
+        this._actionLink.parentNode.style.display = this._text ? null : 'none';
+
+        if (this._inEditingMode) {
+            this._textField.readOnly = !!this._updatingPromise;
+            this._textField.style.display = null;
+            this._label.style.display = 'none';
+            if (!this._updatingPromise)
+                this._textField.focus();
+        } else {
+            this._textField.style.display = 'none';
+            this._label.style.display = null;
+        }
+
+        super.render();
+    }
+
+    _didClick(event)
+    {
+        event.preventDefault();
+        event.stopPropagation();
+
+        if (!this._updateCallback || this._updatingPromise)
+            return;
+
+        if (this._inEditingMode)
+            this._updatingPromise = this._updateCallback().then(this._didUpdate.bind(this));
+        else {
+            this._inEditingMode = true;
+            this._textField.value = this._text;
+            this._textField.style.width = (this._text.length / 1.5) + 'rem';
+            if (this._startedEditingCallback)
+                this._startedEditingCallback();
+        }
+    }
+
+    _didBlur(event)
+    {
+        var self = this;
+        if (self._inEditingMode && !self._updatingPromise && !self.hasFocus())
+            self._didUpdate();
+    }
+
+    _didUpdate()
+    {
+        this._inEditingMode = false;
+        this._updatingPromise = null;
+        this.render();
+    }
+
+    static htmlTemplate()
+    {
+        return `
+            <span class="editable-text-container">
+                <input type="text" class="editable-text-field">
+                <span class="editable-text-label"></span>
+                <span class="editable-text-action">(<a href="#">Edit</a>)</span>
+            </span>`;
+    }
+
+    static cssTemplate()
+    {
+        return `
+            .editable-text-container {
+                position: relative;
+                padding-right: 2.5rem;
+            }
+            .editable-text-field {
+                background: transparent;
+                margin: 0;
+                padding: 0;
+                color: inherit;
+                font-weight: inherit;
+                font-size: inherit;
+                width: 8rem;
+                border: none;
+            }
+            .editable-text-action {
+                position: absolute;
+                padding-left: 0.2rem;
+                color: #999;
+                font-size: 0.8rem;
+                top: 50%;
+                margin-top: -0.4rem;
+                vertical-align: middle;
+            }
+            .editable-text-action a {
+                color: inherit;
+                text-decoration: none;
+            }
+        `;
+    }
+}
+
+ComponentBase.defineElement('editable-text', EditableText);
index df96047..210cb29 100644 (file)
@@ -66,6 +66,7 @@ Run tools/bundle-v3-scripts to speed up the load time for production.`);
         <script src="components/button-base.js"></script>
         <script src="components/close-button.js"></script>
         <script src="components/commit-log-viewer.js"></script>
+        <script src="components/editable-text.js"></script>
         <script src="components/time-series-chart.js"></script>
         <script src="components/interactive-time-series-chart.js"></script>
         <script src="components/chart-status-view.js"></script>
index 6bb8455..dc9d6a3 100644 (file)
@@ -29,6 +29,27 @@ class AnalysisTask extends LabeledObject {
         return this.all().filter(function (task) { return task._platform.id() == platformId && task._metric.id() == metricId; });
     }
 
+    updateSingleton(object)
+    {
+        super.updateSingleton(object);
+
+        console.assert(this._author == object.author);
+        console.assert(+this._createdAt == +object.createdAt);
+        console.assert(this._platform == object.platform);
+        console.assert(this._metric == object.metric);
+        console.assert(this._startMeasurementId == object.startRun);
+        console.assert(this._startTime == object.startRunTime);
+        console.assert(this._endMeasurementId == object.endRun);
+        console.assert(this._endTime == object.endRunTime);
+
+        this._category = object.category;
+        this._changeType = object.result;
+        this._needed = object.needed;
+        this._bugs = object.bugs || [];
+        this._buildRequestCount = object.buildRequestCount;
+        this._finishedBuildRequestCount = object.finishedBuildRequestCount;
+    }
+
     hasResults() { return this._finishedBuildRequestCount; }
     hasPendingRequests() { return this._finishedBuildRequestCount < this._buildRequestCount; }
     requestLabel() { return `${this._finishedBuildRequestCount} of ${this._buildRequestCount}`; }
@@ -46,6 +67,19 @@ class AnalysisTask extends LabeledObject {
     category() { return this._category; }
     changeType() { return this._changeType; }
 
+    updateName(newName)
+    {
+        var self = this;
+        var id = this.id();
+        return PrivilegedAPI.sendRequest('update-analysis-task', {
+            task: id,
+            name: newName,
+        }).then(function (data) {
+            return AnalysisTask.cachedFetch('../api/analysis-tasks', {id: id}, true)
+                .then(AnalysisTask._constructAnalysisTasksFromRawData.bind(AnalysisTask));
+        });
+    }
+
     static categories()
     {
         return [
@@ -94,12 +128,11 @@ class AnalysisTask extends LabeledObject {
         // FIXME: The backend shouldn't create a separate bug row per task for the same bug number.
         var taskToBug = {};
         for (var rawData of data.bugs) {
-            var id = rawData.bugTracker + '-' + rawData.number;
             rawData.bugTracker = BugTracker.findById(rawData.bugTracker);
             if (!rawData.bugTracker)
                 continue;
 
-            var bug = Bug.findById(id) || new Bug(id, rawData);
+            var bug = Bug.ensureSingleton(rawData);
             if (!taskToBug[rawData.task])
                 taskToBug[rawData.task] = [];
             taskToBug[rawData.task].push(bug);
@@ -113,8 +146,7 @@ class AnalysisTask extends LabeledObject {
                 continue;
 
             rawData.bugs = taskToBug[rawData.id];
-            var task = AnalysisTask.findById(rawData.id) || new AnalysisTask(rawData.id, rawData);
-            results.push(task);
+            results.push(AnalysisTask.ensureSingleton(rawData.id, rawData));
         }
 
         Instrumentation.endMeasuringTime('AnalysisTask', 'construction');
index 9f555a5..0329b7e 100644 (file)
@@ -9,6 +9,20 @@ class Bug extends DataModelObject {
         this._bugNumber = object.number;
     }
 
+    static ensureSingleton(object)
+    {
+        console.assert(object.bugTracker instanceof BugTracker);
+        var id = object.bugTracker.id() + '-' + object.number;
+        return super.ensureSingleton(id, object);
+    }
+
+    updateSingleton(object)
+    {
+        super.updateSingleton(object);
+        console.assert(this._bugTracker == object.bugTracker);
+        console.assert(this._bugNumber == object.number);
+    }
+
     bugTracker() { return this._bugTracker; }
     bugNumber() { return this._bugNumber; }
     url() { return this._bugTracker.bugUrl(this._bugNumber); }
index 52a8ede..a374b1d 100644 (file)
@@ -16,6 +16,16 @@ class BuildRequest extends DataModelObject {
         this._result = null;
     }
 
+    updateSingleton(object)
+    {
+        console.assert(this._testGroup == object.testGroup);
+        console.assert(this._order == object.order);
+        console.assert(this._rootSet == object.rootSet);
+        this._status = object.status;
+        this._statusUrl = object.url;
+        this._buildId = object.build;
+    }
+
     testGroup() { return this._testGroup; }
     order() { return this._order; }
     rootSet() { return this._rootSet; }
index 8476869..b4d16ed 100644 (file)
@@ -1,24 +1,30 @@
 
 class CommitLog extends DataModelObject {
-    constructor(id, repository, rawData)
+    constructor(id, rawData)
     {
         super(id);
-        this._repository = repository;
+        this._repository = rawData.repository;
         this._rawData = rawData;
     }
 
     static ensureSingleton(repository, rawData)
     {
         var id = repository.id() + '-' + rawData['revision'];
-        var singleton = this.findById(id);
-        if (singleton) {
-            if (rawData.authorName)
-                singleton._rawData.authorName = rawData.authorName;
-            if (rawData.message)
-                singleton._rawData.message = rawData.message;
-            return singleton;
-        }
-        return new CommitLog(id, repository, rawData);
+        rawData.repository = repository;
+        return super.ensureSingleton(id, rawData);
+    }
+
+    updateSingleton(rawData)
+    {
+        super.updateSingleton(rawData);
+
+        console.assert(+this._rawData['time'] == +rawData['time']);
+        console.assert(this._rawData['revision'] == rawData['revision']);
+
+        if (rawData.authorName)
+            this._rawData.authorName = rawData.authorName;
+        if (rawData.message)
+            this._rawData.message = rawData.message;
     }
 
     repository() { return this._repository; }
index b5aa0e8..7829a82 100644 (file)
@@ -10,11 +10,15 @@ class DataModelObject {
     static ensureSingleton(id, object)
     {
         var singleton = this.findById(id);
-        if (singleton)
+        if (singleton) {
+            singleton.updateSingleton(object)
             return singleton;
+        }
         return new (this)(id, object);
     }
 
+    updateSingleton(object) { }
+
     static namedStaticMap(name)
     {
         var staticMap = this[DataModelObject.StaticMapSymbol];
@@ -80,14 +84,9 @@ class LabeledObject extends DataModelObject {
     {
         super(id);
         this._name = object.name;
-        this.ensureNamedStaticMap('name')[this._name] = this;
     }
 
-    static findByName(name)
-    {
-        var nameMap = this.namedStaticMap('id');
-        return nameMap ? nameMap[name] : null;
-    }
+    updateSingleton(object) { this._name = object.name; }
 
     static sortByName(list)
     {
index 0b7e62f..8773d72 100644 (file)
@@ -152,6 +152,19 @@ class TestGroup extends LabeledObject {
         return values;
     }
 
+    updateName(newName)
+    {
+        var self = this;
+        var id = this.id();
+        return PrivilegedAPI.sendRequest('update-test-group', {
+            group: id,
+            name: newName,
+        }).then(function (data) {
+            return TestGroup.cachedFetch(`../api/test-groups/${id}`, {}, true)
+                .then(TestGroup._createModelsFromFetchedTestGroups.bind(TestGroup));
+        });
+    }
+
     static createAndRefetchTestGroups(task, name, repetitionCount, rootSets)
     {
         var self = this;
@@ -183,7 +196,6 @@ class TestGroup extends LabeledObject {
 
         var rootSets = data['rootSets'].map(function (row) {
             row.roots = row.roots.map(function (rootId) { return rootIdMap[rootId]; });
-            row.testGroup = RootSet.findById(row.testGroup);
             return RootSet.ensureSingleton(row.id, row);
         });
 
index b18eed4..c5d14a3 100644 (file)
@@ -16,10 +16,10 @@ class AnalysisTaskPage extends PageWithHeading {
     constructor()
     {
         super('Analysis Task');
-        this._taskId = null;
         this._task = null;
         this._testGroups = null;
         this._renderedTestGroups = null;
+        this._testGroupLabelMap = new Map;
         this._renderedCurrentTestGroup = undefined;
         this._analysisResults = null;
         this._measurementSet = null;
@@ -32,6 +32,9 @@ class AnalysisTaskPage extends PageWithHeading {
         this._analysisResultsViewer = this.content().querySelector('analysis-results-viewer').component();
         this._analysisResultsViewer.setTestGroupCallback(this._showTestGroup.bind(this));
         this._testGroupResultsTable = this.content().querySelector('test-group-results-table').component();
+        this._taskNameLabel = this.content().querySelector('.analysis-task-name editable-text').component();
+        this._taskNameLabel.setStartedEditingCallback(this._didStartEditingTaskName.bind(this));
+        this._taskNameLabel.setUpdateCallback(this._updateTaskName.bind(this));
 
         this.content().querySelector('.test-group-retry-form').onsubmit = this._retryCurrentTestGroup.bind(this);
     }
@@ -43,20 +46,18 @@ class AnalysisTaskPage extends PageWithHeading {
     {
         var self = this;
         if (state.remainingRoute) {
-            this._taskId = parseInt(state.remainingRoute);
-            AnalysisTask.fetchById(this._taskId).then(this._didFetchTask.bind(this), function (error) {
+            var taskId = parseInt(state.remainingRoute);
+            AnalysisTask.fetchById(taskId).then(this._didFetchTask.bind(this), function (error) {
                 self._errorMessage = `Failed to fetch the analysis task ${state.remainingRoute}: ${error}`;
                 self.render();
             });
-            TestGroup.fetchByTask(this._taskId).then(this._didFetchTestGroups.bind(this));
-            AnalysisResults.fetch(this._taskId).then(this._didFetchAnalysisResults.bind(this));
+            TestGroup.fetchByTask(taskId).then(this._didFetchTestGroups.bind(this));
+            AnalysisResults.fetch(taskId).then(this._didFetchAnalysisResults.bind(this));
         } else if (state.buildRequest) {
             var buildRequestId = parseInt(state.buildRequest);
-            AnalysisTask.fetchByBuildRequestId(buildRequestId).then(this._didFetchTask.bind(this)).then(function () {
-                if (self._task) {
-                    TestGroup.fetchByTask(self._task.id()).then(self._didFetchTestGroups.bind(self));
-                    AnalysisResults.fetch(self._task.id()).then(this._didFetchAnalysisResults.bind(this));
-                }
+            AnalysisTask.fetchByBuildRequestId(buildRequestId).then(this._didFetchTask.bind(this)).then(function (task) {
+                TestGroup.fetchByTask(task.id()).then(self._didFetchTestGroups.bind(self));
+                AnalysisResults.fetch(task.id()).then(self._didFetchAnalysisResults.bind(self));
             }, function (error) {
                 self._errorMessage = `Failed to fetch the analysis task for the build request ${buildRequestId}: ${error}`;
                 self.render();
@@ -66,6 +67,8 @@ class AnalysisTaskPage extends PageWithHeading {
 
     _didFetchTask(task)
     {
+        console.assert(!this._task);
+
         this._task = task;
         var platform = task.platform();
         var metric = task.metric();
@@ -85,6 +88,8 @@ class AnalysisTaskPage extends PageWithHeading {
         this._chartPane.setMainDomain(domain[0], domain[1]);
 
         this.render();
+
+        return task;
     }
 
     _didFetchMeasurement()
@@ -146,20 +151,25 @@ class AnalysisTaskPage extends PageWithHeading {
 
         this.content().querySelector('.error-message').textContent = this._errorMessage || '';
 
-        var v2URL = `/v2/#/analysis/task/${this._taskId}`;
-        this.content().querySelector('.error-message').innerHTML +=
-            `<p>To schedule a custom A/B testing, use <a href="${v2URL}">v2 UI</a>.</p>`;
+        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();
+        this._chartPane.render();
 
         if (this._task) {
-            this.renderReplace(this.content().querySelector('.analysis-task-name'), this._task.name());
+            this._taskNameLabel.setText(this._task.name());
             var platform = this._task.platform();
             var metric = this._task.metric();
             var anchor = this.content().querySelector('.platform-metric-names a');
             this.renderReplace(anchor, metric.fullName() + ' on ' + platform.label());
             anchor.href = this.router().url('charts', ChartsPage.createStateForAnalysisTask(this._task));
         }
+        this.content().querySelector('.overview-chart').style.display = this._task ? null : 'none';
+        this.content().querySelector('.test-group-view').style.display = this._task ? null : 'none';
+        this._taskNameLabel.render();
 
         this._analysisResultsViewer.setCurrentTestGroup(this._currentTestGroup);
         this._analysisResultsViewer.render();
@@ -168,16 +178,34 @@ class AnalysisTaskPage extends PageWithHeading {
         var link = ComponentBase.createLink;
         if (this._testGroups != this._renderedTestGroups) {
             this._renderedTestGroups = this._testGroups;
+            this._testGroupLabelMap.clear();
+
             var self = this;
+            var updateTestGroupName = this._updateTestGroupName.bind(this);
+            var showTestGroup = this._showTestGroup.bind(this);
+
             this.renderReplace(this.content().querySelector('.test-group-list'),
                 this._testGroups.map(function (group) {
-                    return element('li', {class: 'test-group-list-' + group.id()}, link(group.label(), function () {
-                        self._showTestGroup(group);
-                    }));
+                    var text = new EditableText(group.label());
+                    text.setStartedEditingCallback(function () { return text.render(); });
+                    text.setUpdateCallback(function () { return updateTestGroupName(group); });
+
+                    self._testGroupLabelMap.set(group, text);
+                    return element('li', {class: 'test-group-list-' + group.id()},
+                        link(text, group.label(), function () { showTestGroup(group); }));
                 }).reverse());
+
             this._renderedCurrentTestGroup = null;
         }
 
+        if (this._testGroups) {
+            for (var testGroup of this._testGroups) {
+                var label = this._testGroupLabelMap.get(testGroup);
+                label.setText(testGroup.label());
+                label.render();
+            }
+        }
+
         if (this._renderedCurrentTestGroup !== this._currentTestGroup) {
             if (this._renderedCurrentTestGroup) {
                 var element = this.content().querySelector('.test-group-list-' + this._renderedCurrentTestGroup.id());
@@ -222,6 +250,39 @@ class AnalysisTaskPage extends PageWithHeading {
         this.render();
     }
 
+    _didStartEditingTaskName()
+    {
+        this._taskNameLabel.render();
+    }
+
+    _updateTaskName()
+    {
+        console.assert(this._task);
+        this._taskNameLabel.render();
+
+        var self = this;
+        return self._task.updateName(self._taskNameLabel.editedText()).then(function () {
+            self.render();
+        }, function (error) {
+            self.render();
+            alert('Failed to update the name: ' + error);
+        });
+    }
+
+    _updateTestGroupName(testGroup)
+    {
+        var label = this._testGroupLabelMap.get(testGroup);
+        label.render();
+
+        var self = this;
+        return testGroup.updateName(label.editedText()).then(function () {
+            self.render();
+        }, function (error) {
+            self.render();
+            alert('Failed to update the name: ' + error);
+        });
+    }
+
     _retryCurrentTestGroup(event)
     {
         event.preventDefault();
@@ -307,7 +368,7 @@ class AnalysisTaskPage extends PageWithHeading {
         return `
         <div class="analysis-tasl-page-container">
             <div class="analysis-tasl-page">
-                <h2 class="analysis-task-name"></h2>
+                <h2 class="analysis-task-name"><editable-text></editable-text></h2>
                 <h3 class="platform-metric-names"><a href=""></a></h3>
                 <p class="error-message"></p>
                 <div class="overview-chart"><analysis-task-chart-pane></analysis-task-chart-pane></div>
@@ -425,11 +486,11 @@ class AnalysisTaskPage extends PageWithHeading {
                 border-right: none;
             }
 
-            .test-group-list li {
+            .test-group-list li {
                 display: block;
             }
 
-            .test-group-list a {
+            .test-group-list > li > a {
                 display: block;
                 color: inherit;
                 text-decoration: none;
@@ -438,11 +499,11 @@ class AnalysisTaskPage extends PageWithHeading {
                 padding: 0.2rem;
             }
 
-            .test-group-list li.selected a {
+            .test-group-list > li.selected > a {
                 background: rgba(204, 153, 51, 0.1);
             }
 
-            .test-group-list li:not(.selected) a:hover {
+            .test-group-list > li:not(.selected) > a:hover {
                 background: #eee;
             }