chromium-dashboard/client-src/elements/chromedash-guide-editall-pa...

492 строки
14 KiB
TypeScript

import {LitElement, TemplateResult, css, html, nothing} from 'lit';
import {ref} from 'lit/directives/ref.js';
import {repeat} from 'lit/directives/repeat.js';
import {
formatFeatureChanges,
getDisabledHelpText,
getStageValue,
showToastMessage,
flattenSections,
setupScrollToHash,
shouldShowDisplayNameField,
renderHTMLIf,
FieldInfo,
} from './utils.js';
import './chromedash-form-table';
import './chromedash-form-field';
import {
formatFeatureForEdit,
FLAT_METADATA_FIELDS,
FLAT_ENTERPRISE_METADATA_FIELDS,
FORMS_BY_STAGE_TYPE,
FLAT_TRIAL_EXTENSION_FIELDS,
} from './form-definition';
import {SHARED_STYLES} from '../css/shared-css.js';
import {FORM_STYLES} from '../css/forms-css.js';
import {
STAGE_SHORT_NAMES,
STAGE_SPECIFIC_FIELDS,
STAGE_ENT_ROLLOUT,
} from './form-field-enums.js';
import {ALL_FIELDS} from './form-field-specs';
import {openAddStageDialog} from './chromedash-add-stage-dialog';
import {customElement, property, state} from 'lit/decorators.js';
import {Feature} from '../js-src/cs-client.js';
import {ifDefined} from 'lit/directives/if-defined.js';
interface FormToRender {
id: number;
item: typeof nothing | TemplateResult;
}
@customElement('chromedash-guide-editall-page')
export class ChromedashGuideEditallPage extends LitElement {
static get styles() {
return [
...SHARED_STYLES,
...FORM_STYLES,
css`
.enterprise-help-text > *,
.enterprise-help-text li {
margin: revert;
padding: revert;
list-style: revert;
}
`,
];
}
@property({attribute: false})
featureId = 0;
@property({type: String})
appTitle = '';
@property({type: Number})
nextStageToCreateId = 0;
@state()
feature!: Feature;
@state()
loading = true;
@state()
previousStageTypeRendered = 0;
@state()
sameTypeRendered = 0;
@state()
fieldValues: FieldInfo[] & {feature?: Feature} = [];
connectedCallback() {
super.connectedCallback();
this.fetchData();
}
fetchData() {
this.loading = true;
Promise.all([window.csClient.getFeature(this.featureId)])
.then(([feature]) => {
this.feature = feature;
if (this.feature.name) {
document.title = `${this.feature.name} - ${this.appTitle}`;
}
this.loading = false;
})
.catch(() => {
showToastMessage(
'Some errors occurred. Please refresh the page or try again later.'
);
});
}
disconnectedCallback() {
super.disconnectedCallback();
document.title = this.appTitle;
}
/* Add the form's event listener after Shoelace event listeners are attached
* see more at https://github.com/GoogleChrome/chromium-dashboard/issues/2014 */
async registerHandlers(el) {
if (!el) return;
await el.updateComplete;
const hiddenTokenField = this.renderRoot.querySelector(
'input[name=token]'
) as HTMLInputElement;
hiddenTokenField.form?.addEventListener('submit', event => {
this.handleFormSubmit(event, hiddenTokenField);
});
setupScrollToHash(this);
}
handleFormSubmit(e, hiddenTokenField) {
e.preventDefault();
const submitBody = formatFeatureChanges(this.fieldValues, this.featureId);
// get the XSRF token and update it if it's expired before submission
window.csClient
.ensureTokenIsValid()
.then(() => {
hiddenTokenField.value = window.csClient.token;
return window.csClient.updateFeature(submitBody);
})
.then(() => {
window.location.href = this.getNextPage();
})
.catch(() => {
showToastMessage(
'Some errors occurred. Please refresh the page or try again later.'
);
});
}
// Handler to update form values when a field update event is fired.
handleFormFieldUpdate(event) {
const value = event.detail.value;
// Index represents which form was updated.
const index = event.detail.index;
if (index >= this.fieldValues.length) {
throw new Error('Out of bounds index when updating field values.');
}
// The field has been updated, so it is considered touched.
this.fieldValues[index].touched = true;
this.fieldValues[index].value = value;
}
handleCancelClick() {
window.location.href = `/feature/${this.featureId}`;
}
renderSkeletons() {
return html`
<h3><sl-skeleton effect="sheen"></sl-skeleton></h3>
<section class="flat_form">
<h3><sl-skeleton effect="sheen"></sl-skeleton></h3>
<p>
<sl-skeleton effect="sheen"></sl-skeleton>
<sl-skeleton effect="sheen"></sl-skeleton>
<sl-skeleton effect="sheen"></sl-skeleton>
<sl-skeleton effect="sheen"></sl-skeleton>
</p>
</section>
<h3><sl-skeleton effect="sheen"></sl-skeleton></h3>
<section class="flat_form">
<h3><sl-skeleton effect="sheen"></sl-skeleton></h3>
<p>
<sl-skeleton effect="sheen"></sl-skeleton>
<sl-skeleton effect="sheen"></sl-skeleton>
<sl-skeleton effect="sheen"></sl-skeleton>
<sl-skeleton effect="sheen"></sl-skeleton>
</p>
</section>
`;
}
getNextPage() {
return `/feature/${this.featureId}`;
}
renderSubheader() {
return html`
<div id="subheader">
<h2 id="breadcrumbs">
<a href=${this.getNextPage()}>
<iron-icon icon="chromestatus:arrow-back"></iron-icon>
Edit feature: ${this.loading ? 'loading...' : this.feature.name}
</a>
</h2>
</div>
`;
}
getStageForm(stageType) {
return FORMS_BY_STAGE_TYPE[stageType] || null;
}
getHelpTextForStage(stageType) {
switch (stageType) {
case STAGE_ENT_ROLLOUT:
return html`
<section class="enterprise-help-text">
<h3>Rollout steps</h3>
<p>
The enterprise release notes focus on changes to the stable
channel. Please add a stage for each milestone where something is
changing on the stable channel. For finch rollouts, use the
milestone where the rollout starts.
</p>
<p>
For example, you may only have a single stage where you roll out
to 100% of users on milestone N.
</p>
<p>A more complex example might look like this:</p>
<ul>
<li>
On milestone N-1, you introduce a flag for early testing of an
upcoming change, or start a deprecation origin trial
</li>
<li>
On milestone N, you start a finch rollout of a feature at 1% and
introduce an enterprise policy for it
</li>
<li>On milestone N+3, you remove the enterprise policy</li>
</ul>
</section>
`;
default:
return nothing;
}
}
renderStageSection(formattedFeature, sectionBaseName, feStage, stageFields) {
if (!stageFields) return nothing;
// Add a number differentiation if this stage type is the same as another stage.
let numberDifferentiation = '';
if (
this.previousStageTypeRendered &&
this.previousStageTypeRendered === feStage.stage_type
) {
this.sameTypeRendered += 1;
numberDifferentiation = ` ${this.sameTypeRendered}`;
} else {
this.previousStageTypeRendered = feStage.stage_type;
this.sameTypeRendered = 1;
}
let sectionName = `${sectionBaseName}${numberDifferentiation}`;
if (feStage.display_name) {
sectionName = `${sectionBaseName}: ${feStage.display_name} `;
}
const formFieldEls = stageFields.map(field => {
// Only show "display name" field if there is more than one stage of the same type.
const featureJSONKey = ALL_FIELDS[field].name || field;
if (
featureJSONKey === 'display_name' &&
!shouldShowDisplayNameField(this.feature.stages, feStage.stage_type)
) {
return nothing;
}
let value = formattedFeature[field];
let stageId = null;
if (STAGE_SPECIFIC_FIELDS.has(featureJSONKey)) {
value = getStageValue(feStage, featureJSONKey);
stageId = feStage.id;
} else if (this.sameTypeRendered > 1) {
// Don't render fields that are not stage-specific if this is
// a stage type that is already being rendered.
// This is to avoid repeated fields on the edit-all page.
return nothing;
}
const index = this.fieldValues.length;
this.fieldValues.push({
name: featureJSONKey,
touched: false,
value,
stageId,
});
return html`
<chromedash-form-field
name=${field}
index=${index}
stageId=${ifDefined(stageId)}
value=${value}
disabledReason="${getDisabledHelpText(field, feStage)}"
.fieldValues=${this.fieldValues}
.feature=${formattedFeature}
?forEnterprise=${formattedFeature.is_enterprise_feature}
@form-field-update="${this.handleFormFieldUpdate}"
>
</chromedash-form-field>
`;
});
const id =
`${STAGE_SHORT_NAMES[feStage.stage_type] || 'metadata'}${this.sameTypeRendered}`.toLowerCase();
const isEnterpriseFeatureRollout =
formattedFeature.is_enterprise_feature &&
feStage.stage_type === STAGE_ENT_ROLLOUT;
return {
id: feStage.id,
item: html`
${renderHTMLIf(
!isEnterpriseFeatureRollout,
html`<h3 id="${id}">${sectionName}</h3>`
)}
<section class="flat_form" stage="${feStage.stage_type}">
${renderHTMLIf(
feStage.stage_type === STAGE_ENT_ROLLOUT,
html` <sl-button
stage="${feStage.stage_type}"
size="small"
@click="${() => this.deleteStage(feStage)}"
>
Delete
</sl-button>`
)}
${formFieldEls}
</section>
`,
};
}
/**
* Builds the HTML elements for rendering the form sections.
* @param {Object} formattedFeature Object describing the feature.
* @param {Array} feStages List of stages associated with the feature.
*
* @return {Array} formsToRender, All HTML elements to render in the form.
*/
getForms(formattedFeature, feStages) {
// All features display the metadata section.
let fieldsOnly = flattenSections(
formattedFeature.is_enterprise_feature
? FLAT_ENTERPRISE_METADATA_FIELDS
: FLAT_METADATA_FIELDS
);
const formsToRender: (typeof nothing | FormToRender)[] = [
this.renderStageSection(
formattedFeature,
FLAT_METADATA_FIELDS.name,
{id: -1},
fieldsOnly
),
];
let previousStageType = null;
for (const feStage of feStages) {
const stageForm = this.getStageForm(feStage.stage_type);
if (!stageForm) {
continue;
}
if (
formattedFeature.is_enterprise_feature &&
feStage.stage_type !== previousStageType
) {
formsToRender.push({
id: -2,
item: this.getHelpTextForStage(feStage.stage_type),
});
previousStageType = feStage.stage_type;
}
fieldsOnly = flattenSections(stageForm);
formsToRender.push(
this.renderStageSection(
formattedFeature,
stageForm.name,
feStage,
fieldsOnly
)
);
// If extension stages are associated with this stage,
// render them in a separate section as well.
const extensions = feStage.extensions || [];
extensions.forEach(extensionStage => {
fieldsOnly = flattenSections(FLAT_TRIAL_EXTENSION_FIELDS);
let sectionName = FLAT_TRIAL_EXTENSION_FIELDS.name;
if (feStage.display_name) {
sectionName = ` ${FLAT_TRIAL_EXTENSION_FIELDS.name}: ${feStage.display_name} `;
}
formsToRender.push(
this.renderStageSection(
formattedFeature,
sectionName,
extensionStage,
fieldsOnly
)
);
});
}
return formsToRender;
}
// render the button to add a new stage. Displays for enterprise features only.
renderAddStageButton() {
const clickHandler = () => {
openAddStageDialog(
this.feature.id,
this.feature.feature_type_int,
this.createNewStage.bind(this)
);
};
return renderHTMLIf(
this.feature.is_enterprise_feature,
html` <sl-button size="small" @click="${clickHandler}">
Add Step
</sl-button>`
);
}
// Create a stage requested on the edit all page.
createNewStage(newStage) {
window.csClient
.createStage(this.featureId, {stage_type: newStage.stage_type})
.then(() => window.csClient.getFeature(this.featureId))
.then(feature => {
this.feature = feature;
})
.catch(() => {
showToastMessage(
'Some errors occurred. Please refresh the page or try again later.'
);
});
}
deleteStage(stage) {
if (!confirm('Delete feature?')) return;
window.csClient
.deleteStage(this.featureId, stage.id)
.then(() => window.csClient.getFeature(this.featureId))
.then(feature => {
this.feature = feature;
})
.catch(() => {
showToastMessage(
'Some errors occurred. Please refresh the page or try again later.'
);
});
}
renderForm() {
const formattedFeature = formatFeatureForEdit(this.feature);
this.fieldValues.feature = this.feature;
const formsToRender = this.getForms(
formattedFeature,
this.feature.stages
) as FormToRender[];
return html`
<form name="feature_form">
<input type="hidden" name="token" />
<chromedash-form-table ${ref(this.registerHandlers)}>
${repeat(
formsToRender,
form => form.id,
(_, i) => formsToRender[i].item
)}
</chromedash-form-table>
${this.renderAddStageButton()}
<section class="final_buttons">
<input class="button" type="submit" value="Submit" />
<button
id="cancel-button"
type="reset"
@click=${this.handleCancelClick}
>
Cancel
</button>
</section>
</form>
`;
}
render() {
return html`
${this.renderSubheader()}
${this.loading ? this.renderSkeletons() : this.renderForm()}
`;
}
}