blob: 0961509446c150342fac04c97017b71932472bfe [file] [log] [blame]
// Copyright 2019 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import '../../../node_modules/@polymer/polymer/polymer-legacy.js';
import {PolymerElement, html} from '@polymer/polymer';
import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
import '../../chops/chops-button/chops-button.js';
import '../../../node_modules/@vaadin/vaadin-upload/vaadin-upload.js';
import '../../../node_modules/@vaadin/vaadin-upload/theme/lumo/vaadin-upload.js';
import '../../chops/chops-checkbox/chops-checkbox.js';
import '../../mr-error/mr-error.js';
import '../shared/mr-flt-styles.js';
import {MetadataMixin} from '../shared/metadata-mixin.js';
import {selectors} from '../../redux/selectors.js';
import {actionType} from '../../redux/redux-mixin.js';
import './mr-edit-field.js';
import './mr-edit-status.js';
/**
* `<mr-edit-metadata>`
*
* Editing form for either an approval or the overall issue.
*
*/
export class MrEditMetadata extends MetadataMixin(PolymerElement) {
static get template() {
return html`
<link href="https://fonts.googleapis.com/icon?family=Material+Icons"
rel="stylesheet">
<dom-module id="upload-theme" theme-for="vaadin-upload-file">
<!-- Custom styling to hide some unused controls and add some
extra affordances. -->
<template>
<style>
[part="start-button"], [part="status"], [part="progress"] {
display:none;
}
[part="row"]:hover {
background: #eee;
}
[part="clear-button"] {
cursor: pointer;
font-size: 100%;
}
[part="clear-button"]:before {
font-family: sans-serif;
content: 'X';
}
</style>
</template>
</dom-module>
<style include="mr-flt-styles">
:host {
display: block;
font-size: 12px;
--mr-edit-field-styles: {
box-sizing: border-box;
width: 95%;
padding: 0.25em 4px;
}
}
vaadin-upload {
margin-bottom: 1em;
}
input {
@apply --mr-edit-field-styles;
}
label {
font-weight: bold;
word-wrap: break-word;
text-align: right;
}
label.checkbox {
text-align: left;
}
textarea {
width: 100%;
margin: 0.5em 0;
box-sizing: border-box;
border: 1px solid hsl(0, 0%, 70%);
height: 8em;
transition: height 0.1s ease-in-out;
padding: 0.5em 4px;
grid-column-start: 1;
grid-column-end: 2;
}
button.toggle {
background: none;
color: hsl(240, 100%, 40%);
border: 0;
width: 100%;
padding: 0.25em 0;
text-align: left;
}
button.toggle:hover {
cursor: pointer;
text-decoration: underline;
}
.discard-button {
margin-right: 16px;
}
.edit-actions {
width: 100%;
margin: 0.5em 0;
text-align: right;
}
.input-grid {
padding: 0.5em 0;
display: grid;
max-width: 100%;
grid-gap: 10px;
grid-template-columns: 120px auto;
align-items: flex-start;
}
.group {
width: 100%;
border: 1px solid hsl(0, 0%, 83%);
grid-column: 1 / -1;
margin: 0;
margin-bottom: 0.5em;
padding: 0;
padding-bottom: 0.5em;
}
.group legend {
margin-left: 130px;
}
.group-title {
text-align: center;
font-style: oblique;
margin-top: 4px;
margin-bottom: -8px;
}
@media (max-width: 600px) {
label {
margin-top: 8px;
text-align: left;
}
.input-grid {
grid-gap: 4px;
grid-template-columns: 100%;
}
}
</style>
<template is="dom-if" if="[[error]]">
<mr-error>[[error]]</mr-error>
</template>
<form id="editForm">
<textarea id="commentText" placeholder="Add a comment"></textarea>
<vaadin-upload files="{{_newAttachments}}" no-auto hidden$="[[disableAttachments]]">
<i class="material-icons" slot="drop-label-icon">cloud_upload</i>
</vaadin-upload>
<div class="input-grid">
<template is="dom-if" if="[[!isApproval]]">
<label for="summaryInput">Summary:</label>
<input id="summaryInput" value$="[[summary]]" />
</template>
<template is="dom-if" if="[[statuses.length]]">
<label for="statusInput">Status:</label>
<mr-edit-status
id="statusInput"
status="[[status]]"
statuses="[[statuses]]"
is-approval="[[isApproval]]"
merged-into="[[_mapIssueRefToIssueString(mergedInto, projectName)]]"
></mr-edit-status>
</template>
<template is="dom-if" if="[[!isApproval]]">
<label for="ownerInput" on-click="_clickLabelForCustomInput">Owner:</label>
<mr-edit-field
id="ownerInput"
type="USER_TYPE"
initial-values="[[_wrapList(ownerName)]]"
></mr-edit-field>
<label for="ccInput" on-click="_clickLabelForCustomInput">CC:</label>
<mr-edit-field
id="ccInput"
name="cc"
type="USER_TYPE"
initial-values="[[_ccNames]]"
derived-values="[[_derivedCCs]]"
multi
></mr-edit-field>
<label for="componentsInput" on-click="_clickLabelForCustomInput">Components:</label>
<mr-edit-field
id="componentsInput"
name="component"
type="STR_TYPE"
initial-values="[[_mapComponentRefsToNames(components)]]"
ac-type="component"
multi
></mr-edit-field>
</template>
<template is="dom-if" if="[[_and(hasApproverPrivileges, isApproval)]]">
<label for="approversInput" on-click="_clickLabelForCustomInput">Approvers:</label>
<mr-edit-field
id="approversInput"
type="USER_TYPE"
initial-values="[[_mapUserRefsToNames(approvers)]]"
name="approver"
multi
></mr-edit-field>
</template>
<template is="dom-repeat" items="[[_fieldDefsWithGroups]]" as="group">
<fieldset class="group">
<legend>[[group.groupName]]</legend>
<div class="input-grid">
<template is="dom-repeat" items="[[group.fieldDefs]]" as="field">
<label
hidden$="[[_fieldIsHidden(showNicheFields, field.isNiche)]]"
for$="[[_idForField(field.fieldRef.fieldName)]]"
on-click="_clickLabelForCustomInput"
title$="[[field.docstring]]"
>
[[field.fieldRef.fieldName]]:
</label>
<mr-edit-field
hidden$="[[_fieldIsHidden(showNicheFields, field.isNiche)]]"
id$="[[_idForField(field.fieldRef.fieldName)]]"
name="[[field.fieldRef.fieldName]]"
type="[[field.fieldRef.type]]"
options="[[_optionsForField(projectConfig.labelDefs, field.fieldRef.fieldName)]]"
initial-values="[[_valuesForField(fieldValueMap, field.fieldRef.fieldName, phaseName)]]"
multi="[[field.isMultivalued]]"
></mr-edit-field>
</template>
</div>
</fieldset>
</template>
<template is="dom-repeat" items="[[_fieldDefsWithoutGroup]]" as="field">
<label
hidden$="[[_fieldIsHidden(showNicheFields, field.isNiche)]]"
for$="[[_idForField(field.fieldRef.fieldName)]]"
on-click="_clickLabelForCustomInput"
title$="[[field.docstring]]"
>
[[field.fieldRef.fieldName]]:
</label>
<mr-edit-field
hidden$="[[_fieldIsHidden(showNicheFields, field.isNiche)]]"
id$="[[_idForField(field.fieldRef.fieldName)]]"
name="[[field.fieldRef.fieldName]]"
type="[[field.fieldRef.type]]"
options="[[_optionsForField(projectConfig.labelDefs, field.fieldRef.fieldName)]]"
initial-values="[[_valuesForField(fieldValueMap, field.fieldRef.fieldName, phaseName)]]"
multi="[[field.isMultivalued]]"
></mr-edit-field>
</template>
<template is="dom-if" if="[[!isApproval]]">
<label for="blockedOnInput" on-click="_clickLabelForCustomInput">Blocked on:</label>
<mr-edit-field
id="blockedOnInput"
initial-values="[[_mapBlockerRefsToIdStrings(blockedOn, projectName)]]"
name="blocked-on"
multi
></mr-edit-field>
<label for="blockingInput" on-click="_clickLabelForCustomInput">Blocking:</label>
<mr-edit-field
id="blockingInput"
initial-values="[[_mapBlockerRefsToIdStrings(blocking, projectName)]]"
name="blocking"
multi
></mr-edit-field>
<label for="labelsInput" on-click="_clickLabelForCustomInput">Labels:</label>
<mr-edit-field
id="labelsInput"
ac-type="label"
initial-values="[[labelNames]]"
derived-values="[[derivedLabels]]"
name="label"
multi
></mr-edit-field>
</template>
<span hidden$="[[!_nicheFieldCount]]"></span>
<button type="button" class="toggle" on-click="toggleNicheFields" hidden$="[[!_nicheFieldCount]]">
<span hidden$="[[showNicheFields]]">
Show all fields ([[_nicheFieldCount]] currently hidden)
</span>
<span hidden$="[[!showNicheFields]]">
Hide niche fields ([[_nicheFieldCount]] currently shown)
</span>
</button>
<span></span>
<chops-checkbox
id="sendEmail"
on-checked-change="_sendEmailChecked"
checked="[[sendEmail]]"
>Send email</chops-checkbox>
</div>
<div class="edit-actions">
<chops-button
on-click="discard"
class="de-emphasized discard-button"
disabled="[[disabled]]"
>
<i class="material-icons">close</i>
Discard changes
</chops-button>
<chops-button on-click="save" class="emphasized" disabled="[[disabled]]">
<i class="material-icons">create</i>
Save changes
</chops-button>
</div>
</form>
`;
}
static get is() {
return 'mr-edit-metadata';
}
static get properties() {
return {
fieldValueMap: Object,
approvers: {
type: Array,
value: () => [],
},
setter: {
type: Object,
value: () => {},
},
summary: {
type: String,
value: '',
},
cc: {
type: Array,
value: () => [],
},
components: {
type: Array,
value: () => [],
},
status: String,
statuses: {
type: Array,
value: () => [],
},
blockedOn: {
type: Array,
value: () => [],
},
blocking: {
type: Array,
value: () => [],
},
mergedInto: Object,
ownerName: {
type: String,
value: '',
},
labelNames: {
type: Array,
value: () => [],
},
derivedLabels: {
type: Array,
value: [],
},
phaseName: String,
projectConfig: Object,
projectName: String,
isApproval: {
type: Boolean,
value: false,
},
hasApproverPrivileges: {
type: Boolean,
value: false,
},
showNicheFields: {
type: Boolean,
value: false,
},
disabled: {
type: Boolean,
value: false,
},
disableAttachments: {
type: Boolean,
value: false,
},
error: String,
sendEmail: {
type: Boolean,
value: true,
},
_newAttachments: Array,
_nicheFieldCount: {
type: Boolean,
computed: '_computeNicheFieldCount(fieldDefs)',
},
_ccNames: {
type: Array,
computed: '_computeCCNames(cc)',
},
_derivedCCs: {
type: Array,
computed: '_computeDerivedCCs(cc)',
},
};
}
static mapStateToProps(state, element) {
return {
projectConfig: state.projectConfig,
projectName: state.projectName,
fieldValueMap: selectors.issueFieldValueMap(state),
};
}
connectedCallback() {
super.connectedCallback();
this.dispatchAction({type: actionType.UPDATE_FORMS_TO_CHECK, form: this});
}
reset() {
this.$.editForm.reset();
// Since custom elements containing <input> elements have the inputs
// wrapped in ShadowDOM, those inputs don't get reset with the rest of
// the form. Haven't been able to figure out a way to replicate form reset
// behavior with custom input elements.
if (this.isApproval) {
if (this.hasApproverPrivileges) {
const approversInput = dom(this.root).querySelector(
'#approversInput');
if (approversInput) {
approversInput.reset();
}
}
}
dom(this.root).querySelectorAll('mr-edit-field').forEach((el) => {
el.reset();
});
}
save() {
this.dispatchEvent(new CustomEvent('save'));
}
discard() {
const isDirty = Object.keys(this.getDelta()).length !== 0;
if (!isDirty || confirm('Discard your changes?')) {
this.dispatchEvent(new CustomEvent('discard'));
}
}
loadAttachments() {
if (!this._newAttachments || !this._newAttachments.length) return [];
return this._newAttachments.map((f) => {
return this._loadLocalFile(f);
});
}
_loadLocalFile(f) {
return new Promise((resolve, reject) => {
const r = new FileReader();
r.onloadend = () => {
resolve({filename: f.name, content: btoa(r.result)});
};
r.onerror = () => {
reject(r.error);
};
r.readAsBinaryString(f);
});
}
getDelta() {
const result = {};
const root = dom(this.root);
const statusInput = root.querySelector('#statusInput');
if (statusInput) {
Object.assign(result, statusInput.getDelta());
}
const commentContent = root.querySelector('#commentText').value;
if (commentContent) {
result['comment'] = commentContent;
}
if (this.isApproval) {
if (this.hasApproverPrivileges) {
const approversInput = root.querySelector('#approversInput');
const approversAdded = approversInput.getValuesAdded();
if (approversAdded && approversAdded.length) {
result['approversAdded'] = approversAdded;
}
const approversRemoved = approversInput.getValuesRemoved();
if (approversRemoved && approversRemoved.length) {
result['approversRemoved'] = approversRemoved;
}
}
} else {
// TODO(zhangtiff): Consider representing baked-in fields such as owner,
// cc, and status similarly to custom fields to reduce repeated code.
const summaryInput = root.querySelector('#summaryInput');
if (summaryInput) {
const newSummary = summaryInput.value;
if (newSummary !== this.summary) {
result['summary'] = newSummary;
}
}
const ownerInput = root.querySelector('#ownerInput');
if (ownerInput) {
const newOwner = ownerInput.getValue();
if (newOwner !== this.ownerName) {
result['owner'] = newOwner;
}
}
this._addListChangesToDelta(result, 'labelsInput',
'labelsAdded', 'labelsRemoved');
this._addListChangesToDelta(result, 'ccInput',
'ccAdded', 'ccRemoved');
this._addListChangesToDelta(result, 'componentsInput',
'componentsAdded', 'componentsRemoved');
this._addListChangesToDelta(result, 'blockedOnInput',
'blockedOnAdded', 'blockedOnRemoved');
this._addListChangesToDelta(result, 'blockingInput',
'blockingAdded', 'blockingRemoved');
}
let fieldValuesAdded = [];
let fieldValuesRemoved = [];
const fieldDefs = this.fieldDefs || [];
fieldDefs.forEach((field) => {
const fieldName = field.fieldRef.fieldName;
const input = root.querySelector(
`#${this._idForField(fieldName)}`);
const valuesAdded = input.getValuesAdded();
const valuesRemoved = input.getValuesRemoved();
valuesAdded.forEach((v) => {
fieldValuesAdded.push({
fieldRef: {
fieldName: field.fieldRef.fieldName,
fieldId: field.fieldRef.fieldId,
},
value: v,
});
});
valuesRemoved.forEach((v) => {
fieldValuesRemoved.push({
fieldRef: {
fieldName: field.fieldRef.fieldName,
fieldId: field.fieldRef.fieldId,
},
value: v,
});
});
});
if (fieldValuesAdded.length) {
result['fieldValuesAdded'] = fieldValuesAdded;
}
if (fieldValuesRemoved.length) {
result['fieldValuesRemoved'] = fieldValuesRemoved;
}
return result;
}
toggleNicheFields() {
this.showNicheFields = !this.showNicheFields;
}
_addListChangesToDelta(delta, inputId, addedKey, removedKey) {
const root = dom(this.root);
const input = root.querySelector(`#${inputId}`);
if (!input) return;
const valuesAdded = input.getValuesAdded();
const valuesRemoved = input.getValuesRemoved();
if (valuesAdded && valuesAdded.length) {
delta[addedKey] = valuesAdded;
}
if (valuesRemoved && valuesRemoved.length) {
delta[removedKey] = valuesRemoved;
}
}
_computeNicheFieldCount(fieldDefs) {
return fieldDefs.reduce((acc, fd) => acc + (fd.isNiche | 0), 0);
}
_mapIssueRefToIssueString(ref, projectName) {
if (!ref) return '';
if (ref.projectName === projectName) {
return `${ref.localId}`;
}
return `${ref.projectName}:${ref.localId}`;
}
_mapBlockerRefsToIdStrings(arr, projectName) {
if (!arr || !arr.length) return [];
return arr.map((v) => this._mapIssueRefToIssueString(v, projectName));
}
// For simulating && in templating.
_and(a, b) {
return a && b;
}
// This function exists because <label for="inputId"> doesn't work for custom
// input elements.
_clickLabelForCustomInput(e) {
const target = e.target;
const forValue = target.getAttribute('for');
if (forValue) {
const customInput = dom(this.root).querySelector('#' + forValue);
if (customInput && customInput.focus) {
customInput.focus();
}
}
}
_idForField(name) {
return `${name}Input`;
}
_computeCCNames(users) {
if (!users) return [];
return this._mapUserRefsToNames(users.filter((u) => !u.isDerived));
}
_computeDerivedCCs(users) {
if (!users) return [];
return this._mapUserRefsToNames(users.filter((u) => u.isDerived));
}
_mapUserRefsToNames(users) {
if (!users) return [];
return users.map((u) => (u.displayName));
}
_mapComponentRefsToNames(components) {
if (!components) return [];
return components.map((c) => c.path);
}
_optionsForField(labelDefs, fieldName) {
const options = [];
labelDefs = labelDefs || [];
// TODO(zhangtiff): Find a way to avoid traversing through every label on
// every enum field.
for (const label of labelDefs) {
const labelName = label.label;
if (labelName.toLowerCase().startsWith(fieldName.toLowerCase())) {
const opt = Object.assign({}, label, {
optionName: labelName.substring(fieldName.length + 1),
});
options.push(opt);
}
}
return options;
}
_fieldIsHidden(showNicheFields, isNiche) {
return !showNicheFields && isNiche;
}
_wrapList(item) {
if (!item) return [];
return [item];
}
_sendEmailChecked(evt) {
this.sendEmail = evt.detail.checked;
}
}
customElements.define(MrEditMetadata.is, MrEditMetadata);