chromium-dashboard/client-src/elements/utils.ts

685 строки
21 KiB
TypeScript

// This file contains helper functions for our elements.
import {html, nothing} from 'lit';
import {Feature, FeatureLink, StageDict} from '../js-src/cs-client.js';
import {markupAutolinks} from './autolink.js';
import {FORMS_BY_STAGE_TYPE, FormattedFeature} from './form-definition.js';
import {
ENTERPRISE_FEATURE_CATEGORIES_DISPLAYNAME,
ENTERPRISE_IMPACT_DISPLAYNAME,
OT_MILESTONE_END_FIELDS,
OT_SETUP_STATUS_OPTIONS,
PLATFORMS_DISPLAYNAME,
ROLLOUT_IMPACT_DISPLAYNAME,
STAGE_FIELD_NAME_MAPPING,
STAGE_SPECIFIC_FIELDS,
} from './form-field-enums';
let toastEl;
// Determine if the browser looks like the user is on a mobile device.
// 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 ||
document.documentElement.clientWidth ||
document.body.clientWidth;
return width <= NARROW_WINDOW_MAX_WIDTH;
})();
/* Convert user-entered text into safe HTML with clickable links
* where appropriate. Returns an array with text and anchor tags.
*/
export function autolink(s, featureLinks: FeatureLink[] = []) {
const withLinks = markupAutolinks(s, featureLinks);
return withLinks;
}
export function showToastMessage(msg) {
if (!toastEl) toastEl = document.querySelector('chromedash-toast');
if (toastEl?.showMessage) {
toastEl.showMessage(msg);
}
}
/**
* Returns the rendered elements of the named slot of component.
* @param {Element} component
* @param {string} slotName
* @return {Element}
*/
export function slotAssignedElements(component, slotName) {
const slotSelector = slotName ? `slot[name=${slotName}]` : 'slot';
return component.shadowRoot
.querySelector(slotSelector)
.assignedElements({flatten: true});
}
/* Return val, or one of the bounds if val is out of the bounds. */
export function clamp(val, lowerBound, upperBound) {
return Math.max(lowerBound, Math.min(upperBound, val));
}
/* Given a feature entry stage entity, look up the related process stage. */
export function findProcessStage(feStage, process) {
for (const processStage of process.stages) {
if (feStage.stage_type == processStage.stage_type) {
return processStage;
}
}
return null;
}
/* Determine if the display name field should be displayed for a stage. */
export function shouldShowDisplayNameField(feStages, stageType) {
// The display name field is only available to a feature's stages
// that have more than 1 of the same stage type associated.
// It is used to differentiate those stages.
let matchingStageCount = 0;
for (let i = 0; i < feStages.length; i++) {
if (feStages[i].stage_type === stageType) {
matchingStageCount++;
// If we find two of the same stage type, then display the display name field.
if (matchingStageCount > 1) {
return true;
}
}
}
return false;
}
/* Given a process stage, find the first feature entry stage of the same type. */
export function findFirstFeatureStage(intentStage, currentStage, fe) {
if (intentStage == currentStage.intent_stage) {
return currentStage;
}
for (const feStage of fe.stages) {
if (intentStage == feStage.intent_stage) {
return feStage;
}
}
return null;
}
/**
* Returns `stage`'s name, using either its `display_name` or a counter to disambiguate from other
* stages of the same type within `feature`.
*/
export function unambiguousStageName(
stage: StageDict,
feature: Feature | FormattedFeature
): string | undefined {
const processStageName = FORMS_BY_STAGE_TYPE[stage.stage_type]?.name;
if (!processStageName) {
console.error(
`Unexpected stage type ${stage.stage_type} in stage ${stage.id}.`
);
return undefined;
}
if (stage.display_name) {
return `${processStageName}: ${stage.display_name}`;
}
// Count the stages of the same type that appear before this one. This is O(n^2) when it's used to
// number every stage, but the total number of stages is generally <20.
const index = feature.stages
.filter(s => s.stage_type === stage.stage_type)
.findIndex(s => s.id === stage.id);
if (index > 0) {
return `${processStageName} ${index + 1}`;
}
// Ignore if the stage wasn't found.
return processStageName;
}
/* Get the value of a stage field using a form-specific name */
export function getStageValue(stage, fieldName) {
if (!stage) return undefined;
if (fieldName in STAGE_FIELD_NAME_MAPPING) {
return stage[STAGE_FIELD_NAME_MAPPING[fieldName]];
}
return stage[fieldName];
}
// Look at all extension milestones and calculate the highest milestone that an origin trial
// is available. This is used to display the highest milestone available, but to preserve the
// milestone that the trial was originally available for without extensions.
function calcMaxMilestone(feStage, fieldName) {
// If the max milestone has already been calculated, or no trial extensions exist, do nothing.
if (!feStage) return;
if (feStage[`max_${fieldName}`] || !feStage.extensions) {
return;
}
let maxMilestone = getStageValue(feStage, fieldName) || 0;
for (const extension of feStage.extensions) {
const extensionValue = getStageValue(extension, fieldName);
if (extensionValue) {
maxMilestone = Math.max(maxMilestone, extensionValue);
}
}
// Save the findings with the "max_" prefix as a prop of the stage for reference.
feStage[`max_${fieldName}`] = maxMilestone;
}
// Get the milestone value that is displayed to the user regarding the origin trial end date.
function getMilestoneExtensionValue(feStage, fieldName) {
if (!feStage) return undefined;
const milestoneValue = getStageValue(feStage, fieldName);
calcMaxMilestone(feStage, fieldName);
const maxMilestoneFieldName = `max_${fieldName}`;
// Display only extension milestone if the original milestone has not been added.
if (feStage[maxMilestoneFieldName] && !milestoneValue) {
return `Extended to ${feStage[maxMilestoneFieldName]}`;
}
// If the trial has been extended past the original milestone, display the extension
// milestone with additional text reminding of the original milestone end date.
if (
feStage[maxMilestoneFieldName] &&
feStage[maxMilestoneFieldName] > milestoneValue
) {
return `${feStage[maxMilestoneFieldName]} (extended from ${milestoneValue})`;
}
return milestoneValue;
}
/**
* Check if a value is defined and not empty.
*
* @param {any} value - The value to be checked.
* @return {boolean} Returns true if the value is defined and not empty, otherwise false.
*/
export function isDefinedValue(value) {
return !(value === undefined || value === null || value.length == 0);
}
export function hasFieldValue(fieldName, feStage, feature) {
const value = getFieldValueFromFeature(fieldName, feStage, feature);
return isDefinedValue(value);
}
/**
* Retrieves the value of a specific field for a given feature.
* Note: This is independent of any value that might be in a corresponding
* form field.
*
* @param fieldName - The name of the field to retrieve.
* @param feStage - The stage of the feature.
* @param feature - The feature object to retrieve the field value from.
* @return The value of the specified field for the given feature.
*/
export function getFieldValueFromFeature(
fieldName: string,
feStage: StageDict,
feature: Feature
) {
if (STAGE_SPECIFIC_FIELDS.has(fieldName)) {
const value = getStageValue(feStage, fieldName);
if (fieldName === 'rollout_impact' && value) {
return ROLLOUT_IMPACT_DISPLAYNAME[value];
}
if (fieldName === 'rollout_platforms' && value) {
return value.map(platformId => PLATFORMS_DISPLAYNAME[platformId]);
} else if (fieldName in OT_MILESTONE_END_FIELDS) {
// If an origin trial end date is being displayed, handle extension milestones as well.
return getMilestoneExtensionValue(feStage, fieldName);
}
return value;
}
if (!feature) {
return null;
}
const fieldNameMapping = {
owner: 'browsers.chrome.owners',
editors: 'editors',
search_tags: 'tags',
spec_link: 'standards.spec',
standard_maturity: 'standards.maturity.text',
sample_links: 'resources.samples',
docs_links: 'resources.docs',
bug_url: 'browsers.chrome.bug',
blink_components: 'browsers.chrome.blink_components',
devrel: 'browsers.chrome.devrel',
prefixed: 'browsers.chrome.prefixed',
impl_status_chrome: 'browsers.chrome.status.text',
shipped_milestone: 'browsers.chrome.desktop',
shipped_android_milestone: 'browsers.chrome.android',
shipped_webview_milestone: 'browsers.chrome.webview',
shipped_ios_milestone: 'browsers.chrome.ios',
ff_views: 'browsers.ff.view.text',
ff_views_link: 'browsers.ff.view.url',
ff_views_notes: 'browsers.ff.view.notes',
safari_views: 'browsers.safari.view.text',
safari_views_link: 'browsers.safari.view.url',
safari_views_notes: 'browsers.safari.view.notes',
web_dev_views: 'browsers.webdev.view.text',
web_dev_views_link: 'browsers.webdev.view.url',
web_dev_views_notes: 'browsers.webdev.view.notes',
other_views_notes: 'browsers.other.view.notes',
};
let value;
if (fieldNameMapping[fieldName]) {
let propertyValue = feature;
for (const step of fieldNameMapping[fieldName].split('.')) {
if (propertyValue) {
propertyValue = propertyValue[step];
}
}
value = propertyValue;
} else {
value = feature[fieldName];
}
if (fieldName === 'enterprise_feature_categories' && value) {
return value.map(
categoryId => ENTERPRISE_FEATURE_CATEGORIES_DISPLAYNAME[categoryId]
);
}
if (fieldName === 'enterprise_impact' && value) {
return ENTERPRISE_IMPACT_DISPLAYNAME[value];
}
if (fieldName === 'active_stage_id' && value) {
for (const stage of feature.stages) {
if (stage.id === value) {
return unambiguousStageName(stage, feature);
}
}
return undefined;
}
return value;
}
/* Given a stage form definition, return a flat array of the fields associated with the stage. */
export function flattenSections(stage) {
return stage.sections.reduce(
(combined, section) => [...combined, ...section.fields],
[]
);
}
/* Set up scrolling to a hash url (e.g. #id_explainer_links). */
export function setupScrollToHash(pageElement) {
// Scroll to the element identified by the hash parameter, which must include
// the '#' prefix. E.g. for a form field: '#id_<form-field-name>'.
// Note that this function is bound to the pageElement for a page.
const scrollToElement = hash => {
if (hash) {
const el = pageElement.shadowRoot.querySelector(hash);
if (el) {
// Focus on the element, if possible.
// Note: focus() must be called before scrollToView().
if (el.input) {
// Note: shoelace element.focus() calls el.input.focus();
el.focus();
} else {
// No el.input (yet), so try after delay. TODO: Avoid the timeout.
setTimeout(() => {
el.focus();
}, 100);
}
// Find the form field container element, if any.
const fieldRowSelector = `chromedash-form-field[name="${el.name}"] tr + tr`;
const fieldRow = pageElement.shadowRoot.querySelector(fieldRowSelector);
if (fieldRow) {
fieldRow.scrollIntoView({
block: 'center',
behavior: 'smooth',
});
} else {
el.scrollIntoView({
behavior: 'smooth',
});
}
}
}
};
// Add global function to jump to an element within the pageElement.
window.scrollToElement = hash => {
scrollToElement(hash);
};
// Check now as well, which is used when first rendering a page.
if (location.hash) {
const hash = decodeURIComponent(location.hash);
scrollToElement(hash);
}
}
/* Returns a html template if the condition is true, otherwise returns an empty html */
export function renderHTMLIf(condition, originalHTML) {
return condition ? originalHTML : nothing;
}
function _parseDateStr(dateStr) {
// Format date to "YYYY-MM-DDTHH:mm:ss.sssZ" to represent UTC.
dateStr = dateStr || '';
dateStr = dateStr.replace(' ', 'T');
const dateObj = new Date(`${dateStr}Z`);
if (isNaN(Number(dateObj))) {
return null;
}
return dateObj;
}
export function renderAbsoluteDate(dateStr, includeTime = false) {
if (!dateStr) {
return '';
}
if (includeTime) {
return dateStr.split('.')[0]; // Ignore microseconds.
} else {
return dateStr.split(' ')[0]; // Ignore time.
}
}
export function renderRelativeDate(dateStr) {
const dateObj = _parseDateStr(dateStr);
if (!dateObj) return nothing;
return html` <span class="relative_date">
(<sl-relative-time date="${dateObj.toISOString()}"> </sl-relative-time>)
</span>`;
}
/** Returns the non-time part of date in the YYYY-MM-DD format.
*
* @param {Date} date
* @return {string}
*/
export function isoDateString(date) {
return date.toISOString().slice(0, 10);
}
export interface RawQuery {
q?: string;
columns?: string;
showEnterprise?: string;
sort?: string;
start?: string;
after?: string;
num?: string;
[key: string]: string | undefined;
}
/**
* Parses URL query strings into a dict.
* @param {string} rawQuery a raw URL query string, e.g. q=abc&num=1;
* @return {Record<string, string>} A key-value pair dictionary for the query string.
*/
export function parseRawQuery(rawQuery): Record<string, string> {
const params = new URLSearchParams(rawQuery);
const result = {};
for (const param of params.keys()) {
const values = params.getAll(param);
if (!values.length) {
continue;
}
// Assume there is only one value.
result[param] = values[0];
}
return result;
}
/**
* Create a new URL using params and a location.
* @param {string} params is the new param object.
* @param {Object} location is an URL location.
* @return {Object} the new URL.
*/
export function getNewLocation(params, location) {
const url = new URL(location);
url.search = '';
if (params) {
for (const [k, v] of Object.entries(params)) {
// Skip if the value is empty.
if (!v) {
continue;
}
url.searchParams.append(k, v.toString());
}
}
return url;
}
// Get any help text for a specific field based on the condition of if it should be disabled.
export function getDisabledHelpText(field, feStage?) {
// OT milestone fields should not be editable when the automated
// OT creation process is in progress or in a failed state.
if (
field === 'ot_milestone_desktop_start' ||
field === 'ot_milestone_desktop_end'
) {
if (
feStage?.ot_setup_status ===
OT_SETUP_STATUS_OPTIONS.OT_READY_FOR_CREATION ||
feStage?.ot_setup_status === OT_SETUP_STATUS_OPTIONS.OT_CREATION_FAILED ||
feStage?.ot_setup_status === OT_SETUP_STATUS_OPTIONS.OT_ACTIVATION_FAILED
) {
return 'Origin trial milestone cannot be edited while a creation request is in progress';
}
}
return '';
}
/**
* Update window.location with new query params.
* @param {string} key is the key of the query param.
* @param {string} val is the unencoded value of the query param.
*/
export function updateURLParams(key, val) {
const newURL = formatURLParams(key, val);
if (newURL.toString() === window.location.toString()) {
return;
}
// Update URL without refreshing the page. {path:} is needed for
// an issue in page.js:
// https://github.com/visionmedia/page.js/issues/293#issuecomment-456906679
window.history.pushState({path: newURL.toString()}, '', newURL);
}
/**
* Format the existing URL with new query params.
* @param {string} key is the key of the query param.
* @param {string} val is the unencoded value of the query param.
*/
export function formatURLParams(key, val) {
// Update the query param object.
const rawQuery = parseRawQuery(window.location.search);
rawQuery[key] = encodeURIComponent(val);
// Assemble the new URL.
const newURL = getNewLocation(rawQuery, window.location);
newURL.hash = '';
return newURL;
}
export function formatUrlForRelativeOffset(
start: number,
delta: number,
pageSize: number,
totalCount: number
): string | undefined {
const offset = start + delta;
if (totalCount === undefined || offset <= -pageSize || offset >= totalCount) {
return undefined;
}
return formatUrlForOffset(Math.max(0, offset));
}
export function formatUrlForOffset(offset: number): string {
return formatURLParams('start', offset).toString();
}
/**
* Update window.location with new query params.
* @param {string} key is the key of the query param to delete.
*/
export function clearURLParams(key) {
// Update the query param object.
const rawQuery = parseRawQuery(window.location.search);
delete rawQuery[key];
// Assemble the new URL.
const newURL = getNewLocation(rawQuery, window.location);
newURL.hash = '';
if (newURL.toString() === window.location.toString()) {
return;
}
// Update URL without refreshing the page. {path:} is needed for
// an issue in page.js:
// https://github.com/visionmedia/page.js/issues/293#issuecomment-456906679
window.history.pushState({path: newURL.toString()}, '', newURL);
}
export interface FieldInfo {
/** The name of the field. */
name: string | keyof FormattedFeature;
/** Whether the field was mutated by the user. */
touched: boolean;
/**
* The stage that the field is associated with.
* This field is undefined if the change is a feature change.
*/
stageId?: number | null;
/** The value written in the form field. */
value: any;
/**
* Value that should be changed for some checkbox fields.
* e.g. "set_stage" is a checkbox, but should change the field to a stage ID if true.
*/
implicitValue?: any;
alwaysHidden?: boolean;
isApprovalsField?: boolean;
checkMessage?: string;
}
interface UpdateSubmitBody {
feature_changes: FeatureUpdateInfo;
stages: StageUpdateInfo[];
has_changes: boolean;
}
interface StageUpdateInfo {
[stageField: string]: any;
}
interface FeatureUpdateInfo {
[featureField: string]: any;
}
// Prepare feature/stage changes to be submitted.
export function formatFeatureChanges(fieldValues, featureId): UpdateSubmitBody {
let hasChanges = false;
const featureChanges = {id: featureId};
// Multiple stages can be mutated, so this object is a stage of stages.
const stages = {};
for (const {name, touched, value, stageId, implicitValue} of fieldValues) {
// Only submit changes for touched fields or accuracy verification updates.
if (!touched) {
continue;
}
// Arrays should be submitted as comma-separated strings.
let formattedValue = value;
if (Array.isArray(formattedValue)) {
formattedValue = formattedValue.join(',');
}
// If an explicit value is present, the field value should be truthy.
// Otherwise, we ignore the change.
// For example, if this is a checkbox to set the active stage, it would need
// to be set to true (value), then the active stage would be set to a stage ID (implicitValue).
if (implicitValue !== undefined) {
// Falsey value with an implicit value should be ignored (like an unchecked checkbox).
if (!formattedValue) {
continue;
}
// fields with implicit values are always changes to feature entities.
featureChanges[name] = implicitValue;
} else if (!stageId) {
// If the field doesn't specify a stage ID, that means this change is for a feature field.
featureChanges[name] = formattedValue;
} else {
if (!(stageId in stages)) {
stages[stageId] = {id: stageId};
}
stages[stageId][STAGE_FIELD_NAME_MAPPING[name] || name] = {
form_field_name: name,
value: formattedValue,
};
}
// If we see a touched field, it means there are changes in the submission.
hasChanges = true;
}
return {
feature_changes: featureChanges,
stages: Object.values(stages),
has_changes: hasChanges,
};
}
/**
* Manage response to change submission.
* Required to manage beforeUnload handler.
* @param {string} response The error message to display,
* or empty string if save was successful.
*/
export function handleSaveChangesResponse(response) {
const app = document.querySelector('chromedash-app');
(app as any).setUnsavedChanges(response !== '');
}
export function extensionMilestoneIsValid(value, currentMilestone) {
if (isNaN(value)) {
return false;
}
// Milestone should only have digits.
for (let i = 0; i < value.length; i++) {
if (value[i] < '0' || value[i] > '9') {
return false;
}
}
const intValue = parseInt(value);
if (intValue >= 1000) {
return false;
}
// 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.
* @param gracePeriod The grace period in milliseconds. Defaults
* to ACCURACY_GRACE_PERIOD.
*/
export function isVerifiedWithinGracePeriod(
accurateAsOf: string | undefined,
currentDate: number,
gracePeriod: number = ACCURACY_GRACE_PERIOD
) {
if (!accurateAsOf) {
return false;
}
const accurateDate = Date.parse(accurateAsOf);
if (accurateDate + gracePeriod < currentDate) {
return false;
}
return true;
}