[3/3] New Intent SPA component and direct intent posting (#4114)
* new Intents API and notifier for intent sending * OpenAPI for Intents API * Add request body for Intents API POST * reference request object correctly * Intents API tests * notifier tests for intent posting * fix tests * changes suggested by @jrobbins * changes suggested by @jcscottiii * Fix tests and add new ones * mypy and test fix * Changes suggested by @jcscottiii * Use OpenAPI object to create response * UI changes and paths for intent draft and posting * changes suggested by @jrobbins * lint-fix * read subject from API response * remove unused imports * Move copy intent body event to @click * Additional fixes intents_api.py: Pass in `gate_id` for intent GET request to populate URL query string. Also, pass `sections_to_show` param for template rendering. chromedash-intent-preview-page.js: When finding matching stage, search extension stages as well. cs-client.js: Pass `gateId` in GET request. notifier.py: Add APP_TITLE to populate intent template. main.py: Update route for passing `gate_id` in GET request. api.yaml: Add definition for `gate_id` in GET request.
This commit is contained in:
Родитель
ff3f830926
Коммит
e4b279863d
|
@ -28,6 +28,7 @@ from internals.core_enums import INTENT_STAGES_BY_STAGE_TYPE
|
|||
from internals.core_models import FeatureEntry
|
||||
from internals.review_models import Gate
|
||||
from pages.intentpreview import compute_subject_prefix
|
||||
import settings
|
||||
|
||||
|
||||
# Format for Google Cloud Task body passed to cloud_tasks_helpers.enqueue_task
|
||||
|
@ -58,6 +59,14 @@ class IntentsAPI(basehandlers.APIHandler):
|
|||
if stage.feature_id != feature_id:
|
||||
self.abort(400, msg='Stage does not belong to given feature')
|
||||
|
||||
gate_id = int(kwargs.get('gate_id', 0))
|
||||
if gate_id:
|
||||
gate: Gate|None = Gate.get_by_id(gate_id)
|
||||
if not gate:
|
||||
self.abort(400, msg='Invalid Gate ID')
|
||||
elif gate.stage_id != stage_id:
|
||||
self.abort(400, msg='Given gate does not belong to stage')
|
||||
|
||||
# Check that the user has feature edit permissions.
|
||||
redirect_resp = permissions.validate_feature_edit_permission(
|
||||
self, feature_id)
|
||||
|
@ -68,13 +77,19 @@ class IntentsAPI(basehandlers.APIHandler):
|
|||
intent_stage = INTENT_STAGES_BY_STAGE_TYPE[stage.stage_type]
|
||||
default_url = (f'{self.request.scheme}://{self.request.host}'
|
||||
f'/feature/{feature_id}')
|
||||
if gate_id:
|
||||
default_url += f'?gate={gate_id}'
|
||||
|
||||
template_data = {
|
||||
'feature': converters.feature_entry_to_json_verbose(feature),
|
||||
'stage_info': stage_helpers.get_stage_info_for_templates(feature),
|
||||
'should_render_mstone_table': stage_info['should_render_mstone_table'],
|
||||
'should_render_intents': stage_info['should_render_intents'],
|
||||
'sections_to_show': processes.INTENT_EMAIL_SECTIONS.get(
|
||||
intent_stage, []),
|
||||
'intent_stage': intent_stage,
|
||||
'default_url': default_url,
|
||||
'APP_TITLE': settings.APP_TITLE,
|
||||
}
|
||||
return GetIntentResponse(
|
||||
subject=(f'{compute_subject_prefix(feature, intent_stage)}: '
|
||||
|
|
|
@ -75,6 +75,8 @@ import './elements/chromedash-guide-stage-page';
|
|||
import './elements/chromedash-guide-metadata-page';
|
||||
import './elements/chromedash-guide-verify-accuracy-page';
|
||||
import './elements/chromedash-header';
|
||||
import './elements/chromedash-intent-content';
|
||||
import './elements/chromedash-intent-preview-page';
|
||||
import './elements/chromedash-legend';
|
||||
import './elements/chromedash-login-required-page';
|
||||
import './elements/chromedash-myfeatures-page';
|
||||
|
|
|
@ -442,6 +442,13 @@ export class ChromedashApp extends LitElement {
|
|||
this.pageComponent.appTitle = this.appTitle;
|
||||
}
|
||||
);
|
||||
page('/feature/:featureId(\\d+)/gate/:gateId(\\d+/intent)', ctx => {
|
||||
if (!this.setupNewPage(ctx, 'chromedash-intent-preview-page')) return;
|
||||
this.pageComponent.featureId = parseInt(ctx.params.featureId);
|
||||
this.pageComponent.gateId = parseInt(ctx.params.gateId);
|
||||
this.pageComponent.appTitle = this.appTitle;
|
||||
this.hideSidebar();
|
||||
});
|
||||
page('/ot_creation_request/:featureId(\\d+)/:stageId(\\d+)', ctx => {
|
||||
if (!this.setupNewPage(ctx, 'chromedash-ot-creation-page')) return;
|
||||
this.pageComponent.featureId = parseInt(ctx.params.featureId);
|
||||
|
|
|
@ -550,9 +550,8 @@ class ChromedashFeatureDetail extends LitElement {
|
|||
const label = action.name;
|
||||
const url = action.url
|
||||
.replace('{feature_id}', this.feature.id)
|
||||
.replace('{intent_stage}', stage.outgoing_stage)
|
||||
// No gate_id for this URL.
|
||||
.replace('/{gate_id}', '');
|
||||
// No gate_id for this URL. Use 0 by default.
|
||||
.replace('{gate_id}', '0');
|
||||
|
||||
const gatesForStage = this.gates.filter(g => g.stage_id == feStage.id);
|
||||
const checkCompletion = () => {
|
||||
|
|
|
@ -485,8 +485,7 @@ export class ChromedashGateColumn extends LitElement {
|
|||
const label = action.name;
|
||||
const url = action.url
|
||||
.replace('{feature_id}', this.feature.id)
|
||||
.replace('{intent_stage}', processStage.outgoing_stage)
|
||||
.replace('{gate_id}', this.gate.id);
|
||||
.replace('{gate_id}', this.gate.id || 0);
|
||||
|
||||
const checkCompletion = () => {
|
||||
if (
|
||||
|
|
|
@ -0,0 +1,197 @@
|
|||
import {LitElement, css, html, nothing} from 'lit';
|
||||
import {unsafeHTML} from 'lit/directives/unsafe-html.js';
|
||||
import {SHARED_STYLES} from '../css/shared-css.js';
|
||||
import {showToastMessage} from './utils.js';
|
||||
|
||||
export class ChromedashIntentContent extends LitElement {
|
||||
static get properties() {
|
||||
return {
|
||||
appTitle: {type: String},
|
||||
subject: {type: String},
|
||||
intentBody: {type: String},
|
||||
};
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.appTitle = '';
|
||||
this.subject = '';
|
||||
this.intentBody = '';
|
||||
}
|
||||
|
||||
static get styles() {
|
||||
return [
|
||||
...SHARED_STYLES,
|
||||
css`
|
||||
#copy-email-body {
|
||||
cursor: pointer;
|
||||
color: var(--link-color);
|
||||
}
|
||||
.email-content-border {
|
||||
border: 1px solid #ddd;
|
||||
box-shadow: rgba(0, 0, 0, 0.067) 1px 1px 4px;
|
||||
margin: 8px 0 24px 0;
|
||||
}
|
||||
.email-content-div {
|
||||
background: white;
|
||||
padding: 12px;
|
||||
}
|
||||
p {
|
||||
color: #444;
|
||||
}
|
||||
h3 {
|
||||
margin-bottom: 10px;
|
||||
&:before {
|
||||
counter-increment: h3;
|
||||
content: counter(h3) '.';
|
||||
margin-right: 5px;
|
||||
}
|
||||
}
|
||||
#content section > div {
|
||||
background: white;
|
||||
border: 1px solid #ddd;
|
||||
box-shadow: rgba(0, 0, 0, 0.067) 1px 1px 4px;
|
||||
padding: 12px;
|
||||
margin: 8px 0 16px 0;
|
||||
}
|
||||
#content section > p {
|
||||
color: #444;
|
||||
}
|
||||
|
||||
.email .help {
|
||||
font-style: italic;
|
||||
color: #aaa;
|
||||
}
|
||||
.email h4 {
|
||||
font-weight: 600;
|
||||
}
|
||||
.alertbox {
|
||||
margin: 2em;
|
||||
padding: 1em;
|
||||
background: var(--warning-background);
|
||||
color: var(--warning-color);
|
||||
}
|
||||
.subject {
|
||||
font-size: 16px;
|
||||
}
|
||||
table {
|
||||
tr[hidden] {
|
||||
th,
|
||||
td {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
th {
|
||||
padding: 12px 10px 5px 0;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 6px 10px;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
td:first-of-type {
|
||||
width: 60%;
|
||||
}
|
||||
|
||||
.helptext {
|
||||
display: block;
|
||||
font-size: small;
|
||||
max-width: 40em;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
input[type='text'],
|
||||
input[type='url'],
|
||||
input[type='email'],
|
||||
textarea {
|
||||
width: 100%;
|
||||
font: var(--form-element-font);
|
||||
}
|
||||
|
||||
select {
|
||||
max-width: 350px;
|
||||
}
|
||||
|
||||
:required {
|
||||
border: 1px solid $chromium-color-dark;
|
||||
}
|
||||
|
||||
.interacted:valid {
|
||||
border: 1px solid green;
|
||||
}
|
||||
|
||||
.interacted:invalid {
|
||||
border: 1px solid $invalid-color;
|
||||
}
|
||||
|
||||
input:not([type='submit']):not([type='search']) {
|
||||
outline: 1px dotted var(--error-border-color);
|
||||
background-color: #ffedf5;
|
||||
}
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
renderEmailBody() {
|
||||
if (this.intentBody) {
|
||||
// Needed for rendering HTML format returned from the API.
|
||||
return unsafeHTML(this.intentBody);
|
||||
}
|
||||
return nothing;
|
||||
}
|
||||
|
||||
copyIntentBodyHandler() {
|
||||
const copyEmailBodyEl = this.shadowRoot.querySelector('#copy-email-body');
|
||||
const emailBodyEl = this.shadowRoot.querySelector('.email');
|
||||
if (copyEmailBodyEl && emailBodyEl) {
|
||||
window.getSelection().removeAllRanges();
|
||||
const range = document.createRange();
|
||||
range.selectNode(emailBodyEl);
|
||||
window.getSelection().addRange(range);
|
||||
document.execCommand('copy');
|
||||
showToastMessage('Email body copied');
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<p>Email to</p>
|
||||
<div class="email-content-border">
|
||||
<div class="subject email-content-div">blink-dev@chromium.org</div>
|
||||
</div>
|
||||
|
||||
<p>Subject</p>
|
||||
<div class="email-content-border">
|
||||
<div class="subject email-content-div" id="email-subject-content">
|
||||
${this.subject}
|
||||
</div>
|
||||
</div>
|
||||
<p>
|
||||
Body
|
||||
<span
|
||||
class="tooltip copy-text"
|
||||
style="float:right"
|
||||
title="Copy text to clipboard"
|
||||
>
|
||||
<iron-icon
|
||||
icon="chromestatus:content_copy"
|
||||
id="copy-email-body"
|
||||
@click="${() => this.copyIntentBodyHandler()}"
|
||||
></iron-icon>
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<div class="email-content-border">
|
||||
<div class="email email-content-div" id="email-body-content">
|
||||
${this.renderEmailBody()}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('chromedash-intent-content', ChromedashIntentContent);
|
|
@ -0,0 +1,235 @@
|
|||
import {LitElement, css, html, nothing} from 'lit';
|
||||
import {SHARED_STYLES} from '../css/shared-css.js';
|
||||
import {showToastMessage} from './utils';
|
||||
import {openPostIntentDialog} from './chromedash-post-intent-dialog.js';
|
||||
import {
|
||||
STAGE_TYPES_DEV_TRIAL,
|
||||
STAGE_TYPES_SHIPPING,
|
||||
} from './form-field-enums.js';
|
||||
import './chromedash-intent-content.js';
|
||||
|
||||
class ChromedashIntentPreviewPage extends LitElement {
|
||||
static get properties() {
|
||||
return {
|
||||
appTitle: {type: String},
|
||||
featureId: {type: Number},
|
||||
gateId: {type: Number},
|
||||
feature: {type: Object},
|
||||
stage: {type: Object},
|
||||
gate: {type: Object},
|
||||
loading: {type: Boolean},
|
||||
subject: {type: String},
|
||||
intentBody: {type: String},
|
||||
displayFeatureUnlistedWarning: {type: Boolean},
|
||||
};
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.appTitle = '';
|
||||
this.featureId = 0;
|
||||
this.gateId = 0;
|
||||
this.feature = undefined;
|
||||
this.stage = undefined;
|
||||
this.gate = undefined;
|
||||
this.loading = true;
|
||||
this.subject = '';
|
||||
this.intentBody = '';
|
||||
this.displayFeatureUnlistedWarning = false;
|
||||
}
|
||||
|
||||
static get styles() {
|
||||
return [
|
||||
...SHARED_STYLES,
|
||||
css`
|
||||
#content {
|
||||
flex-direction: column;
|
||||
counter-reset: h3;
|
||||
height: auto;
|
||||
}
|
||||
#content section {
|
||||
max-width: 800px;
|
||||
flex: 1 0 auto;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
h3 {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
#content h3:before {
|
||||
counter-increment: h3;
|
||||
content: counter(h3) '.';
|
||||
margin-right: 5px;
|
||||
}
|
||||
#post-intent-button {
|
||||
float: right;
|
||||
}
|
||||
.inline {
|
||||
display: inline;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.fetchData();
|
||||
}
|
||||
|
||||
fetchData() {
|
||||
Promise.all([
|
||||
window.csClient.getFeature(this.featureId),
|
||||
window.csClient.getGates(this.featureId),
|
||||
])
|
||||
.then(([feature, gates]) => {
|
||||
this.feature = feature;
|
||||
document.title = `${this.feature.name} - ${this.appTitle}`;
|
||||
// TODO(DanielRyanSmith): only fetch a single gate based on given ID.
|
||||
if (this.gateId) {
|
||||
this.gate = gates.gates.find(gate => gate.id === this.gateId);
|
||||
}
|
||||
if (this.gate) {
|
||||
// Find the stage that matches the given gate.
|
||||
for (const stage of this.feature.stages) {
|
||||
if (this.stage) {
|
||||
break;
|
||||
}
|
||||
if (stage.id === this.gate.stage_id) {
|
||||
this.stage = stage;
|
||||
}
|
||||
// Check if gate matches an extension stage.
|
||||
if (!this.stage) {
|
||||
this.stage = stage.extensions.find(
|
||||
e => e.id === this.gate.stage_id
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if (!this.gateId) {
|
||||
// This is a "Ready for Developer Testing" intent if no gate is supplied (0).
|
||||
this.stage = this.feature.stages.find(stage =>
|
||||
STAGE_TYPES_DEV_TRIAL.has(stage.stage_type)
|
||||
);
|
||||
} else {
|
||||
throw new Error('Invalid gate ID');
|
||||
}
|
||||
|
||||
if (this.feature.unlisted) {
|
||||
this.displayFeatureUnlistedWarning = true;
|
||||
}
|
||||
// Finally, get the contents of the intent based on the feature/stage.
|
||||
return window.csClient.getIntentBody(
|
||||
this.featureId,
|
||||
this.stage.id,
|
||||
this.gateId
|
||||
);
|
||||
})
|
||||
.then(intentResp => {
|
||||
this.subject = intentResp.subject;
|
||||
this.intentBody = intentResp.email_body;
|
||||
this.loading = false;
|
||||
})
|
||||
.catch(() => {
|
||||
showToastMessage(
|
||||
'Some errors occurred. Please refresh the page or try again later.'
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
renderThreeLGTMSection() {
|
||||
// Show for shipping stages only.
|
||||
if (!STAGE_TYPES_SHIPPING.has(this.stage?.stage_type)) return nothing;
|
||||
return html` <section>
|
||||
<h3>Obtain LGTMs from 3 API Owners</h3>
|
||||
<span class="help">
|
||||
You will need three LGTMs from API owners. According to the
|
||||
<a href="http://www.chromium.org/blink#launch-process"
|
||||
>Blink Launch process</a
|
||||
>
|
||||
after that, you're free to ship your feature.
|
||||
</span>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
renderFeatureUnlistedAlert() {
|
||||
if (!this.displayFeatureUnlistedWarning) return nothing;
|
||||
return html`<div class="alertbox">
|
||||
Important: This feature is currently unlisted. Please only share feature
|
||||
details with people who are collaborating with you on the feature.
|
||||
</div>`;
|
||||
}
|
||||
|
||||
renderSkeletonSection() {
|
||||
return html`
|
||||
<section>
|
||||
<h3><sl-skeleton effect="sheen"></sl-skeleton></h3>
|
||||
<p>
|
||||
<sl-skeleton effect="sheen"></sl-skeleton>
|
||||
<sl-skeleton effect="sheen"></sl-skeleton>
|
||||
</p>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
renderSkeletons() {
|
||||
return html`
|
||||
<div id="feature" style="margin-top: 65px;">
|
||||
${this.renderSkeletonSection()} ${this.renderSkeletonSection()}
|
||||
${this.renderSkeletonSection()} ${this.renderSkeletonSection()}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.loading) {
|
||||
return this.renderSkeletons();
|
||||
}
|
||||
return html`
|
||||
<div id="content">
|
||||
<div id="subheader">
|
||||
<div>
|
||||
<h2>Next steps for the Blink launch process</h2>
|
||||
</div>
|
||||
</div>
|
||||
${this.renderFeatureUnlistedAlert()}
|
||||
<section>
|
||||
<h3>Reach out to a spec mentor</h3>
|
||||
<p style="margin-left: 1em">
|
||||
Consider showing your draft intent email to your spec mentor or
|
||||
sending it to spec-mentors@chromium.org. They can help make sure
|
||||
that your intent email is ready for review.
|
||||
</p>
|
||||
</section>
|
||||
<section>
|
||||
<h3 class="inline">Send this text for your "Intent to ..." email</h3>
|
||||
<input
|
||||
ref()
|
||||
id="post-intent-button"
|
||||
class="button inline"
|
||||
type="submit"
|
||||
value="Post directly to blink-dev"
|
||||
@click="${() =>
|
||||
openPostIntentDialog(
|
||||
this.feature.id,
|
||||
this.stage.id,
|
||||
this.feature.owner_emails,
|
||||
this.gate?.id
|
||||
)}"
|
||||
/>
|
||||
<chromedash-intent-content
|
||||
appTitle="${this.appTitle}"
|
||||
.feature=${this.feature}
|
||||
.stage=${this.stage}
|
||||
subject="${this.subject}"
|
||||
intentBody="${this.intentBody}"
|
||||
>
|
||||
</chromedash-intent-content>
|
||||
</section>
|
||||
${this.renderThreeLGTMSection()}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define(
|
||||
'chromedash-intent-preview-page',
|
||||
ChromedashIntentPreviewPage
|
||||
);
|
|
@ -0,0 +1,26 @@
|
|||
import {html} from 'lit';
|
||||
import {assert, fixture} from '@open-wc/testing';
|
||||
import {ChromedashIntentContent} from './chromedash-intent-content';
|
||||
|
||||
describe('chromedash-intent-content', () => {
|
||||
it('renders with fake data', async () => {
|
||||
const component = await fixture(
|
||||
html`<chromedash-intent-content
|
||||
appTitle="Chrome Status Test"
|
||||
subject="A fake subject"
|
||||
intentBody="<div>A basic intent body</div>"
|
||||
>
|
||||
</chromedash-intent-content>`
|
||||
);
|
||||
assert.exists(component);
|
||||
assert.instanceOf(component, ChromedashIntentContent);
|
||||
|
||||
const subject = component.shadowRoot.querySelector(
|
||||
'#email-subject-content'
|
||||
);
|
||||
const body = component.shadowRoot.querySelector('#email-body-content');
|
||||
|
||||
assert.equal(body.innerText, 'A basic intent body');
|
||||
assert.equal(subject.innerText, 'A fake subject');
|
||||
});
|
||||
});
|
|
@ -0,0 +1,161 @@
|
|||
import {LitElement, css, html} from 'lit';
|
||||
import {customElement, property} from 'lit/decorators.js';
|
||||
import {ref} from 'lit/directives/ref.js';
|
||||
import {FORM_STYLES} from '../css/forms-css.js';
|
||||
import {SHARED_STYLES} from '../css/shared-css.js';
|
||||
import {ALL_FIELDS} from './form-field-specs.js';
|
||||
import {showToastMessage} from './utils.js';
|
||||
|
||||
let dialogEl;
|
||||
|
||||
export async function openPostIntentDialog(
|
||||
featureId: number,
|
||||
stageId: number,
|
||||
ownerEmails: Array<string>,
|
||||
gateId = 0
|
||||
) {
|
||||
if (!dialogEl) {
|
||||
dialogEl = document.createElement('chromedash-post-intent-dialog');
|
||||
document.body.appendChild(dialogEl);
|
||||
dialogEl.featureId = featureId;
|
||||
dialogEl.stageId = stageId;
|
||||
dialogEl.gateId = gateId;
|
||||
dialogEl.ownerEmails = ownerEmails;
|
||||
await dialogEl.updateComplete;
|
||||
}
|
||||
dialogEl.show();
|
||||
}
|
||||
|
||||
@customElement('chromedash-post-intent-dialog')
|
||||
class ChromedashPostIntentDialog extends LitElement {
|
||||
@property({type: Number})
|
||||
featureId = 0;
|
||||
@property({type: Number})
|
||||
stageId = 0;
|
||||
@property({type: Number})
|
||||
gateId = 0;
|
||||
@property({type: Array<string>})
|
||||
ownerEmails = [];
|
||||
|
||||
static get styles() {
|
||||
return [
|
||||
...SHARED_STYLES,
|
||||
...FORM_STYLES,
|
||||
css`
|
||||
#prereqs-list li {
|
||||
margin-left: 8px;
|
||||
margin-bottom: 8px;
|
||||
list-style: circle;
|
||||
}
|
||||
#prereqs-header {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
#update-button {
|
||||
margin-right: 8px;
|
||||
}
|
||||
.float-right {
|
||||
float: right;
|
||||
}
|
||||
sl-input::part(base) {
|
||||
margin-top: 8px;
|
||||
}
|
||||
sl-input[data-user-invalid]::part(base) {
|
||||
border-color: red;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
show() {
|
||||
this.shadowRoot!.querySelector('sl-dialog')!.show();
|
||||
}
|
||||
|
||||
updateAttributes(el) {
|
||||
if (!el) return;
|
||||
|
||||
const attrs = ALL_FIELDS.intent_cc_emails.attrs || {};
|
||||
Object.keys(attrs).map(attr => {
|
||||
el.setAttribute(attr, attrs[attr]);
|
||||
});
|
||||
}
|
||||
|
||||
renderIntentCCEmailOption() {
|
||||
const fieldInfo = ALL_FIELDS.intent_cc_emails;
|
||||
const defaultCCEmails = this.ownerEmails.join(',');
|
||||
|
||||
return html`${fieldInfo.help_text}<br />
|
||||
<sl-input
|
||||
${ref(this.updateAttributes)}
|
||||
id="id_${fieldInfo.name}"
|
||||
size="small"
|
||||
autocomplete="off"
|
||||
.value=${defaultCCEmails}
|
||||
?required=${fieldInfo.required}
|
||||
>
|
||||
</sl-input>`;
|
||||
}
|
||||
|
||||
submitIntent() {
|
||||
// Make sure that the CC emails input is valid.
|
||||
const ccEmailsInput = this.shadowRoot!.querySelector('sl-input');
|
||||
if (!ccEmailsInput || ccEmailsInput.hasAttribute('data-user-invalid')) {
|
||||
return;
|
||||
}
|
||||
const submitButton = this.shadowRoot!.querySelector(
|
||||
'#submit-intent-button'
|
||||
);
|
||||
if (submitButton) {
|
||||
submitButton.setAttribute('disabled', '');
|
||||
}
|
||||
window.csClient
|
||||
.postIntentToBlinkDev(this.featureId, this.stageId, {
|
||||
gate_id: this.gateId,
|
||||
intent_cc_emails: ccEmailsInput?.value?.split(','),
|
||||
})
|
||||
.then(() => {
|
||||
showToastMessage(
|
||||
'Intent submitted! Check for your thread on blink-dev shortly.'
|
||||
);
|
||||
setTimeout(() => {
|
||||
window.location.href = `/feature/${this.featureId}`;
|
||||
}, 3000);
|
||||
})
|
||||
.catch(() => {
|
||||
showToastMessage(
|
||||
'Some errors occurred. Please refresh the page or try again later.'
|
||||
);
|
||||
if (submitButton) {
|
||||
submitButton.removeAttribute('disabled');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
renderDialog() {
|
||||
return html`<sl-dialog label="Post intent to blink-dev">
|
||||
<p>
|
||||
This intent will be sent directly to
|
||||
<a
|
||||
href="https://groups.google.com/a/chromium.org/g/blink-dev"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>blink-dev</a
|
||||
>.
|
||||
</p>
|
||||
<br /><br />
|
||||
${this.renderIntentCCEmailOption()}
|
||||
<br /><br />
|
||||
<sl-button
|
||||
class="float-right"
|
||||
id="submit-intent-button"
|
||||
variant="primary"
|
||||
size="small"
|
||||
@click=${() => this.submitIntent()}
|
||||
>Submit intent</sl-button
|
||||
>
|
||||
</sl-dialog>`;
|
||||
}
|
||||
|
||||
render() {
|
||||
return this.renderDialog();
|
||||
}
|
||||
}
|
|
@ -2104,6 +2104,15 @@ export const ALL_FIELDS: Record<string, Field> = {
|
|||
help_text: html` This is a breaking change: customers or developers must
|
||||
take action to continue using some existing functionaity.`,
|
||||
},
|
||||
|
||||
intent_cc_emails: {
|
||||
type: 'input',
|
||||
attrs: MULTI_EMAIL_FIELD_ATTRS,
|
||||
required: false,
|
||||
label: 'Intent email CC list',
|
||||
help_text: html`Add emails to the CC list of the intent email.<br />
|
||||
Comma separated list of full email addresses.`,
|
||||
},
|
||||
};
|
||||
|
||||
// Return a simplified field type to help differentiate the
|
||||
|
|
|
@ -661,6 +661,14 @@ export class ChromeStatusClient {
|
|||
return this.doGet(`/features/${featureId}/process`);
|
||||
}
|
||||
|
||||
// Intents API
|
||||
async getIntentBody(featureId, stageId, gateId) {
|
||||
return this.doGet(`/features/${featureId}/${stageId}/${gateId}/intent`);
|
||||
}
|
||||
async postIntentToBlinkDev(featureId, stageId, body) {
|
||||
return this.doPost(`/features/${featureId}/${stageId}/intent`, body);
|
||||
}
|
||||
|
||||
// Progress API
|
||||
async getFeatureProgress(featureId) {
|
||||
return this.doGet(`/features/${featureId}/progress`);
|
||||
|
|
|
@ -55,6 +55,7 @@ export interface AddUserToComponentRequest {
|
|||
export interface GetIntentBodyRequest {
|
||||
featureId: number;
|
||||
stageId: number;
|
||||
gateId: number;
|
||||
}
|
||||
|
||||
export interface ListExternalReviewsRequest {
|
||||
|
@ -73,6 +74,7 @@ export interface ListSpecMentorsRequest {
|
|||
export interface PostIntentToBlinkDevRequest {
|
||||
featureId: number;
|
||||
stageId: number;
|
||||
gateId: number;
|
||||
postIntentRequest?: PostIntentRequest;
|
||||
}
|
||||
|
||||
|
@ -111,6 +113,7 @@ export interface DefaultApiInterface {
|
|||
* @summary Get the HTML body of an intent draft
|
||||
* @param {number} featureId Feature ID
|
||||
* @param {number} stageId Stage ID
|
||||
* @param {number} gateId Gate ID
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
* @memberof DefaultApiInterface
|
||||
|
@ -201,6 +204,7 @@ export interface DefaultApiInterface {
|
|||
* @summary Submit an intent to be posted on blink-dev
|
||||
* @param {number} featureId Feature ID
|
||||
* @param {number} stageId Stage ID
|
||||
* @param {number} gateId Gate ID
|
||||
* @param {PostIntentRequest} [postIntentRequest] Gate ID and additional users to CC email to.
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
|
@ -301,12 +305,19 @@ export class DefaultApi extends runtime.BaseAPI implements DefaultApiInterface {
|
|||
);
|
||||
}
|
||||
|
||||
if (requestParameters['gateId'] == null) {
|
||||
throw new runtime.RequiredError(
|
||||
'gateId',
|
||||
'Required parameter "gateId" was null or undefined when calling getIntentBody().'
|
||||
);
|
||||
}
|
||||
|
||||
const queryParameters: any = {};
|
||||
|
||||
const headerParameters: runtime.HTTPHeaders = {};
|
||||
|
||||
const response = await this.request({
|
||||
path: `/features/{feature_id}/{stage_id}/intent`.replace(`{${"feature_id"}}`, encodeURIComponent(String(requestParameters['featureId']))).replace(`{${"stage_id"}}`, encodeURIComponent(String(requestParameters['stageId']))),
|
||||
path: `/features/{feature_id}/{stage_id}/{gate_id}/intent`.replace(`{${"feature_id"}}`, encodeURIComponent(String(requestParameters['featureId']))).replace(`{${"stage_id"}}`, encodeURIComponent(String(requestParameters['stageId']))).replace(`{${"gate_id"}}`, encodeURIComponent(String(requestParameters['gateId']))),
|
||||
method: 'GET',
|
||||
headers: headerParameters,
|
||||
query: queryParameters,
|
||||
|
@ -508,6 +519,13 @@ export class DefaultApi extends runtime.BaseAPI implements DefaultApiInterface {
|
|||
);
|
||||
}
|
||||
|
||||
if (requestParameters['gateId'] == null) {
|
||||
throw new runtime.RequiredError(
|
||||
'gateId',
|
||||
'Required parameter "gateId" was null or undefined when calling postIntentToBlinkDev().'
|
||||
);
|
||||
}
|
||||
|
||||
const queryParameters: any = {};
|
||||
|
||||
const headerParameters: runtime.HTTPHeaders = {};
|
||||
|
@ -515,7 +533,7 @@ export class DefaultApi extends runtime.BaseAPI implements DefaultApiInterface {
|
|||
headerParameters['Content-Type'] = 'application/json';
|
||||
|
||||
const response = await this.request({
|
||||
path: `/features/{feature_id}/{stage_id}/intent`.replace(`{${"feature_id"}}`, encodeURIComponent(String(requestParameters['featureId']))).replace(`{${"stage_id"}}`, encodeURIComponent(String(requestParameters['stageId']))),
|
||||
path: `/features/{feature_id}/{stage_id}/{gate_id}/intent`.replace(`{${"feature_id"}}`, encodeURIComponent(String(requestParameters['featureId']))).replace(`{${"stage_id"}}`, encodeURIComponent(String(requestParameters['stageId']))).replace(`{${"gate_id"}}`, encodeURIComponent(String(requestParameters['gateId']))),
|
||||
method: 'POST',
|
||||
headers: headerParameters,
|
||||
query: queryParameters,
|
||||
|
|
|
@ -34,7 +34,7 @@ def add_user_to_component(component_id, user_id, component_users_request=None):
|
|||
return 'do some magic!'
|
||||
|
||||
|
||||
def get_intent_body(feature_id, stage_id): # noqa: E501
|
||||
def get_intent_body(feature_id, stage_id, gate_id): # noqa: E501
|
||||
"""Get the HTML body of an intent draft
|
||||
|
||||
# noqa: E501
|
||||
|
@ -43,6 +43,8 @@ def get_intent_body(feature_id, stage_id): # noqa: E501
|
|||
:type feature_id: int
|
||||
:param stage_id: Stage ID
|
||||
:type stage_id: int
|
||||
:param gate_id: Gate ID
|
||||
:type gate_id: int
|
||||
|
||||
:rtype: Union[GetIntentResponse, Tuple[GetIntentResponse, int], Tuple[GetIntentResponse, int, Dict[str, str]]
|
||||
"""
|
||||
|
@ -115,7 +117,7 @@ def list_spec_mentors(after=None): # noqa: E501
|
|||
return 'do some magic!'
|
||||
|
||||
|
||||
def post_intent_to_blink_dev(feature_id, stage_id, post_intent_request=None): # noqa: E501
|
||||
def post_intent_to_blink_dev(feature_id, stage_id, gate_id, post_intent_request=None): # noqa: E501
|
||||
"""Submit an intent to be posted on blink-dev
|
||||
|
||||
# noqa: E501
|
||||
|
@ -124,6 +126,8 @@ def post_intent_to_blink_dev(feature_id, stage_id, post_intent_request=None): #
|
|||
:type feature_id: int
|
||||
:param stage_id: Stage ID
|
||||
:type stage_id: int
|
||||
:param gate_id: Gate ID
|
||||
:type gate_id: int
|
||||
:param post_intent_request: Gate ID and additional users to CC email to.
|
||||
:type post_intent_request: dict | bytes
|
||||
|
||||
|
|
|
@ -160,7 +160,7 @@ paths:
|
|||
format.
|
||||
summary: List how long each feature took to launch
|
||||
x-openapi-router-controller: chromestatus_openapi.controllers.default_controller
|
||||
/features/{feature_id}/{stage_id}/intent:
|
||||
/features/{feature_id}/{stage_id}/{gate_id}/intent:
|
||||
get:
|
||||
operationId: get_intent_body
|
||||
parameters:
|
||||
|
@ -180,6 +180,14 @@ paths:
|
|||
schema:
|
||||
type: integer
|
||||
style: simple
|
||||
- description: Gate ID
|
||||
explode: false
|
||||
in: path
|
||||
name: gate_id
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
style: simple
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
|
@ -212,6 +220,14 @@ paths:
|
|||
schema:
|
||||
type: integer
|
||||
style: simple
|
||||
- description: Gate ID
|
||||
explode: false
|
||||
in: path
|
||||
name: gate_id
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
style: simple
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
|
|
|
@ -45,7 +45,7 @@ class TestDefaultController(BaseTestCase):
|
|||
'Accept': 'application/json:',
|
||||
}
|
||||
response = self.client.open(
|
||||
'/api/v0/features/{feature_id}/{stage_id}/intent'.format(feature_id=56, stage_id=56),
|
||||
'/api/v0/features/{feature_id}/{stage_id}/{gate_id}/intent'.format(feature_id=56, stage_id=56, gate_id=56),
|
||||
method='GET',
|
||||
headers=headers)
|
||||
self.assert200(response,
|
||||
|
@ -143,7 +143,7 @@ class TestDefaultController(BaseTestCase):
|
|||
'Content-Type': 'application/json',
|
||||
}
|
||||
response = self.client.open(
|
||||
'/api/v0/features/{feature_id}/{stage_id}/intent'.format(feature_id=56, stage_id=56),
|
||||
'/api/v0/features/{feature_id}/{stage_id}/{gate_id}/intent'.format(feature_id=56, stage_id=56, gate_id=56),
|
||||
method='POST',
|
||||
headers=headers,
|
||||
data=json.dumps(post_intent_request),
|
||||
|
|
|
@ -902,6 +902,7 @@ class IntentToBlinkDevHandler(basehandlers.FlaskHandler):
|
|||
'should_render_intents': stage_info['should_render_intents'],
|
||||
'intent_stage': json_data['intent_stage'],
|
||||
'default_url': json_data['default_url'],
|
||||
'APP_TITLE': settings.APP_TITLE,
|
||||
}
|
||||
body = render_template(self.EMAIL_TEMPLATE_PATH, **template_data)
|
||||
|
||||
|
|
|
@ -72,9 +72,7 @@ def process_to_dict(process):
|
|||
# The param "intent" adds clauses the template to include details
|
||||
# needed for an intent email. The param "launch" causes those
|
||||
# details to be omitted and a link to create a launch bug shown instead.
|
||||
INTENT_EMAIL_URL = ('/admin/features/launch/{feature_id}'
|
||||
'/{intent_stage}/{gate_id}'
|
||||
'?intent=1')
|
||||
INTENT_EMAIL_URL = ('/feature/{feature_id}/gate/{gate_id}/intent')
|
||||
LAUNCH_BUG_TEMPLATE_URL = '/admin/features/launch/{feature_id}?launch=1'
|
||||
# TODO(jrobbins): Creation of the launch bug has been a TODO for 5 years.
|
||||
|
||||
|
|
|
@ -149,7 +149,7 @@ False
|
|||
|
||||
|
||||
|
||||
<br><br><h4>Link to entry on the </h4>
|
||||
<br><br><h4>Link to entry on the Local testing</h4>
|
||||
<a href="https://chromestatus.com/feature/1?gate=$100">https://chromestatus.com/feature/1?gate=$100</a>
|
||||
|
||||
|
||||
|
|
8
main.py
8
main.py
|
@ -30,6 +30,7 @@ from api import (
|
|||
feature_latency_api,
|
||||
feature_links_api,
|
||||
features_api,
|
||||
intents_api,
|
||||
login_api,
|
||||
logout_api,
|
||||
metricsdata,
|
||||
|
@ -134,6 +135,10 @@ api_routes: list[Route] = [
|
|||
Route(
|
||||
f'{API_BASE}/features/<int:feature_id>/stages/<int:stage_id>/addXfnGates',
|
||||
reviews_api.XfnGatesAPI),
|
||||
Route(f'{API_BASE}/features/<int:feature_id>/<int:stage_id>/intent',
|
||||
intents_api.IntentsAPI),
|
||||
Route(f'{API_BASE}/features/<int:feature_id>/<int:stage_id>/<int:gate_id>/intent',
|
||||
intents_api.IntentsAPI),
|
||||
|
||||
Route(f'{API_BASE}/blinkcomponents',
|
||||
blink_components_api.BlinkComponentsAPI),
|
||||
|
@ -202,6 +207,8 @@ spa_page_routes = [
|
|||
defaults={'require_edit_feature': True}),
|
||||
Route('/guide/stage/<int:feature_id>/metadata',
|
||||
defaults={'require_edit_feature': True}),
|
||||
Route('/feature/<int:feature_id>/gate/<int:gate_id>/intent',
|
||||
defaults={'require_edit_feature': True}),
|
||||
Route('/ot_creation_request/<int:feature_id>/<int:stage_id>',
|
||||
defaults={'require_signin': True}),
|
||||
Route('/ot_extension_request/<int:feature_id>/<int:stage_id>',
|
||||
|
@ -300,6 +307,7 @@ internals_routes: list[Route] = [
|
|||
Route('/tasks/email-ot-extended', notifier.OTExtendedHandler),
|
||||
Route('/tasks/email-ot-extension-approved',
|
||||
notifier.OTExtensionApprovedHandler),
|
||||
Route('/tasks/email-intent-to-blink-dev', notifier.IntentToBlinkDevHandler),
|
||||
|
||||
# OT process reminder emails
|
||||
Route('/tasks/email-ot-first-branch', notifier.OTFirstBranchReminderHandler),
|
||||
|
|
|
@ -146,7 +146,7 @@ paths:
|
|||
$ref: '#/components/schemas/SpecMentor'
|
||||
'400':
|
||||
description: The ?after query parameter isn't a valid date in ISO YYYY-MM-DD format.
|
||||
/features/{feature_id}/{stage_id}/intent:
|
||||
/features/{feature_id}/{stage_id}/{gate_id}/intent:
|
||||
parameters:
|
||||
- name: feature_id
|
||||
in: path
|
||||
|
@ -160,6 +160,12 @@ paths:
|
|||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
- name: gate_id
|
||||
in: path
|
||||
description: Gate ID
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
get:
|
||||
summary: Get the HTML body of an intent draft
|
||||
operationId: getIntentBody
|
||||
|
|
Загрузка…
Ссылка в новой задаче