Convert guide/stage page into a component (#2163)

* Convert guide/stage page into a component

* Fix python unit test
This commit is contained in:
Kuan-Hsuan (Kevin) Shen 2022-08-23 19:24:02 -04:00 коммит произвёл GitHub
Родитель 697cf9b38d
Коммит bd92897383
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
13 изменённых файлов: 487 добавлений и 237 удалений

Просмотреть файл

@ -77,13 +77,13 @@ STAGE_FORMS = {
IMPL_STATUS_FORMS = {
core_enums.INTENT_INCUBATE:
(None, guideforms.ImplStatus_Incubate),
('', guideforms.ImplStatus_Incubate),
core_enums.INTENT_EXPERIMENT:
(core_enums.BEHIND_A_FLAG, guideforms.ImplStatus_DevTrial),
core_enums.INTENT_EXTEND_TRIAL:
(core_enums.ORIGIN_TRIAL, guideforms.ImplStatus_OriginTrial),
core_enums.INTENT_IMPLEMENT_SHIP:
(None, guideforms.ImplStatus_EvalReadinessToShip),
('', guideforms.ImplStatus_EvalReadinessToShip),
core_enums.INTENT_SHIP:
(core_enums.ENABLED_BY_DEFAULT, guideforms.ImplStatus_AllMilestones),
core_enums.INTENT_SHIPPED:
@ -256,43 +256,32 @@ class FeatureEditStage(basehandlers.FlaskHandler):
f, feature_process = self.get_feature_and_process(feature_id)
stage_name = ''
for stage in feature_process.stages:
if stage.outgoing_stage == stage_id:
stage_name = stage.name
template_data = {
'stage_name': stage_name,
'stage_id': stage_id,
}
# TODO(jrobbins): show useful error if stage not found.
detail_form_class = STAGE_FORMS[f.feature_type][stage_id]
impl_status_offered, impl_status_form_class = IMPL_STATUS_FORMS.get(
stage_id, (None, None))
stage_id, ('', ''))
feature_edit_dict = f.format_for_edit()
detail_form = None
detail_form = ''
if detail_form_class:
detail_form = detail_form_class(feature_edit_dict)
impl_status_form = None
form = detail_form_class(feature_edit_dict)
detail_form = json.dumps((str(form), list(form.fields)))
impl_status_form = ''
if impl_status_form_class:
impl_status_form = impl_status_form_class(feature_edit_dict)
form = impl_status_form_class(feature_edit_dict)
impl_status_form = json.dumps((str(form), list(form.fields)))
# Provide new or populated form to template.
template_data.update({
'feature': f,
template_data = {
'stage_id': stage_id,
'feature_id': f.key.integer_id(),
'feature_form': detail_form,
'already_on_this_stage': stage_id == f.intent_stage,
'already_on_this_impl_status':
impl_status_offered == f.impl_status_chrome,
'impl_status_form': impl_status_form,
'impl_status_name': core_enums.IMPLEMENTATION_STATUS.get(
impl_status_offered, None),
impl_status_offered, ''),
'impl_status_offered': impl_status_offered,
})
}
return template_data
@permissions.require_edit_feature

Просмотреть файл

@ -320,33 +320,8 @@ class FeatureEditStageTest(testing_config.CustomTestCase):
with test_app.test_request_context(self.request_path):
template_data = self.handler.get_template_data(
self.feature_1.key.integer_id(), self.stage)
self.assertTrue('feature' in template_data)
self.assertTrue('feature_id' in template_data)
self.assertTrue('feature_form' in template_data)
self.assertTrue('already_on_this_stage' in template_data)
def test_get__not_on_this_stage(self):
"""When feature is not on the stage for the current form, offer checkbox."""
testing_config.sign_in('user1@google.com', 1234567890)
with test_app.test_request_context(self.request_path):
template_data = self.handler.get_template_data(
self.feature_1.key.integer_id(), self.stage)
self.assertFalse(template_data['already_on_this_stage'])
def test_get__already_on_this_stage(self):
"""When feature is already on the stage for the current form, say that."""
self.feature_1.intent_stage = self.stage
self.feature_1.put()
testing_config.sign_in('user1@google.com', 1234567890)
with test_app.test_request_context(self.request_path):
template_data = self.handler.get_template_data(
self.feature_1.key.integer_id(), self.stage)
self.assertTrue(template_data['already_on_this_stage'])
def test_post__anon(self):
"""Anon cannot edit features, gets a 403."""

Просмотреть файл

@ -49,6 +49,7 @@ import './elements/chromedash-guide-edit-page';
import './elements/chromedash-guide-editall-page';
import './elements/chromedash-guide-metadata';
import './elements/chromedash-guide-new-page';
import './elements/chromedash-guide-stage-page';
import './elements/chromedash-guide-verify-accuracy-page';
import './elements/chromedash-header';
import './elements/chromedash-legend';

Просмотреть файл

@ -50,15 +50,16 @@ export class ChromedashFormField extends LitElement {
const type = fieldProps.type;
const choices = fieldProps.choices;
// If type is checkbox, then generate locally.
// If type is checkbox, select, or input, then generate locally.
let fieldHTML = '';
if (type === 'checkbox') {
// value can be a js or python boolean value converted to a string
fieldHTML = html`
<sl-checkbox
name="${this.name}"
id="id_${this.name}"
size="small"
?checked=${this.value === 'True' ? true : false}
?checked=${this.value === 'true' || this.value === 'True'}
?disabled=${this.disabled}
>
${label}

Просмотреть файл

@ -24,19 +24,6 @@ export class ChromedashGuideEditPage extends LitElement {
display: flex;
gap: 1.5em;
}
sl-skeleton {
margin-bottom: 1em;
width: 60%;
}
sl-skeleton:nth-of-type(even) {
width: 50%;
}
h3 sl-skeleton {
width: 30%;
height: 1.5em;
}
`];
}

Просмотреть файл

@ -13,19 +13,6 @@ export class ChromedashGuideEditallPage extends LitElement {
...SHARED_STYLES,
...FORM_STYLES,
css`
sl-skeleton {
margin-bottom: 1em;
width: 60%;
}
sl-skeleton:nth-of-type(even) {
width: 50%;
}
h3 sl-skeleton {
margin-top: 1em;
width: 30%;
height: 1.25em;
}
`];
}

Просмотреть файл

@ -0,0 +1,291 @@
import {LitElement, css, html, nothing} from 'lit';
import {unsafeHTML} from 'lit/directives/unsafe-html.js';
import {showToastMessage} from './utils.js';
import './chromedash-form-table';
import './chromedash-form-field';
import {SHARED_STYLES} from '../sass/shared-css.js';
import {FORM_STYLES} from '../sass/forms-css.js';
export class ChromedashGuideStagePage extends LitElement {
static get styles() {
return [
...SHARED_STYLES,
...FORM_STYLES,
css`
`];
}
static get properties() {
return {
stageId: {type: Number},
stageName: {type: String},
featureId: {type: Number},
feature: {type: Object},
featureForm: {type: String},
implStatusForm: {type: String},
implStatusName: {type: String},
implStatusOffered: {type: String},
loading: {type: Boolean},
};
}
constructor() {
super();
this.stageId = 0;
this.stageName = '';
this.featureId = 0;
this.feature = {};
this.featureForm = '';
this.implStatusForm = '';
this.implStatusName = '';
this.implStatusOffered = '';
this.loading = true;
}
connectedCallback() {
super.connectedCallback();
this.fetchData();
}
fetchData() {
this.loading = true;
Promise.all([
window.csClient.getFeature(this.featureId),
window.csClient.getFeatureProcess(this.featureId),
]).then(([feature, process]) => {
this.feature = feature;
process.stages.map(stage => {
if (stage.outgoing_stage === this.stageId) {
this.stageName = stage.name;
}
});
this.loading = false;
// TODO(kevinshen56714): Remove this once SPA index page is set up.
// Has to include this for now to remove the spinner at _base.html.
document.body.classList.remove('loading');
}).catch(() => {
showToastMessage('Some errors occurred. Please refresh the page or try again later.');
});
}
/* Add the form's event listener after Shoelace event listeners are attached
* see more at https://github.com/GoogleChrome/chromium-dashboard/issues/2014 */
firstUpdated() {
/* TODO(kevinshen56714): remove the timeout once the form fields are all
* migrated to frontend, we need it now because the unsafeHTML(this.overviewForm)
* delays the Shoelace event listener attachment */
setTimeout(() => {
const hiddenTokenField = this.shadowRoot.querySelector('input[name=token]');
hiddenTokenField.form.addEventListener('submit', (event) => {
this.handleFormSubmission(event, hiddenTokenField);
});
this.addMiscEventListeners();
this.scrollToPosition();
}, 1000);
}
handleFormSubmission(event, hiddenTokenField) {
event.preventDefault();
// get the XSRF token and update it if it's expired before submission
window.csClient.ensureTokenIsValid().then(() => {
hiddenTokenField.value = window.csClient.token;
event.target.submit();
});
}
addMiscEventListeners() {
const fields = this.shadowRoot.querySelectorAll('input, textarea');
for (let i = 0; i < fields.length; ++i) {
fields[i].addEventListener('input', (e) => {
e.target.classList.add('interacted');
});
}
// Allow editing if there was already a value specified in this
// deprecated field.
const timelineField = this.shadowRoot.querySelector('#id_experiment_timeline');
if (timelineField && timelineField.value) {
timelineField.disabled = '';
}
// Copy field SRC to DST if SRC is edited and DST was empty and
// has not been edited.
const COPY_ON_EDIT = [
['dt_milestone_desktop_start', 'dt_milestone_android_start'],
['dt_milestone_desktop_start', 'dt_milestone_webview_start'],
// Don't autofill dt_milestone_ios_start because it is rare.
['ot_milestone_desktop_start', 'ot_milestone_android_start'],
['ot_milestone_desktop_end', 'ot_milestone_android_end'],
['ot_milestone_desktop_start', 'ot_milestone_webview_start'],
['ot_milestone_desktop_end', 'ot_milestone_webview_end'],
];
for (const [srcId, dstId] of COPY_ON_EDIT) {
const srcEl = this.shadowRoot.querySelector('#id_' + srcId);
const dstEl = this.shadowRoot.querySelector('#id_' + dstId);
if (srcEl && dstEl && srcEl.value == dstEl.value) {
srcEl.addEventListener('input', () => {
if (!dstEl.classList.contains('interacted')) {
dstEl.value = srcEl.value;
dstEl.classList.add('copied');
}
});
}
}
}
scrollToPosition() {
if (location.hash) {
const hash = decodeURIComponent(location.hash);
if (hash) {
const el = this.shadowRoot.querySelector(hash);
el.scrollIntoView(true, {behavior: 'smooth'});
el.focus();
}
}
}
handleCancelClick() {
window.location.href = `/guide/edit/${this.featureId}`;
}
// get a comma-spearated list of field names
getFormFields() {
let fields = JSON.parse(this.featureForm)[1];
// if there is a implStatusForm. add its field names to the list
if (this.implStatusForm) {
fields = [...fields, ...JSON.parse(this.implStatusForm)[1]];
}
return fields.join();
}
renderSkeletons() {
return html`
<h3><sl-skeleton effect="sheen"></sl-skeleton></h3>
<section id="metadata">
<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>
<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>
`;
}
renderSubheader() {
return html`
<div id="subheader">
<h2 id="breadcrumbs">
<a href="/guide/edit/${this.featureId}">
<iron-icon icon="chromestatus:arrow-back"></iron-icon>
Edit feature: ${this.feature.name}
</a>
</h2>
</div>
<h3>${this.stageName}</h3>
`;
}
renderFeatureFormSection() {
const alreadyOnThisStage = this.stageId === this.feature.intent_stage_int;
return html`
<section class="stage_form">
<chromedash-form-table>
${unsafeHTML(JSON.parse(this.featureForm)[0])}
<chromedash-form-field
name="set_stage"
stage=${this.stageName}
value=${alreadyOnThisStage}
?disabled=${alreadyOnThisStage}>
</chromedash-form-field>
</chromedash-form-table>
</section>
`;
}
renderImplStatusFormSection() {
const alreadyOnThisImplStatus = this.implStatusOffered === this.feature.impl_status_chrome;
return html`
<h3>Implementation in Chromium</h3>
<section class="stage_form">
<chromedash-form-table>
${this.implStatusName ? html`
<chromedash-form-field>
<span slot="label">Implementation status:</span>
${alreadyOnThisImplStatus ?
html`
<span slot="help">
This feature already has implementation status:
<b>${this.implStatusName}</b>.
</td>
</span>
` :
// TODO(jrobbins): When checked, make some milestone fields required.
html`
<span slot="field">
<input type="hidden" name="impl_status_offered"
value=${this.implStatusOffered}>
<input type="checkbox" name="set_impl_status"
id="set_impl_status">
<label for="set_impl_status">
Set implementation status to: <b>${this.implStatusName}</b>
</label>
</span>
<span slot="help">
Check this box to update the implementation
status of this feature in Chromium.
</span>
`}
</chromedash-form-field>
`: nothing}
${unsafeHTML(JSON.parse(this.implStatusForm)[0])}
</chromedash-form-table>
</section>
`;
}
renderForm() {
return html`
<form name="feature_form" method="POST"
action="/guide/stage/${this.featureId}/${this.stageId}">
<input type="hidden" name="token">
<input type="hidden" name="form_fields" value=${this.getFormFields()} >
${this.renderFeatureFormSection()}
${this.implStatusName || this.implStatusForm ?
this.renderImplStatusFormSection() : nothing}
<div class="final_buttons">
<input class="button" type="submit" value="Submit">
<button id="cancel-button" type="reset"
@click=${this.handleCancelClick}>Cancel</button>
</div>
</form>
`;
}
render() {
return html`
${this.renderSubheader()}
${this.loading ? this.renderSkeletons() : this.renderForm()}
`;
}
}
customElements.define('chromedash-guide-stage-page', ChromedashGuideStagePage);

Просмотреть файл

@ -0,0 +1,146 @@
import {html} from 'lit';
import {assert, fixture} from '@open-wc/testing';
import {ChromedashGuideStagePage} from './chromedash-guide-stage-page';
import './chromedash-toast';
import '../js-src/cs-client';
import sinon from 'sinon';
describe('chromedash-guide-stage-page', () => {
const validFeaturePromise = Promise.resolve({
id: 123456,
name: 'feature one',
summary: 'fake detailed summary',
category: 'fake category',
feature_type: 'fake feature type',
intent_stage: 'fake intent stage',
new_crbug_url: 'fake crbug link',
browsers: {
chrome: {
blink_components: ['Blink'],
owners: ['fake chrome owner one', 'fake chrome owner two'],
status: {text: 'fake chrome status text'},
},
ff: {view: {text: 'fake ff view text'}},
safari: {view: {text: 'fake safari view text'}},
webdev: {view: {text: 'fake webdev view text'}},
},
resources: {
samples: ['fake sample link one', 'fake sample link two'],
docs: ['fake doc link one', 'fake doc link two'],
},
standards: {
spec: 'fake spec link',
maturity: {text: 'Unknown standards status - check spec link for status'},
},
tags: ['tag_one'],
});
const processPromise = Promise.resolve({
stages: [{
name: 'stage one',
description: 'a description',
progress_items: [],
outgoing_stage: 1,
actions: [],
}],
});
const implStatusName = 'fake implStatusName';
/* TODO: create a proper fake data once the form generation is migrated to frontend */
const featureForm = '["", ["fake feature field 1", "fake feature field 2"]]';
const implStatusForm = '["", ["fake implStatus field 1", "fake implStatus field 2"]]';
/* window.csClient and <chromedash-toast> are initialized at _base.html
* which are not available here, so we initialize them before each test.
* We also stub out the API calls here so that they return test data. */
beforeEach(async () => {
await fixture(html`<chromedash-toast></chromedash-toast>`);
window.csClient = new ChromeStatusClient('fake_token', 1);
sinon.stub(window.csClient, 'getFeature');
sinon.stub(window.csClient, 'getFeatureProcess');
window.csClient.getFeatureProcess.returns(processPromise);
});
afterEach(() => {
window.csClient.getFeature.restore();
window.csClient.getFeatureProcess.restore();
});
it('renders with no data', async () => {
const invalidFeaturePromise = Promise.reject(new Error('Got error response from server'));
window.csClient.getFeature.withArgs(0).returns(invalidFeaturePromise);
const component = await fixture(
html`<chromedash-guide-stage-page></chromedash-guide-stage-page>`);
assert.exists(component);
assert.instanceOf(component, ChromedashGuideStagePage);
// invalid feature requests would trigger the toast to show message
const toastEl = document.querySelector('chromedash-toast');
const toastMsgSpan = toastEl.shadowRoot.querySelector('span#msg');
assert.include(toastMsgSpan.innerHTML,
'Some errors occurred. Please refresh the page or try again later.');
});
it('renders with fake data (with implStatusForm and implStatusName)', async () => {
const stageId = 1;
const featureId = 123456;
window.csClient.getFeature.withArgs(featureId).returns(validFeaturePromise);
const component = await fixture(
html`<chromedash-guide-stage-page
.stageId=${stageId}
.featureId=${featureId}
.featureForm=${featureForm}
.implStatusForm=${implStatusForm}
.implStatusName=${implStatusName}>
</chromedash-guide-stage-page>`);
assert.exists(component);
assert.instanceOf(component, ChromedashGuideStagePage);
const subheaderDiv = component.shadowRoot.querySelector('div#subheader');
assert.exists(subheaderDiv);
// subheader title is correct and clickable
assert.include(subheaderDiv.innerHTML, 'href="/guide/edit/123456"');
assert.include(subheaderDiv.innerHTML, 'Edit feature:');
// feature form, hidden token field, and submit/cancel buttons exist
const form = component.shadowRoot.querySelector('form[name="feature_form"]');
assert.exists(form);
assert.include(form.innerHTML, '<input type="hidden" name="token">');
assert.include(form.innerHTML,
'<input type="hidden" name="form_fields" value="fake feature field 1,'+
'fake feature field 2,fake implStatus field 1,fake implStatus field 2">');
assert.include(form.innerHTML, '<div class="final_buttons">');
// Implementation section renders correct title and fields
assert.include(form.innerHTML, 'Implementation in Chromium');
assert.include(form.innerHTML, 'fake implStatusName');
assert.include(form.innerHTML, 'type="hidden" name="impl_status_offered"');
assert.include(form.innerHTML, 'type="checkbox" name="set_impl_status"');
assert.notInclude(form.innerHTML, 'This feature already has implementation status');
});
it('renders with fake data (without implStatusForm and implStatusName)', async () => {
const stageId = 1;
const featureId = 123456;
window.csClient.getFeature.withArgs(featureId).returns(validFeaturePromise);
const component = await fixture(
html`<chromedash-guide-stage-page
.stageId=${stageId}
.featureId=${featureId}
.featureForm=${featureForm}>
</chromedash-guide-stage-page>`);
assert.exists(component);
assert.instanceOf(component, ChromedashGuideStagePage);
const form = component.shadowRoot.querySelector('form[name="feature_form"]');
assert.exists(form);
// Implementation section renders correct title and fields
assert.notInclude(form.innerHTML, 'Implementation in Chromium');
assert.notInclude(form.innerHTML, 'This feature already has implementation status');
assert.notInclude(form.innerHTML, 'fake implStatusName');
assert.notInclude(form.innerHTML, 'type="hidden" name="impl_status_offered"');
assert.notInclude(form.innerHTML, 'type="checkbox" name="set_impl_status"');
});
});

Просмотреть файл

@ -13,19 +13,6 @@ export class ChromedashGuideVerifyAccuracyPage extends LitElement {
...SHARED_STYLES,
...FORM_STYLES,
css`
sl-skeleton {
margin-bottom: 1em;
width: 60%;
}
sl-skeleton:nth-of-type(even) {
width: 50%;
}
h3 sl-skeleton {
margin-top: 1em;
width: 30%;
height: 1.25em;
}
`];
}

Просмотреть файл

@ -1,53 +0,0 @@
(function() {
'use strict';
const fields = document.querySelectorAll('input, textarea');
for (let i = 0; i < fields.length; ++i) {
fields[i].addEventListener('input', (e) => {
e.target.classList.add('interacted');
});
}
// Allow editing if there was already a value specified in this
// deprecated field.
const timelineField = document.querySelector('#id_experiment_timeline');
if (timelineField && timelineField.value) {
timelineField.disabled = '';
}
if (document.querySelector('#cancel-button')) {
document.querySelector('#cancel-button').addEventListener('click', (e) => {
window.location.href = `/guide/edit/${e.currentTarget.dataset.id}`;
});
}
document.addEventListener('DOMContentLoaded', function() {
document.body.classList.remove('loading');
});
// Copy field SRC to DST if SRC is edited and DST was empty and
// has not been edited.
const COPY_ON_EDIT = [
['dt_milestone_desktop_start', 'dt_milestone_android_start'],
['dt_milestone_desktop_start', 'dt_milestone_webview_start'],
// Don't autofill dt_milestone_ios_start because it is rare.
['ot_milestone_desktop_start', 'ot_milestone_android_start'],
['ot_milestone_desktop_end', 'ot_milestone_android_end'],
['ot_milestone_desktop_start', 'ot_milestone_webview_start'],
['ot_milestone_desktop_end', 'ot_milestone_webview_end'],
];
for (let [srcId, dstId] of COPY_ON_EDIT) {
let srcEl = document.getElementById('id_' + srcId);
let dstEl = document.getElementById('id_' + dstId);
if (srcEl && dstEl && srcEl.value == dstEl.value) {
srcEl.addEventListener('input', (e) => {
if (!dstEl.classList.contains('interacted')) {
dstEl.value = srcEl.value;
dstEl.classList.add('copied');
}
});
}
}
})();

Просмотреть файл

@ -212,4 +212,16 @@ export const FORM_STYLES = [
margin: 0;
}
sl-skeleton {
margin-bottom: 1em;
width: 60%;
}
sl-skeleton:nth-of-type(even) {
width: 50%;
}
h3 sl-skeleton {
width: 30%;
height: 1.5em;
}
`];

Просмотреть файл

@ -201,3 +201,16 @@ chromedash-form-field {
}
}
sl-skeleton {
margin-bottom: 1em;
width: 60%;
}
sl-skeleton:nth-of-type(even) {
width: 50%;
}
h3 sl-skeleton {
width: 30%;
height: 1.5em;
}

Просмотреть файл

@ -1,99 +1,13 @@
{% extends "_base.html" %}
{% block page_title %}{{ feature.name }} - {% endblock %}
{% block css %}
<link rel="stylesheet" href="/static/css/forms.css?v={{app_version}}">
{% endblock %}
{% block subheader %}
<div id="subheader">
<h2 id="breadcrumbs">
<a href="/guide/edit/{{ feature_id }}">
<iron-icon icon="chromestatus:arrow-back"></iron-icon>
Edit feature: {{ feature.name }}
</a>
</h2>
</div>
<h3>{{ stage_name }}</h3>
{% endblock %}
{% block content %}
<form name="feature_form" method="POST" action="{{current_path}}">
<input type="hidden" name="token" value="{{xsrf_token}}">
<input type="hidden" name="form_fields"
value="{{ feature_form.fields.keys | join:',' }},
{% if impl_status_form %}
{{ impl_status_form.fields.keys | join:',' }}
{% endif %}" >
<section class="stage_form">
<chromedash-form-table>
{{ feature_form }}
<chromedash-form-field name="set_stage"
stage="{{ stage_name }}"
value="{{ already_on_this_stage }}"
{% if already_on_this_stage %}
disabled
{% endif %}
>
</chromedash-form-field>
</chromedash-form-table>
</section>
{% if impl_status_name or impl_status_form %}
<h3>Implementation in Chromium</h3>
<section class="stage_form">
<chromedash-form-table>
{% if impl_status_name %}
<chromedash-form-field>
<span slot="label">Implementation status:</span>
{% if already_on_this_impl_status %}
<span slot="help">
This feature already has implementation status:
<b>{{ impl_status_name }}</b>.
</td>
</span>
{% else %}
<span slot="field">
<input type="hidden" name="impl_status_offered"
value="{{impl_status_offered}}">
<input type="checkbox" name="set_impl_status"
id="set_impl_status">
<!-- TODO(jrobbins): When checked, make some milestone fields required. -->
<label for="set_impl_status">
Set implementation status to: <b>{{ impl_status_name }}</b>
</label>
</span>
<span slot="help">
Check this box to update the implementation
status of this feature in Chromium.
</span>
{% endif %}
</chromedash-form-field>
{% endif %}
{{ impl_status_form }}
</chromedash-form-table>
</section>
{% endif %}
<div style="padding-left: 220px" class="final_buttons">
<input class="button" type="submit" value="Submit">
<button id="cancel-button" data-id="{{ feature_id }}"
type="reset">Cancel</button>
</div>
</form>
{% endblock %}
{% block js %}
<script src="/static/js/admin/feature_form.min.js?v={{app_version}}"
nonce="{{nonce}}"></script>
<chromedash-guide-stage-page
stageId="{{ stage_id }}"
featureId="{{ feature_id }}"
featureForm="{{ feature_form }}"
implStatusForm="{{ impl_status_form }}"
implStatusName="{{ impl_status_name }}"
implStatusOffered="{{ impl_status_offered }}">
</chromedash-guide-stage-page>
{% endblock %}