Add UI for A/B testing on owned commits.
[WebKit-https.git] / Websites / perf.webkit.org / public / v3 / components / customizable-test-group-form.js
1 class CustomizableTestGroupForm extends TestGroupForm {
2
3     constructor()
4     {
5         super('customizable-test-group-form');
6         this._commitSetMap = null;
7         this._name = null;
8         this._isCustomized = false;
9         this._revisionEditorMap = null;
10         this._ownerRevisionMap = null;
11         this._checkedLabelByPosition = null;
12         this._hasIncompleteOwnedRepository = null;
13         this._fetchingCommitPromises = [];
14     }
15
16     setCommitSetMap(map)
17     {
18         this._commitSetMap = map;
19         this._isCustomized = false;
20         this._fetchingCommitPromises = [];
21         this._checkedLabelByPosition = new Map;
22         this._hasIncompleteOwnedRepository = new Map;
23         this.enqueueToRender();
24     }
25
26     startTesting()
27     {
28         this.dispatchAction('startTesting', this._repetitionCount, this._name, this._computeCommitSetMap());
29     }
30
31     didConstructShadowTree()
32     {
33         super.didConstructShadowTree();
34
35         const nameControl = this.content('name');
36         nameControl.oninput = () => {
37             this._name = nameControl.value;
38             this.enqueueToRender();
39         };
40
41         this.content('customize-link').onclick = this.createEventHandler(() => {
42             if (!this._isCustomized) {
43                 const originalCommitSetMap = this._commitSetMap;
44                 const fetchingCommitPromises = [];
45                 this._commitSetMap = new Map;
46                 for (const label in originalCommitSetMap) {
47                     const intermediateCommitSet = new IntermediateCommitSet(originalCommitSetMap[label]);
48                     fetchingCommitPromises.push(intermediateCommitSet.fetchCommitLogs());
49                     this._commitSetMap.set(label, intermediateCommitSet);
50                 }
51                 this._fetchingCommitPromises = fetchingCommitPromises;
52
53                 return Promise.all(fetchingCommitPromises).then(() => {
54                     if (this._fetchingCommitPromises !== fetchingCommitPromises)
55                         return;
56                     this._isCustomized = true;
57                     this._fetchingCommitPromises = [];
58                     for (const label in originalCommitSetMap)
59                         this._checkedLabelByPosition.set(label, new Map);
60                     this.enqueueToRender();
61                 });
62             }
63             this._isCustomized = true;
64             this.enqueueToRender();
65         });
66     }
67
68     _computeCommitSetMap()
69     {
70         console.assert(this._commitSetMap);
71         if (!this._isCustomized)
72             return this._commitSetMap;
73
74         const map = {};
75         for (const [label, commitSet] of this._commitSetMap) {
76             const customCommitSet = new CustomCommitSet;
77             for (const repository of commitSet.repositories()) {
78                 const revisionEditor = this._revisionEditorMap.get(label).get(repository);
79                 console.assert(revisionEditor);
80                 const ownerRevision = this._ownerRevisionMap.get(label).get(repository) || null;
81
82                 customCommitSet.setRevisionForRepository(repository, revisionEditor.value, null, ownerRevision);
83             }
84             map[label] = customCommitSet;
85         }
86         return map;
87     }
88
89     render()
90     {
91         super.render();
92
93         this.content('start-button').disabled = !(this._commitSetMap && this._name);
94         this.content('customize-link-container').style.display = !this._commitSetMap ? 'none' : null;
95
96         this._renderCustomRevisionTable(this._commitSetMap, this._isCustomized);
97     }
98
99     _renderCustomRevisionTable(commitSetMap, isCustomized)
100     {
101         if (!commitSetMap || !isCustomized) {
102             this.renderReplace(this.content('custom-table'), []);
103             return;
104         }
105
106         const repositorySet = new Set;
107         const ownedRepositoriesByRepository = new Map;
108         const commitSetLabels = [];
109         this._revisionEditorMap = new Map;
110         this._ownerRevisionMap = new Map;
111         for (const [label, commitSet] of commitSetMap) {
112             for (const repository of commitSet.highestLevelRepositories()) {
113                 repositorySet.add(repository);
114                 const ownedRepositories = commitSetMap.get(label).ownedRepositoriesForOwnerRepository(repository);
115
116                 if (!ownedRepositories)
117                     continue;
118
119                 if (!ownedRepositoriesByRepository.has(repository))
120                     ownedRepositoriesByRepository.set(repository, new Set);
121                 const ownedRepositorySet = ownedRepositoriesByRepository.get(repository);
122                 for (const ownedRepository of ownedRepositories)
123                     ownedRepositorySet.add(ownedRepository)
124             }
125             commitSetLabels.push(label);
126             this._revisionEditorMap.set(label, new Map);
127             this._ownerRevisionMap.set(label, new Map);
128         }
129
130         const repositoryList = Repository.sortByNamePreferringOnesWithURL(Array.from(repositorySet.values()));
131         const element = ComponentBase.createElement;
132         this.renderReplace(this.content('custom-table'), [
133             element('thead',
134                 element('tr',
135                     [element('td', {colspan: 2}, 'Repository'), commitSetLabels.map((label) => element('td', {colspan: commitSetLabels.length + 1}, label)), element('td')])),
136             this._constructTableBodyList(repositoryList, commitSetMap, ownedRepositoriesByRepository, this._hasIncompleteOwnedRepository)]);
137     }
138
139     _constructTableBodyList(repositoryList, commitSetMap, ownedRepositoriesByRepository, hasIncompleteOwnedRepository)
140     {
141         const element = ComponentBase.createElement;
142         const tableBodyList = [];
143         for(const repository of repositoryList) {
144             const rows = [];
145             const allCommitSetSpecifiesOwnerCommit = Array.from(commitSetMap.values()).every((commitSet) => commitSet.ownsCommitsForRepository(repository));
146             const hasIncompleteRow = hasIncompleteOwnedRepository.get(repository);
147             const ownedRepositories = ownedRepositoriesByRepository.get(repository);
148
149             rows.push(this._constructTableRowForCommitsWithoutOwner(commitSetMap, repository, allCommitSetSpecifiesOwnerCommit, hasIncompleteOwnedRepository));
150
151             if ((!ownedRepositories || !ownedRepositories.size) && !hasIncompleteRow) {
152                 tableBodyList.push(element('tbody', rows));
153                 continue;
154             }
155
156             if (ownedRepositories) {
157                 for (const ownedRepository of ownedRepositories)
158                     rows.push(this._constructTableRowForCommitsWithOwner(commitSetMap, ownedRepository, repository));
159             }
160
161             if (hasIncompleteRow) {
162                 const commits = Array.from(commitSetMap.values()).map((commitSet) => commitSet.commitForRepository(repository));
163                 const commitDiff = CommitLog.ownedCommitDifferenceForOwnerCommits(...commits);
164                 rows.push(this._constructTableRowForIncompleteOwnedCommits(commitSetMap, repository, commitDiff));
165             }
166             tableBodyList.push(element('tbody', rows));
167         }
168         return tableBodyList;
169     }
170
171     _constructTableRowForCommitsWithoutOwner(commitSetMap, repository, ownsRepositories, hasIncompleteOwnedRepository)
172     {
173         const element = ComponentBase.createElement;
174         const cells = [element('th', {colspan: 2}, repository.label())];
175
176         for (const label of commitSetMap.keys())
177             cells.push(this._constructRevisionRadioButtons(commitSetMap, repository, label, null, ownsRepositories));
178
179         if (ownsRepositories) {
180             const plusButton = new PlusButton();
181             plusButton.setDisabled(hasIncompleteOwnedRepository.get(repository));
182             plusButton.listenToAction('activate', () => {
183                 this._hasIncompleteOwnedRepository.set(repository, true);
184                 this.enqueueToRender();
185             });
186             cells.push(element('td', plusButton));
187         } else
188             cells.push(element('td'));
189
190         return element('tr', cells);
191     }
192
193     _constructTableRowForCommitsWithOwner(commitSetMap, repository, ownerRepository)
194     {
195         const element = ComponentBase.createElement;
196         const cells = [element('td', {class: 'owner-repository-label'}), element('th', repository.label())];
197         const minusButton = new MinusButton();
198
199         for (const label of commitSetMap.keys())
200             cells.push(this._constructRevisionRadioButtons(commitSetMap, repository, label, ownerRepository, false));
201
202         minusButton.listenToAction('activate', () => {
203             for (const commitSet of commitSetMap.values())
204                 commitSet.removeCommitForRepository(repository)
205             this.enqueueToRender();
206         });
207         cells.push(element('td', minusButton));
208         return element('tr', cells);
209     }
210
211     _constructTableRowForIncompleteOwnedCommits(commitSetMap, ownerRepository, commitDiff)
212     {
213         const element = ComponentBase.createElement;
214         const configurationCount =  commitSetMap.size;
215         const numberOfCellsPerConfiguration = configurationCount + 1;
216         const changedRepositories = Array.from(commitDiff.keys());
217         const minusButton = new MinusButton();
218         const comboBox = new ComboBox(changedRepositories.map((repository) => repository.name()).sort());
219
220         comboBox.listenToAction('update', (repositoryName) => {
221             const targetRepository = changedRepositories.find((repository) => repositoryName === repository.name());
222             const ownedCommitDifferenceForRepository = Array.from(commitDiff.get(targetRepository).values());
223             const commitSetList = Array.from(commitSetMap.values());
224
225             console.assert(ownedCommitDifferenceForRepository.length === commitSetList.length);
226             for (let i = 0; i < commitSetList.length; i++)
227                 commitSetList[i].setCommitForRepository(targetRepository, ownedCommitDifferenceForRepository[i]);
228             this._hasIncompleteOwnedRepository.set(ownerRepository, false);
229             this.enqueueToRender();
230         });
231
232         const cells = [element('td', {class: 'owner-repository-label'}), element('th', comboBox), element('td', {colspan: configurationCount * numberOfCellsPerConfiguration})];
233
234         minusButton.listenToAction('activate', () => {
235             this._hasIncompleteOwnedRepository.set(ownerRepository, false);
236             this.enqueueToRender();
237         });
238         cells.push(element('td', minusButton));
239
240         return element('tr', cells);
241     }
242
243     _constructRevisionRadioButtons(commitSetMap, repository, rowLabel, ownerRepository, ownsRepositories)
244     {
245         const element = ComponentBase.createElement;
246         const revisionEditor = element('input', {disabled: !!ownerRepository,
247             onchange: () => {
248                 if (!ownsRepositories)
249                     return;
250                 commitSetMap.get(rowLabel).updateRevisionForOwnerRepository(repository, revisionEditor.value).catch(
251                     () => revisionEditor.value = '');
252             }});
253
254         this._revisionEditorMap.get(rowLabel).set(repository, revisionEditor);
255
256         const nodes = [];
257         for (const labelToChoose of commitSetMap.keys()) {
258             const commit = commitSetMap.get(labelToChoose).commitForRepository(repository);
259             const checkedLabel = this._checkedLabelByPosition.get(rowLabel).get(repository) || rowLabel;
260             const checked =  labelToChoose == checkedLabel;
261             const radioButton = element('input', {type: 'radio', name: `${rowLabel}-${repository.id()}-radio`, checked,
262                 onchange: () => {
263                     this._checkedLabelByPosition.get(rowLabel).set(repository, labelToChoose);
264                     revisionEditor.value = commit ? commit.revision() : '';
265                     if (commit && commit.ownerCommit())
266                         this._ownerRevisionMap.get(rowLabel).set(repository, commit.ownerCommit().revision());
267                 }});
268
269             if (checked) {
270                 revisionEditor.value = commit ? commit.revision() : '';
271                 if (commit && commit.ownerCommit())
272                     this._ownerRevisionMap.get(rowLabel).set(repository, commit.ownerCommit().revision());
273             }
274             nodes.push(element('td', element('label', [radioButton, labelToChoose])));
275         }
276         nodes.push(element('td', revisionEditor));
277
278         return nodes;
279     }
280
281     static cssTemplate()
282     {
283         return `
284             #customize-link-container,
285             #customize-link {
286                 color: #333;
287             }
288
289             #custom-table:not(:empty) {
290                 margin: 1rem 0;
291             }
292
293             #custom-table td.owner-repository-label {
294                 border-top: solid 2px transparent;
295                 border-bottom: solid 1px transparent;
296                 min-width: 2rem;
297                 text-align: right;
298             }
299
300             #custom-table tr:last-child td.owner-repository-label {
301                 border-bottom: solid 1px #ddd;
302             }
303
304             #custom-table th {
305                 min-width: 12rem;
306             }
307
308             #custom-table,
309             #custom-table td,
310             #custom-table th {
311                 font-weight: inherit;
312                 border-collapse: collapse;
313                 border-top: solid 1px #ddd;
314                 border-bottom: solid 1px #ddd;
315                 padding: 0.4rem 0.2rem;
316                 font-size: 0.9rem;
317             }
318
319             #custom-table thead td,
320             #custom-table th {
321                 text-align: center;
322             }
323             `;
324     }
325
326     static formContent()
327     {
328         return `
329             <input id="name" type="text" placeholder="Test group name">
330             ${super.formContent()}
331             <span id="customize-link-container">(<a id="customize-link" href="#">Customize</a>)</span>
332             <table id="custom-table"></table>
333         `;
334     }
335 }
336
337 ComponentBase.defineElement('customizable-test-group-form', CustomizableTestGroupForm);