Keep features up-to-date in the Feature Detail page (#4481)
* Feature detail page UI for keeping upcoming milestones up-to-date
This commit is contained in:
Родитель
b4c5cedb5f
Коммит
59a1e11bee
|
@ -7,13 +7,15 @@ import {
|
|||
FeatureLink,
|
||||
FeatureNotFoundError,
|
||||
User,
|
||||
StageDict,
|
||||
} from '../js-src/cs-client.js';
|
||||
import './chromedash-feature-detail';
|
||||
import {DETAILS_STYLES} from './chromedash-feature-detail';
|
||||
import './chromedash-feature-highlights.js';
|
||||
import {GateDict} from './chromedash-gate-chip.js';
|
||||
import {Process, ProgressItem} from './chromedash-gate-column.js';
|
||||
import {showToastMessage} from './utils.js';
|
||||
import {showToastMessage, isVerifiedWithinGracePeriod} from './utils.js';
|
||||
import {STAGE_TYPES_SHIPPING} from './form-field-enums';
|
||||
|
||||
const INACTIVE_STATES = ['No longer pursuing', 'Deprecated', 'Removed'];
|
||||
declare var ga: Function;
|
||||
|
@ -109,6 +111,13 @@ export class ChromedashFeaturePage extends LitElement {
|
|||
starred = false;
|
||||
@state()
|
||||
loading = true;
|
||||
@state()
|
||||
isUpcoming = false;
|
||||
@state()
|
||||
currentDate: number = Date.now();
|
||||
@state()
|
||||
// The closest milestone shipping date as an ISO string.
|
||||
closestShippingDate: string = '';
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
@ -119,6 +128,60 @@ export class ChromedashFeaturePage extends LitElement {
|
|||
return this.feature && Object.keys(this.feature).length !== 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if this feature is upcoming - scheduled to ship
|
||||
* within two milestones, and find the closest shipping date
|
||||
* for that milestone.*/
|
||||
calcUpcoming(channels, stages: Array<StageDict>) {
|
||||
const latestStableVersion = channels['stable']?.version;
|
||||
if (!latestStableVersion || !stages) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shippingMilestones = new Set<number | undefined>();
|
||||
// Get milestones from all shipping stages, STAGE_TYPES_SHIPPING.
|
||||
for (const stage of stages) {
|
||||
if (STAGE_TYPES_SHIPPING.has(stage.stage_type)) {
|
||||
shippingMilestones.add(stage.desktop_first);
|
||||
shippingMilestones.add(stage.android_first);
|
||||
shippingMilestones.add(stage.ios_first);
|
||||
shippingMilestones.add(stage.webview_first);
|
||||
}
|
||||
}
|
||||
// Check if this feature is shipped within two milestones.
|
||||
let foundMilestone = 0;
|
||||
if (shippingMilestones.has(latestStableVersion + 1)) {
|
||||
foundMilestone = latestStableVersion + 1;
|
||||
this.isUpcoming = true;
|
||||
} else if (shippingMilestones.has(latestStableVersion + 2)) {
|
||||
foundMilestone = latestStableVersion + 2;
|
||||
this.isUpcoming = true;
|
||||
}
|
||||
|
||||
if (this.isUpcoming) {
|
||||
Object.keys(channels).forEach(key => {
|
||||
if (channels[key].version === foundMilestone) {
|
||||
this.closestShippingDate = channels[key].final_beta;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A feature is outdated if it is scheduled to ship in the next 2 milestones,
|
||||
* and its accurate_as_of date is at least 4 weeks ago.*/
|
||||
isFeatureOutdated(): boolean {
|
||||
if (!this.isUpcoming) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const isVerified = isVerifiedWithinGracePeriod(
|
||||
this.feature.accurate_as_of,
|
||||
this.currentDate
|
||||
);
|
||||
return !isVerified;
|
||||
}
|
||||
|
||||
fetchData() {
|
||||
this.loading = true;
|
||||
Promise.all([
|
||||
|
@ -128,6 +191,7 @@ export class ChromedashFeaturePage extends LitElement {
|
|||
window.csClient.getFeatureProcess(this.featureId),
|
||||
window.csClient.getStars(),
|
||||
window.csClient.getFeatureProgress(this.featureId),
|
||||
window.csClient.getChannels(),
|
||||
])
|
||||
.then(
|
||||
([
|
||||
|
@ -137,6 +201,7 @@ export class ChromedashFeaturePage extends LitElement {
|
|||
process,
|
||||
starredFeatures,
|
||||
progress,
|
||||
channels,
|
||||
]) => {
|
||||
this.feature = feature;
|
||||
this.gates = gatesRes.gates;
|
||||
|
@ -149,6 +214,7 @@ export class ChromedashFeaturePage extends LitElement {
|
|||
if (this.feature.name) {
|
||||
document.title = `${this.feature.name} - ${this.appTitle}`;
|
||||
}
|
||||
this.calcUpcoming(channels, feature.stages);
|
||||
this.loading = false;
|
||||
}
|
||||
)
|
||||
|
@ -420,6 +486,50 @@ export class ChromedashFeaturePage extends LitElement {
|
|||
</div>
|
||||
`);
|
||||
}
|
||||
if (this.isFeatureOutdated()) {
|
||||
if (this.userCanEdit()) {
|
||||
warnings.push(html`
|
||||
<div class="warning layout horizontal center">
|
||||
<span class="tooltip" id="outdated-icon" title="Feature outdated ">
|
||||
<iron-icon icon="chromestatus:error" data-tooltip></iron-icon>
|
||||
</span>
|
||||
<span>
|
||||
Your feature hasn't been verified as accurate since${' '}
|
||||
<sl-relative-time
|
||||
date=${this.feature.accurate_as_of}
|
||||
></sl-relative-time
|
||||
>, but it is scheduled to ship${' '}
|
||||
<sl-relative-time
|
||||
date=${this.closestShippingDate}
|
||||
></sl-relative-time
|
||||
>. Please
|
||||
<a href="/guide/verify_accuracy/${this.featureId}"
|
||||
>verify that your feature is accurate</a
|
||||
>.
|
||||
</span>
|
||||
</div>
|
||||
`);
|
||||
} else {
|
||||
warnings.push(html`
|
||||
<div class="warning layout horizontal center">
|
||||
<span class="tooltip" id="outdated-icon" title="Feature outdated ">
|
||||
<iron-icon icon="chromestatus:error" data-tooltip></iron-icon>
|
||||
</span>
|
||||
<span>
|
||||
This feature hasn't been verified as accurate since${' '}
|
||||
<sl-relative-time
|
||||
date=${this.feature.accurate_as_of}
|
||||
></sl-relative-time
|
||||
>, but it is scheduled to ship${' '}
|
||||
<sl-relative-time
|
||||
date=${this.closestShippingDate}
|
||||
></sl-relative-time
|
||||
>.
|
||||
</span>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
}
|
||||
return warnings;
|
||||
}
|
||||
|
||||
|
|
|
@ -39,7 +39,7 @@ describe('chromedash-feature-page', () => {
|
|||
'Spec link': 'fake spec link',
|
||||
'Web developer signals': 'True',
|
||||
});
|
||||
const channelsPromise = Promise.resolve({
|
||||
const channels = {
|
||||
canary_asan: {
|
||||
version: 81,
|
||||
earliest_beta: '2020-02-13T00:00:00',
|
||||
|
@ -59,13 +59,15 @@ describe('chromedash-feature-page', () => {
|
|||
version: 80,
|
||||
earliest_beta: '2020-02-13T00:00:00',
|
||||
mstone: 'fake milestone number',
|
||||
final_beta: '2020-03-13T00:00:00',
|
||||
},
|
||||
stable: {
|
||||
version: 79,
|
||||
earliest_beta: '2020-02-13T00:00:00',
|
||||
mstone: 'fake milestone number',
|
||||
},
|
||||
});
|
||||
};
|
||||
const channelsPromise = Promise.resolve(channels);
|
||||
const validFeaturePromise = Promise.resolve({
|
||||
id: 123456,
|
||||
name: 'feature one',
|
||||
|
@ -103,6 +105,12 @@ describe('chromedash-feature-page', () => {
|
|||
stage_type: 120,
|
||||
intent_stage: 2,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
stage_type: 160,
|
||||
intent_stage: 3,
|
||||
desktop_first: 80,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
|
@ -314,4 +322,101 @@ describe('chromedash-feature-page', () => {
|
|||
// But it does still include webdev views.
|
||||
assert.include(consensusSection.innerHTML, 'fake webdev view text');
|
||||
});
|
||||
|
||||
it('calcUpcoming() tests', async () => {
|
||||
const featureId = 123456;
|
||||
const contextLink = '/features';
|
||||
const feature: any = structuredClone(await validFeaturePromise);
|
||||
const component: ChromedashFeaturePage =
|
||||
await fixture<ChromedashFeaturePage>(
|
||||
html`<chromedash-feature-page
|
||||
.user=${user}
|
||||
.featureId=${featureId}
|
||||
.contextLink=${contextLink}
|
||||
>
|
||||
</chromedash-feature-page>`
|
||||
);
|
||||
assert.exists(component);
|
||||
|
||||
component.calcUpcoming({}, feature.stages);
|
||||
assert.isFalse(component.isUpcoming);
|
||||
assert.equal(component.closestShippingDate, '');
|
||||
|
||||
component.calcUpcoming(channels, []);
|
||||
assert.isFalse(component.isUpcoming);
|
||||
assert.equal(component.closestShippingDate, '');
|
||||
|
||||
// No shipping milestones.
|
||||
let stages: any = structuredClone(feature.stages);
|
||||
stages[2].stage_type = 130;
|
||||
component.calcUpcoming(channels, stages);
|
||||
assert.isFalse(component.isUpcoming);
|
||||
assert.equal(component.closestShippingDate, '');
|
||||
|
||||
// No upcoming shipping milestones.
|
||||
stages = structuredClone(feature.stages);
|
||||
stages[2].desktop_first = 20;
|
||||
component.calcUpcoming(channels, stages);
|
||||
assert.isFalse(component.isUpcoming);
|
||||
assert.equal(component.closestShippingDate, '');
|
||||
|
||||
component.calcUpcoming(channels, feature.stages);
|
||||
assert.isTrue(component.isUpcoming);
|
||||
assert.equal(component.closestShippingDate, '2020-03-13T00:00:00');
|
||||
});
|
||||
|
||||
it('isFeatureOutdated() tests', async () => {
|
||||
const featureId = 123456;
|
||||
const contextLink = '/features';
|
||||
const feature: any = structuredClone(await validFeaturePromise);
|
||||
feature.accurate_as_of = '2024-08-28 21:51:34.22386';
|
||||
window.csClient.getFeature
|
||||
.withArgs(featureId)
|
||||
.returns(Promise.resolve(feature));
|
||||
const component: ChromedashFeaturePage =
|
||||
await fixture<ChromedashFeaturePage>(
|
||||
html`<chromedash-feature-page
|
||||
.user=${user}
|
||||
.featureId=${featureId}
|
||||
.contextLink=${contextLink}
|
||||
>
|
||||
</chromedash-feature-page>`
|
||||
);
|
||||
component.currentDate = new Date('2024-10-23').getTime();
|
||||
assert.exists(component);
|
||||
|
||||
component.calcUpcoming(channels, feature.stages);
|
||||
assert.isTrue(component.isUpcoming);
|
||||
assert.equal(component.closestShippingDate, '2020-03-13T00:00:00');
|
||||
assert.isTrue(component.isFeatureOutdated());
|
||||
|
||||
// accurate_as_of is not outdated and within the 4-week grace period.
|
||||
component.currentDate = new Date('2024-09-18').getTime();
|
||||
assert.isFalse(component.isFeatureOutdated());
|
||||
});
|
||||
|
||||
it('render the oudated warning when outdated', async () => {
|
||||
const featureId = 123456;
|
||||
const contextLink = '/features';
|
||||
const feature: any = structuredClone(await validFeaturePromise);
|
||||
feature.accurate_as_of = '2024-08-28 21:51:34.22386';
|
||||
window.csClient.getFeature
|
||||
.withArgs(featureId)
|
||||
.returns(Promise.resolve(feature));
|
||||
const component: ChromedashFeaturePage =
|
||||
await fixture<ChromedashFeaturePage>(
|
||||
html`<chromedash-feature-page
|
||||
.user=${user}
|
||||
.featureId=${featureId}
|
||||
.contextLink=${contextLink}
|
||||
>
|
||||
</chromedash-feature-page>`
|
||||
);
|
||||
component.currentDate = new Date('2024-10-23').getTime();
|
||||
assert.exists(component);
|
||||
|
||||
component.calcUpcoming(channels, feature.stages);
|
||||
const oudated = component.shadowRoot!.querySelector('#outdated-icon');
|
||||
assert.exists(oudated);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -21,6 +21,10 @@ let toastEl;
|
|||
// We assume that a small enough window width implies a mobile device.
|
||||
const NARROW_WINDOW_MAX_WIDTH = 700;
|
||||
|
||||
// Represent a 4-week period in milliseconds. This grace period needs
|
||||
// to be consistent with ACCURACY_GRACE_PERIOD in internals/reminders.py.
|
||||
const ACCURACY_GRACE_PERIOD = 4 * 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
export const IS_MOBILE = (() => {
|
||||
const width =
|
||||
window.innerWidth ||
|
||||
|
@ -652,3 +656,26 @@ export function extensionMilestoneIsValid(value, currentMilestone) {
|
|||
// End milestone should not be in the past.
|
||||
return parseInt(currentMilestone) <= intValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if feature.accurate_as_of is verified, within the four-week
|
||||
* grace period to currentDate.
|
||||
*
|
||||
* @param accurateAsOf The accurate_as_of date as an ISO string.
|
||||
* @param currentDate The current date in milliseconds.
|
||||
*/
|
||||
export function isVerifiedWithinGracePeriod(
|
||||
accurateAsOf: string | undefined,
|
||||
currentDate: number
|
||||
) {
|
||||
if (!accurateAsOf) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const accurateDate = Date.parse(accurateAsOf);
|
||||
if (accurateDate + ACCURACY_GRACE_PERIOD < currentDate) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
|
|
@ -220,6 +220,8 @@ class AbstractReminderHandler(basehandlers.FlaskHandler):
|
|||
class FeatureAccuracyHandler(AbstractReminderHandler):
|
||||
"""Periodically remind owners to verify the accuracy of their entries."""
|
||||
|
||||
# This grace period needs to be consistent with
|
||||
# ACCURACY_GRACE_PERIOD in client-src/elements/utils.ts.
|
||||
ACCURACY_GRACE_PERIOD = timedelta(weeks=4)
|
||||
SUBJECT_FORMAT = '[Action requested] Update %s'
|
||||
EMAIL_TEMPLATE_PATH = 'accuracy_notice_email.html'
|
||||
|
|
Загрузка…
Ссылка в новой задаче