Skip to content
1 change: 1 addition & 0 deletions client-src/elements/chromedash-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,7 @@ export class ChromedashApp extends LitElement {
page('/guide/editall/:featureId(\\d+)', ctx => {
if (!this.setupNewPage(ctx, 'chromedash-guide-editall-page')) return;
this.pageComponent.featureId = parseInt(ctx.params.featureId);
this.pageComponent.user = this.user;
this.pageComponent.appTitle = this.appTitle;
});
page('/guide/verify_accuracy/:featureId(\\d+)', ctx => {
Expand Down
29 changes: 9 additions & 20 deletions client-src/elements/chromedash-feature-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import {
FeatureLink,
FeatureNotFoundError,
User,
StageDict,
} from '../js-src/cs-client.js';
import './chromedash-feature-detail';
import {DETAILS_STYLES} from './chromedash-feature-detail';
Expand All @@ -19,12 +18,9 @@ import {
getFeatureOutdatedBanner,
findClosestShippingDate,
closestShippingDateInfo,
userCanEdit,
} from './utils.js';
import {
STAGE_TYPES_SHIPPING,
STAGE_TYPES_ORIGIN_TRIAL,
IMPLEMENTATION_STATUS,
} from './form-field-enums';
import {IMPLEMENTATION_STATUS} from './form-field-enums';

const INACTIVE_STATES = ['No longer pursuing', 'Deprecated', 'Removed'];
declare var ga: Function;
Expand Down Expand Up @@ -269,7 +265,7 @@ export class ChromedashFeaturePage extends LitElement {
}

canDeleteFeature() {
return this.user?.is_admin || this.userCanEdit();
return this.user?.is_admin || userCanEdit(this.user, this.featureId);
}

handleArchiveFeature() {
Expand Down Expand Up @@ -358,14 +354,6 @@ export class ChromedashFeaturePage extends LitElement {
return INACTIVE_STATES.includes(status);
}

userCanEdit() {
return (
this.user &&
(this.user.can_edit_all ||
this.user.editable_features.includes(this.featureId))
);
}

pairedUserCanEdit() {
return (
this.paired_user?.can_edit_all ||
Expand Down Expand Up @@ -469,7 +457,8 @@ export class ChromedashFeaturePage extends LitElement {
</div>
`);
}
if (!this.userCanEdit() && this.pairedUserCanEdit()) {
const canEdit = userCanEdit(this.user, this.featureId);
if (!canEdit && this.pairedUserCanEdit()) {
warnings.push(html`
<div id="switch_to_edit" class="warning">
User ${this.user.email} cannot edit this feature or request reviews.
Expand All @@ -492,12 +481,12 @@ export class ChromedashFeaturePage extends LitElement {
</div>
`);
}
const userCanEdit = this.userCanEdit();

const featureOutdatedBanner = getFeatureOutdatedBanner(
this.feature,
this.shippingInfo,
this.currentDate,
userCanEdit
canEdit
);
if (featureOutdatedBanner) {
warnings.push(featureOutdatedBanner);
Expand All @@ -511,7 +500,7 @@ export class ChromedashFeaturePage extends LitElement {
<chromedash-feature-detail
appTitle=${this.appTitle}
.user=${this.user}
?canEdit=${this.userCanEdit()}
?canEdit=${userCanEdit(this.user, this.featureId)}
.feature=${this.feature}
.gates=${this.gates}
.comments=${this.comments}
Expand Down Expand Up @@ -540,7 +529,7 @@ export class ChromedashFeaturePage extends LitElement {
.feature=${this.feature}
.featureLinks=${this.featureLinks}
?canDeleteFeature=${this.canDeleteFeature()}
?canEditFeature=${this.userCanEdit()}
?canEditFeature=${userCanEdit(this.user, this.featureId)}
@archive=${this.handleArchiveFeature}
@suspend=${this.handleSuspend}
@resume=${this.handleResume}
Expand Down
35 changes: 31 additions & 4 deletions client-src/elements/chromedash-guide-editall-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ import {
shouldShowDisplayNameField,
renderHTMLIf,
FieldInfo,
closestShippingDateInfo,
findClosestShippingDate,
getFeatureOutdatedBanner,
userCanEdit,
} from './utils.js';
import './chromedash-form-table';
import './chromedash-form-field';
Expand All @@ -31,8 +35,7 @@ import {
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';
import {Feature, User} from '../js-src/cs-client.js';

interface FormToRender {
id: number;
Expand All @@ -56,6 +59,8 @@ export class ChromedashGuideEditallPage extends LitElement {
}
@property({attribute: false})
featureId = 0;
@property({attribute: false})
user!: User;
@property({type: String})
appTitle = '';
@property({type: Number})
Expand All @@ -70,6 +75,13 @@ export class ChromedashGuideEditallPage extends LitElement {
sameTypeRendered = 0;
@state()
fieldValues: FieldInfo[] & {feature?: Feature} = [];
@state()
shippingInfo: closestShippingDateInfo = {
// The closest milestone shipping date as an ISO string.
closestShippingDate: '',
hasShipped: false,
isUpcoming: false,
};

connectedCallback() {
super.connectedCallback();
Expand All @@ -78,12 +90,19 @@ export class ChromedashGuideEditallPage extends LitElement {

fetchData() {
this.loading = true;
Promise.all([window.csClient.getFeature(this.featureId)])
.then(([feature]) => {
Promise.all([
window.csClient.getFeature(this.featureId),
window.csClient.getChannels(),
])
.then(async ([feature, channels]) => {
this.feature = feature;
if (this.feature.name) {
document.title = `${this.feature.name} - ${this.appTitle}`;
}
this.shippingInfo = await findClosestShippingDate(
channels,
feature.stages
);
this.loading = false;
})
.catch(() => {
Expand Down Expand Up @@ -501,6 +520,14 @@ export class ChromedashGuideEditallPage extends LitElement {
render() {
return html`
${this.renderSubheader()}
${this.loading
? nothing
: getFeatureOutdatedBanner(
this.feature,
this.shippingInfo,
Date.now(),
userCanEdit(this.user, this.feature.id)
)}
${this.loading ? this.renderSkeletons() : this.renderForm()}
`;
}
Expand Down
78 changes: 72 additions & 6 deletions client-src/elements/chromedash-guide-editall-page_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,20 +72,82 @@ describe('chromedash-guide-editall-page', () => {
tags: ['tag_one'],
});

const validChannelsPromise = Promise.resolve({
stable: {
branch_point: '2025-09-01T00:00:00',
earliest_beta: '2025-09-03T00:00:00',
earliest_beta_chromeos: '2025-09-16T00:00:00',
earliest_beta_ios: '2025-09-03T00:00:00',
early_stable: '2025-09-24T00:00:00',
early_stable_ios: '2025-09-17T00:00:00',
final_beta: '2025-09-24T00:00:00',
final_beta_cut: '2025-09-23T00:00:00',
late_stable_date: '2025-10-14T00:00:00',
latest_beta: '2025-09-18T00:00:00',
mstone: 141,
next_late_stable_refresh: '2025-10-28T00:00:00',
next_stable_refresh: '2025-10-14T00:00:00',
stable_cut: '2025-09-23T00:00:00',
stable_cut_ios: '2025-09-16T00:00:00',
stable_date: '2025-09-30T00:00:00',
stable_refresh_first: '2025-10-14T00:00:00',
version: 141,
},
beta: {
branch_point: '2025-09-29T00:00:00',
earliest_beta: '2025-10-01T00:00:00',
earliest_beta_chromeos: '2025-10-14T00:00:00',
earliest_beta_ios: '2025-10-01T00:00:00',
early_stable: '2025-10-22T00:00:00',
early_stable_ios: '2025-10-22T00:00:00',
final_beta: '2025-10-22T00:00:00',
final_beta_cut: '2025-10-21T00:00:00',
late_stable_date: '2025-11-11T00:00:00',
latest_beta: '2025-10-16T00:00:00',
mstone: 142,
stable_cut: '2025-10-21T00:00:00',
stable_cut_ios: '2025-10-21T00:00:00',
stable_date: '2025-10-28T00:00:00',
stable_refresh_first: '2025-11-11T00:00:00',
stable_refresh_second: '2025-12-02T00:00:00',
stable_refresh_third: '2025-12-16T00:00:00',
version: 142,
},
dev: {
branch_point: '2025-10-27T00:00:00',
earliest_beta: '2025-10-29T00:00:00',
earliest_beta_chromeos: '2025-11-11T00:00:00',
earliest_beta_ios: '2025-10-29T00:00:00',
early_stable: '2025-11-19T00:00:00',
early_stable_ios: '2025-11-19T00:00:00',
final_beta: '2025-11-19T00:00:00',
final_beta_cut: '2025-11-18T00:00:00',
late_stable_date: '2025-12-16T00:00:00',
latest_beta: '2025-11-13T00:00:00',
mstone: 143,
stable_cut: '2025-11-18T00:00:00',
stable_cut_ios: '2025-11-18T00:00:00',
stable_date: '2025-12-02T00:00:00',
version: 143,
},
});

/* window.csClient and <chromedash-toast> are initialized at spa.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, 'getChannels');
sinon.stub(window.csClient, 'getBlinkComponents');
window.csClient.getBlinkComponents.returns(Promise.resolve({}));
});

afterEach(() => {
window.csClient.getFeature.restore();
window.csClient.getBlinkComponents.restore();
window.csClient.getChannels.restore();
});

it('renders with no data', async () => {
Expand All @@ -112,16 +174,20 @@ describe('chromedash-guide-editall-page', () => {
it('renders with fake data', async () => {
const featureId = 123456;
window.csClient.getFeature.withArgs(featureId).returns(validFeaturePromise);
window.csClient.getChannels.returns(validChannelsPromise);

const component = await fixture(
const component = await fixture<ChromedashGuideEditallPage>(
html`<chromedash-guide-editall-page .featureId=${featureId}>
</chromedash-guide-editall-page>`
);
assert.exists(component);

await component.updateComplete;

assert.exists(component, 'component exists.');
assert.instanceOf(component, ChromedashGuideEditallPage);

const subheaderDiv = component.renderRoot.querySelector('div#subheader');
assert.exists(subheaderDiv);
assert.exists(subheaderDiv, 'Subheader exists.');
// subheader title is correct and clickable
assert.include(subheaderDiv.innerHTML, 'href="/feature/123456"');
assert.include(subheaderDiv.innerHTML, 'Edit feature:');
Expand All @@ -130,14 +196,14 @@ describe('chromedash-guide-editall-page', () => {
const featureForm = component.renderRoot.querySelector(
'form[name="feature_form"]'
);
assert.exists(featureForm);
assert.exists(featureForm, 'Feature form exists.');
assert.include(featureForm.innerHTML, '<input type="hidden" name="token">');
assert.include(featureForm.innerHTML, '<section class="final_buttons">');

const formTable = component.renderRoot.querySelector(
'chromedash-form-table'
);
assert.exists(formTable);
assert.exists(formTable, 'Form table exists.');

// delete button shown on rollout steps only
const deleteButtons = formTable.querySelectorAll('sl-button[stage="1061"]');
Expand All @@ -148,13 +214,13 @@ describe('chromedash-guide-editall-page', () => {
const featureId = 123456;
// The mock feature has two stages of type 160 (ids 2 and 3).
window.csClient.getFeature.withArgs(featureId).returns(validFeaturePromise);
window.csClient.getChannels.returns(validChannelsPromise);

const component = await fixture<ChromedashGuideEditallPage>(
html`<chromedash-guide-editall-page .featureId=${featureId}>
</chromedash-guide-editall-page>`
);

// Wait for component to render and update.
await component.updateComplete;

// Find all the sections for the duplicated stage type.
Expand Down
8 changes: 8 additions & 0 deletions client-src/elements/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
Feature,
FeatureLink,
StageDict,
User,
} from '../js-src/cs-client.js';
import {markupAutolinks} from './autolink.js';
import {FORMS_BY_STAGE_TYPE, FormattedFeature} from './form-definition.js';
Expand Down Expand Up @@ -720,6 +721,13 @@ export function extensionMilestoneIsValid(value, currentMilestone) {
return parseInt(currentMilestone) <= intValue;
}

// Check if a specific user has edit permission for a specific feature.
export function userCanEdit(user: User | undefined, featureId): boolean {
return Boolean(
user && (user.can_edit_all || user.editable_features.includes(featureId))
);
}

/**
* Check if feature.accurate_as_of is verified, within the four-week
* grace period to currentDate.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,13 @@ async function gotoEditAllPage(page) {
const editButton = page.locator('a[href^="/guide/editall/"]');
await delay(500);
await editButton.click();
await delay(500);
await delay(1500);
}


test.beforeEach(async ({ page }, testInfo) => {
captureConsoleMessages(page);
testInfo.setTimeout(90000);
testInfo.setTimeout(30000);

// Login before running each test.
await login(page);
Expand Down