Introduce CodeOwnersModel and ModelLoader and use them in UI elements
This change removes direct dependencies between CodeOwnersService
and UI elements. Instead, UI elements displays/uses model as a
data source and calls ModelLoader when a model property should be
populated with a data.
This change doesn't update suggest-owners.js (it displays the
list of suggested owners). The suggest-owners.js is updated in the
upcoming change.
Change-Id: I9d4c4fda6eac4aab674a9928db9f1c408f93b8f3
diff --git a/.eslintrc.json b/.eslintrc.json
index b38415b..2c0d24a 100644
--- a/.eslintrc.json
+++ b/.eslintrc.json
@@ -101,7 +101,8 @@
"Polymer",
"LegacyElementMixin",
"GestureEventListeners",
- "LegacyDataMixin"
+ "LegacyDataMixin",
+ "CodeOwnersModelMixin"
]
}
],
diff --git a/ui/code-owners-model-loader.js b/ui/code-owners-model-loader.js
new file mode 100644
index 0000000..ea10813
--- /dev/null
+++ b/ui/code-owners-model-loader.js
@@ -0,0 +1,78 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * ModelLoader provides a method for loading data into the model.
+ * It is a bridge between an ownersModel and an ownersService.
+ * When UI elements depends on a model property XXX, the element should
+ * observe (or bind to) the property and call modelLoader.loadXXX method
+ * to load the value.
+ * It is recommended to use CodeOwnersModelMixin and call all load... methods
+ * in the loadPropertiesAfterModelChanged method
+ */
+export class ModelLoader {
+ constructor(ownersService, ownersModel) {
+ this.ownersService = ownersService;
+ this.ownersModel = ownersModel;
+ }
+
+ async _loadProperty(propertyName, propertyLoader, propertySetter) {
+ // Load property only if it was not already loaded
+ if (this.ownersModel[propertyName] !== undefined) return;
+ const newValue = await propertyLoader();
+ // It is possible, that several requests is made in parallel.
+ // Store only the first result and discard all other results.
+ // (also, due to the CodeOwnersCacheApi all result must be identical)
+ if (this.ownersModel[propertyName] !== undefined) return;
+ propertySetter(newValue);
+ }
+
+ async loadBranchConfig() {
+ await this._loadProperty('branchConfig',
+ () => this.ownersService.getBranchConfig(),
+ value => this.ownersModel.setBranchConfig(value)
+ );
+ }
+
+ async loadStatus() {
+ await this._loadProperty('status',
+ () => this.ownersService.getStatus(),
+ value => this.ownersModel.setStatus(value)
+ );
+ }
+
+ async loadUserRole() {
+ await this._loadProperty('userRole',
+ () => this.ownersService.getLoggedInUserInitialRole(),
+ value => this.ownersModel.setUserRole(value)
+ );
+ }
+
+ async loadIsCodeOwnerEnabled() {
+ await this._loadProperty('isCodeOwnerEnabled',
+ () => this.ownersService.isCodeOwnerEnabled(),
+ value => this.ownersModel.setIsCodeOwnerEnabled(value)
+ );
+ }
+
+ async loadAreAllFilesApproved() {
+ await this._loadProperty('areAllFilesApproved',
+ () => this.ownersService.areAllFilesApproved(),
+ value => this.ownersModel.setAreAllFilesApproved(value)
+ );
+ }
+}
diff --git a/ui/code-owners-model-mixin.js b/ui/code-owners-model-mixin.js
new file mode 100644
index 0000000..722af6f
--- /dev/null
+++ b/ui/code-owners-model-mixin.js
@@ -0,0 +1,100 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {CodeOwnerService} from './code-owners-service.js';
+import {ModelLoader} from './code-owners-model-loader.js';
+import {CodeOwnersModel} from './code-owners-model.js';
+
+/**
+ * The CodeOwnersMixin adds several properties to a class and translates
+ * 'model-property-changed' events from the model to the notifyPath calls.
+ * This allows to use properties of the model in observers, calculated
+ * properties and bindings.
+ * It is guaranteed, that model and modelLoader are set only if change,
+ * reporting and restApi properties are set.
+ */
+export const CodeOwnersModelMixin = Polymer.dedupingMixin(base => {
+ return class extends base {
+ constructor(...args) {
+ super(...args);
+ /**
+ * The modelLoader allows an element to request a property
+ * Typically should be used in loadPropertiesAfterModelChanged
+ * to ensure that all required model properties are loaded
+ */
+ this.modelLoader = undefined;
+ }
+ static get properties() {
+ return {
+ /* The following 3 properties (change, reporting, restApi) have to be
+ * set from the outside for the mixin to work.
+ */
+ change: Object,
+ reporting: Object,
+ restApi: Object,
+ model: {
+ type: Object,
+ observer: '_modelChanged',
+ },
+ };
+ }
+
+ static get observers() {
+ return ['onInputChanged(restApi, change, reporting)'];
+ }
+
+ onInputChanged(restApi, change, reporting) {
+ if ([restApi, change, reporting].includes(undefined)) {
+ this.model = undefined;
+ this.modelLoader = undefined;
+ return;
+ }
+ const ownerService = CodeOwnerService.getOwnerService(
+ this.restApi,
+ this.change
+ );
+ const model = CodeOwnersModel.getModel(change);
+ this.modelLoader = new ModelLoader(ownerService, model);
+ // Assign model after modelLoader, so modelLoader can be used in
+ // the _requirePropertiesAfterModelChanged method
+ this.model = model;
+ }
+
+ _modelChanged(newModel) {
+ if (this.modelPropertyChangedUnsubscriber) {
+ this.modelPropertyChangedUnsubscriber();
+ this.modelPropertyChangedUnsubscriber = undefined;
+ }
+ if (!newModel) return;
+ const propertyChangedListener = e => {
+ this.notifyPath(`model.${e.detail.propertyName}`);
+ };
+ newModel.addEventListener('model-property-changed',
+ propertyChangedListener);
+ this.modelPropertyChangedUnsubscriber = () => {
+ newModel.removeEventListener('model-property-changed',
+ propertyChangedListener);
+ };
+ this.loadPropertiesAfterModelChanged();
+ }
+
+ loadPropertiesAfterModelChanged() {
+ // The class should override this method and calls appropriate methods
+ // from this.modelLoader to ensure that all required properties are
+ // set in model.
+ }
+ };
+});
diff --git a/ui/code-owners-model.js b/ui/code-owners-model.js
new file mode 100644
index 0000000..5600773
--- /dev/null
+++ b/ui/code-owners-model.js
@@ -0,0 +1,90 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Maintain the state of code-owners.
+ * Raises 'model-property-changed' event when a property is changed.
+ * The plugin shares the same model between all UI elements (thought it is not
+ * required).
+ * UI elements use values from this model to display information
+ * and listens for the model-property-changed event. To do so, UI elements
+ * add CodeOwnersModelMixin, which is doing the listening and the translation
+ * from model-property-changed event to Polymer property-changed-event. The
+ * translation allows to use model properties in observables, bindings,
+ * computed properties, etc...
+ * The CodeOwnersModelLoader updates the model.
+ *
+ * It would be good to use RxJs Observable for implementing model properties.
+ * However, RxJs library is imported by Gerrit and there is no
+ * good way to reuse the same library in the plugin.
+ */
+export class CodeOwnersModel extends EventTarget {
+ constructor(change) {
+ super();
+ this.change = change;
+ this.branchConfig = undefined;
+ this.status = undefined;
+ this.userRole = undefined;
+ this.isCodeOwnerEnabled = undefined;
+ this.areAllFilesApproved = undefined;
+ }
+
+ setBranchConfig(config) {
+ if (this.branchConfig === config) return;
+ this.branchConfig = config;
+ this._firePropertyChanged('branchConfig');
+ }
+
+ setStatus(status) {
+ if (this.status === status) return;
+ this.status = status;
+ this._firePropertyChanged('status');
+ }
+
+ setUserRole(userRole) {
+ if (this.userRole === userRole) return;
+ this.userRole = userRole;
+ this._firePropertyChanged('userRole');
+ }
+
+ setIsCodeOwnerEnabled(enabled) {
+ if (this.isCodeOwnerEnabled === enabled) return;
+ this.isCodeOwnerEnabled = enabled;
+ this._firePropertyChanged('isCodeOwnerEnabled');
+ }
+
+ setAreAllFilesApproved(approved) {
+ if (this.areAllFilesApproved === approved) return;
+ this.areAllFilesApproved = approved;
+ this._firePropertyChanged('areAllFilesApproved');
+ }
+
+ _firePropertyChanged(propertyName) {
+ this.dispatchEvent(new CustomEvent('model-property-changed', {
+ detail: {
+ propertyName,
+ },
+ }));
+ }
+
+ static getModel(change) {
+ if (!this.model || this.model.change !== change) {
+ this.model = new CodeOwnersModel(change);
+ }
+ return this.model;
+ }
+}
diff --git a/ui/code-owners-service.js b/ui/code-owners-service.js
index 9f29d6d..52638ac 100644
--- a/ui/code-owners-service.js
+++ b/ui/code-owners-service.js
@@ -546,7 +546,7 @@
this.codeOwnerCacheApi = new CodeOwnersCacheApi(codeOwnerApi, change);
}
- getBranchConfig() {
+ async getBranchConfig() {
return this.codeOwnerCacheApi.getBranchConfig();
}
@@ -570,3 +570,4 @@
return this.ownerService;
}
}
+
diff --git a/ui/owner-requirement.js b/ui/owner-requirement.js
index 44d14ca..b7b9dbb 100644
--- a/ui/owner-requirement.js
+++ b/ui/owner-requirement.js
@@ -15,8 +15,9 @@
* limitations under the License.
*/
-import {CodeOwnerService, OwnerStatus} from './code-owners-service.js';
+import {OwnerStatus} from './code-owners-service.js';
import {ownerState} from './owner-ui-state.js';
+import {CodeOwnersModelMixin} from './code-owners-model-mixin.js';
/**
* Owner requirement control for `submit-requirement-item-code-owners` endpoint.
@@ -24,7 +25,8 @@
* This will show the status and suggest owners button next to
* the code-owners submit requirement.
*/
-export class OwnerRequirementValue extends Polymer.Element {
+export class OwnerRequirementValue extends
+ CodeOwnersModelMixin(Polymer.Element) {
static get is() {
return 'owner-requirement-value';
}
@@ -80,83 +82,82 @@
static get properties() {
return {
- change: Object,
- reporting: Object,
- restApi: Object,
-
- ownerService: Object,
-
_statusCount: Object,
_isLoading: {
type: Boolean,
- value: true,
+ computed: '_computeIsLoading(model.branchConfig, model.status, '
+ + 'model.userRole)',
},
_allApproved: {
type: Boolean,
computed: '_computeAllApproved(_statusCount)',
},
- _isOverriden: Boolean,
- _overrideInfoUrl: String,
+ _isOverriden: {
+ type: Boolean,
+ computed: '_computeIsOverriden(model.branchConfig)',
+ },
+ _overrideInfoUrl: {
+ type: String,
+ computed: '_computeOverrideInfoUrl(model.branchConfig)',
+ },
};
}
static get observers() {
return [
- 'onInputChanged(restApi, change, reporting)',
+ '_onStatusChanged(model.status, model.userRole)',
];
}
- _checkIfOverriden() {
- this.ownerService.getBranchConfig().then(res => {
- if (!res['override_approval']) {
- // no override label configured
- this._isOverriden = false;
- return;
- }
-
- const overridenLabel = res['override_approval'].label;
- const overridenValue = res['override_approval'].value;
-
- if (this.change.labels[overridenLabel]) {
- const votes = this.change.labels[overridenLabel].all || [];
- if (votes.find(v => `${v.value}` === `${overridenValue}`)) {
- this._isOverriden = true;
- return;
- }
- }
-
- // otherwise always reset it to false
- this._isOverriden = false;
- });
- }
-
- _updateStatus() {
- this._isLoading = true;
+ loadPropertiesAfterModelChanged() {
+ super.loadPropertiesAfterModelChanged();
this.reporting.reportLifeCycle('owners-submit-requirement-summary-start');
-
- return this.ownerService.getStatus()
- .then(({rawStatuses}) => {
- this._statusCount = this._getStatusCount(rawStatuses);
- this.ownerService.getLoggedInUserInitialRole().then(role => {
- // Send a metric with overall summary when code owners submit
- // requirement shown and finished fetching status
- this.reporting.reportLifeCycle(
- 'owners-submit-requirement-summary-shown',
- {...this._statusCount, user_role: role}
- );
- });
- })
- .finally(() => {
- this._isLoading = false;
- });
+ this.modelLoader.loadBranchConfig();
+ this.modelLoader.loadStatus();
+ this.modelLoader.loadUserRole();
}
- _updateOverrideInfoUrl() {
- this.ownerService.getBranchConfig().then(config => {
- this._overrideInfoUrl = config.general && config.general.override_info_url
- ?
- config.general.override_info_url : '';
- });
+ _computeIsLoading(branchConfig, status, userRole) {
+ return !branchConfig || !status || !userRole;
+ }
+
+ _onStatusChanged(status, userRole) {
+ if (!status || !userRole) {
+ this._statusCount = undefined;
+ return;
+ }
+ const rawStatuses = status.rawStatuses;
+ this._statusCount = this._getStatusCount(rawStatuses);
+ this.reporting.reportLifeCycle('owners-submit-requirement-summary-shown',
+ {...this._statusCount, user_role: userRole});
+ }
+
+ _computeOverrideInfoUrl(branchConfig) {
+ if (!branchConfig) {
+ return '';
+ }
+ return branchConfig.general && branchConfig.general.override_info_url
+ ? branchConfig.general.override_info_url : '';
+ }
+
+ _computeIsOverriden(branchConfig) {
+ if (!branchConfig || !branchConfig['override_approval']) {
+ // no override label configured
+ return false;
+ }
+
+ const overridenLabel = branchConfig['override_approval'].label;
+ const overridenValue = branchConfig['override_approval'].value;
+
+ if (this.change.labels[overridenLabel]) {
+ const votes = this.change.labels[overridenLabel].all || [];
+ if (votes.find(v => `${v.value}` === `${overridenValue}`)) {
+ return true;
+ }
+ }
+
+ // otherwise always reset it to false
+ return false;
}
_computeAllApproved(statusCount) {
@@ -188,6 +189,7 @@
}
_computeStatusText(statusCount, isOverriden) {
+ if (statusCount === undefined || isOverriden === undefined) return '';
const statusText = [];
if (statusCount.missing) {
statusText.push(`${statusCount.missing} missing`);
@@ -212,14 +214,6 @@
return status === OwnerStatus.PENDING;
}
- onInputChanged(restApi, change, reporting) {
- if ([restApi, change, reporting].includes(undefined)) return;
- this.ownerService = CodeOwnerService.getOwnerService(this.restApi, change);
- this._updateStatus();
- this._checkIfOverriden();
- this._updateOverrideInfoUrl();
- }
-
_openReplyDialog() {
this.dispatchEvent(
new CustomEvent('open-reply-dialog', {
@@ -229,10 +223,8 @@
})
);
ownerState.expandSuggestion = true;
- this.ownerService.getLoggedInUserInitialRole().then(role => {
- this.reporting.reportInteraction(
- 'suggest-owners-from-submit-requirement', {user_role: role});
- });
+ this.reporting.reportInteraction('suggest-owners-from-submit-requirement',
+ {user_role: this.model.userRole});
}
}
diff --git a/ui/owner-status-column.js b/ui/owner-status-column.js
index a63ef8b..31ad654 100644
--- a/ui/owner-status-column.js
+++ b/ui/owner-status-column.js
@@ -15,7 +15,8 @@
* limitations under the License.
*/
-import {CodeOwnerService, OwnerStatus} from './code-owners-service.js';
+import {OwnerStatus} from './code-owners-service.js';
+import {CodeOwnersModelMixin} from './code-owners-model-mixin.js';
const MAGIC_FILES = ['/COMMIT_MSG', '/MERGE_LIST', '/PATCHSET_LEVEL'];
const STATUS_CODE = {
@@ -46,7 +47,7 @@
[STATUS_CODE.ERROR]: 'Failed to fetch code owner status',
};
-class BaseEl extends Polymer.Element {
+class BaseEl extends CodeOwnersModelMixin(Polymer.Element) {
computeHidden(change, patchRange) {
if ([change, patchRange].includes(undefined)) return true;
// if code-owners is not a submit requirement, don't show status column
@@ -66,11 +67,6 @@
if (`${patchRange.patchNum}` !== `${latestPatchset._number}`) return true;
return false;
}
-
- onInputChanged(restApi, change) {
- if ([restApi, change].includes(undefined)) return;
- this.ownerService = CodeOwnerService.getOwnerService(this.restApi, change);
- }
}
/**
@@ -96,26 +92,15 @@
static get properties() {
return {
- change: Object,
- reporting: Object,
patchRange: Object,
- restApi: Object,
hidden: {
type: Boolean,
reflectToAttribute: true,
computed: 'computeHidden(change, patchRange)',
},
- ownerService: Object,
};
}
-
- static get observers() {
- return [
- 'onInputChanged(restApi, change)',
- 'onOwnerServiceChanged(ownerServive)',
- ];
- }
}
customElements.define(OwnerStatusColumnHeader.is, OwnerStatusColumnHeader);
@@ -130,10 +115,6 @@
static get properties() {
return {
- change: Object,
- reporting: Object,
- restApi: Object,
-
path: String,
patchRange: Object,
hidden: {
@@ -188,47 +169,45 @@
static get observers() {
return [
- 'onInputChanged(restApi, change)',
- 'computeStatusIcon(ownerService, path)',
+ 'computeStatusIcon(model.status, path)',
];
}
- computeStatusIcon(ownerService, path) {
- if ([ownerService, path].includes(undefined)) return;
+ loadPropertiesAfterModelChanged() {
+ super.loadPropertiesAfterModelChanged();
+ this.modelLoader.loadStatus();
+ }
+
+ computeStatusIcon(modelStatus, path) {
+ if ([modelStatus, path].includes(undefined)) return;
if (MAGIC_FILES.includes(path)) return;
- ownerService.getStatus()
- .then(({codeOwnerStatusMap}) => {
- const statusItem = codeOwnerStatusMap.get(path);
- if (!statusItem) {
- this.status = STATUS_CODE.ERROR;
- return;
- }
+ const codeOwnerStatusMap = modelStatus.codeOwnerStatusMap;
+ const statusItem = codeOwnerStatusMap.get(path);
+ if (!statusItem) {
+ this.status = STATUS_CODE.ERROR;
+ return;
+ }
- const status = statusItem.status;
- let oldPathStatus = null;
- if (statusItem.oldPath) {
- const oldStatusItem = codeOwnerStatusMap.get(statusItem.oldPath);
- if (!oldStatusItem) {
- // should not happen
- } else {
- oldPathStatus = oldStatusItem.status;
- }
- }
+ const status = statusItem.status;
+ let oldPathStatus = null;
+ if (statusItem.oldPath) {
+ const oldStatusItem = codeOwnerStatusMap.get(statusItem.oldPath);
+ if (!oldStatusItem) {
+ // should not happen
+ } else {
+ oldPathStatus = oldStatusItem.status;
+ }
+ }
- const newPathStatus = this._computeStatus(status);
- if (!oldPathStatus) {
- this.status = newPathStatus;
- } else {
- this.status = newPathStatus === STATUS_CODE.APPROVED
- ? this._computeStatus(oldPathStatus, /* oldPath= */ true)
- : newPathStatus;
- }
- })
- .catch(e => {
- this.status = STATUS_CODE.ERROR;
- throw e;
- });
+ const newPathStatus = this._computeStatus(status);
+ if (!oldPathStatus) {
+ this.status = newPathStatus;
+ } else {
+ this.status = newPathStatus === STATUS_CODE.APPROVED
+ ? this._computeStatus(oldPathStatus, /* oldPath= */ true)
+ : newPathStatus;
+ }
}
_computeIcon(status) {
diff --git a/ui/suggest-owners-trigger.js b/ui/suggest-owners-trigger.js
index 8e799f4..c6fd55d 100644
--- a/ui/suggest-owners-trigger.js
+++ b/ui/suggest-owners-trigger.js
@@ -14,10 +14,11 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-import {CodeOwnerService} from './code-owners-service.js';
import {ownerState} from './owner-ui-state.js';
+import {CodeOwnersModelMixin} from './code-owners-model-mixin.js';
-export class SuggestOwnersTrigger extends Polymer.Element {
+export class SuggestOwnersTrigger extends
+ CodeOwnersModelMixin(Polymer.Element) {
static get is() {
return 'suggest-owners-trigger';
}
@@ -29,26 +30,19 @@
static get properties() {
return {
- change: Object,
- reporting: Object,
- restApi: Object,
-
expanded: {
type: Boolean,
value: false,
},
hidden: {
type: Boolean,
- value: true,
+ computed: '_computeHidden(model.isCodeOwnerEnabled,' +
+ 'model.areAllFilesApproved, model.userRole)',
reflectToAttribute: true,
},
};
}
- static get observers() {
- return ['onInputChanged(restApi, change)'];
- }
-
static get template() {
return Polymer.html`
<style include="shared-styles">
@@ -81,6 +75,13 @@
`;
}
+ loadPropertiesAfterModelChanged() {
+ super.loadPropertiesAfterModelChanged();
+ this.modelLoader.loadUserRole();
+ this.modelLoader.loadIsCodeOwnerEnabled();
+ this.modelLoader.loadAreAllFilesApproved();
+ }
+
connectedCallback() {
super.connectedCallback();
this.expandSuggestionStateUnsubscriber = ownerState
@@ -97,26 +98,17 @@
}
}
- onInputChanged(restApi, change) {
- if ([restApi, change].includes(undefined)) return;
- this.ownerService = CodeOwnerService.getOwnerService(
- this.restApi,
- this.change
- );
-
- Promise.all([
- this.ownerService.isCodeOwnerEnabled(),
- this.ownerService.areAllFilesApproved(),
- this.ownerService.getLoggedInUserInitialRole(),
- ])
- .then(([enabled, approved, userRole]) => {
- if (enabled) {
- this.hidden = approved;
- } else {
- this.hidden = true;
- }
- this._userRole = userRole;
- });
+ _computeHidden(enabled, allFilesApproved, userRole) {
+ if (enabled === undefined ||
+ allFilesApproved === undefined ||
+ userRole === undefined) {
+ return true;
+ }
+ if (enabled) {
+ return allFilesApproved;
+ } else {
+ return true;
+ }
}
toggleControlContent() {
@@ -124,7 +116,8 @@
ownerState.expandSuggestion = this.expanded;
this.reporting.reportInteraction('toggle-suggest-owners', {
expanded: this.expanded,
- user_role: this._userRole ? this._userRole : 'UNKNOWN',
+ user_role: this.model.userRole ?
+ this.model.userRole : 'UNKNOWN',
});
}