Skip to content
21 changes: 21 additions & 0 deletions client-src/css/forms-css.js
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,27 @@ export const FORM_STYLES = [
box-shadow: 0 0 0 3px #0ea5e966;
}

chromedash-form-field .form-field-header {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
chromedash-form-field .usage-tag {
padding: 2px 6px;
margin-inline-start: 4px;
border-radius: 12px;
font-size: 0.85em;
font-weight: 700;
text-align: center;
white-space: nowrap;
vertical-align: middle;
cursor: help;
color: #424242;
background-color: #f5f5f5;
border: 1px solid #bdbdbd;
}

sl-skeleton {
margin-bottom: 1em;
width: 60%;
Expand Down
119 changes: 115 additions & 4 deletions client-src/elements/chromedash-form-field.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,80 @@
import {SlDetails, SlIconButton, SlInput} from '@shoelace-style/shoelace';
import {LitElement, TemplateResult, html, nothing} from 'lit';
import {LitElement, TemplateResult, css, html, nothing} from 'lit';
import {customElement, property, state} from 'lit/decorators.js';
import {ref} from 'lit/directives/ref.js';

import {ChromedashApp} from './chromedash-app';
import './chromedash-attachments';
import './chromedash-textarea';
import {ALL_FIELDS, resolveFieldForFeature} from './form-field-specs';
import {
ALL_FIELDS,
FieldUsage,
resolveFieldForFeature,
} from './form-field-specs';
import {
FieldInfo,
getFieldValueFromFeature,
showToastMessage,
} from './utils.js';
import {Feature, StageDict} from '../js-src/cs-client';
import {FormattedFeature} from './form-definition';
import {ALL_INTENT_USAGE_BY_FEATURE_TYPE, UsageType} from './form-field-enums';

interface getFieldValue {
(fieldName: string, stageOrId: any): any;
feature?: Feature;
}

interface UsageTypeDetail {
abbreviation: string;
className: string;
title: string;
}

// Helper map to store details for each intent type.
const USAGE_TYPE_DETAILS: Record<UsageType, UsageTypeDetail> = {
[UsageType.Prototype]: {
abbreviation: 'I2P',
className: 'usage-tag--prototype',
title: 'Intent to Prototype',
},
[UsageType.DeveloperTesting]: {
abbreviation: 'R4DT',
className: 'usage-tag--dev-testing',
title: 'Ready for Developer Testing',
},
[UsageType.Experiment]: {
abbreviation: 'I2E',
className: 'usage-tag--experiment',
title: 'Intent to Experiment',
},
[UsageType.Ship]: {
abbreviation: 'I2S',
className: 'usage-tag--ship',
title: 'Intent to Ship',
},
[UsageType.PSA]: {
abbreviation: 'PSA',
className: 'usage-tag--psa',
title: 'Web-Facing Change PSA',
},
[UsageType.DeprecateAndRemove]: {
abbreviation: 'I2D',
className: 'usage-tag--deprecate',
title: 'Intent to Deprecate and Remove',
},
[UsageType.ReleaseNotes]: {
abbreviation: 'RN',
className: 'usage-tag--rn',
title: 'Release Notes',
},
[UsageType.CrossFunctionReview]: {
abbreviation: 'XFN',
className: 'usage-tag-xfn',
title: 'Cross-Functional Review',
},
};

@customElement('chromedash-form-field')
export class ChromedashFormField extends LitElement {
@property({type: String})
Expand Down Expand Up @@ -462,6 +517,54 @@ export class ChromedashFormField extends LitElement {
return fieldHTML;
}

/**
* Generates an array of Lit TemplateResults for intent tags.
* @param fieldUsageInfo An object containing the intent usage for a field.
* @param featureType The type of the current feature.
* @return An array of TemplateResults, each rendering a <span> tag.
*/
renderUsageIcons(
fieldUsageInfo: FieldUsage,
featureType: number | undefined
): TemplateResult[] {
if (featureType === undefined) {
return [];
}
const intentTypesUsed = fieldUsageInfo[featureType];
if (!intentTypesUsed) {
return [];
}

// If the field is used in ALL intents, render a special "All" tag.
if (
ALL_INTENT_USAGE_BY_FEATURE_TYPE[featureType].isSubsetOf(intentTypesUsed)
) {
return [
html`<span
class="usage-tag usage-tag--all"
title="This field is used to populate all intent templates when provided"
>
All
</span>`,
];
}

const intentIcons: TemplateResult[] = [];
for (const intentType of intentTypesUsed) {
const details = USAGE_TYPE_DETAILS[intentType];
if (details) {
const tooltipText = `This field is used to populate the ${details.title} template`;
intentIcons.push(html`
<span class="usage-tag ${details.className}" title="${tooltipText}"
>${details.abbreviation}</span
>
`);
}
}

return intentIcons;
}

render() {
if (this.fieldProps.deprecated && !this.value) {
return nothing;
Expand All @@ -479,8 +582,16 @@ export class ChromedashFormField extends LitElement {
${this.fieldProps.label
? html`
<tr class="${fadeInClass}">
<th colspan="2">
<b>${this.fieldProps.label}:</b>
<th class="form-field-header">
<div>
<b>${this.fieldProps.label}:</b>
</div>
<div>
${this.renderUsageIcons(
this.fieldProps.usage,
this.feature?.feature_type_int
)}
</div>
</th>
</tr>
`
Expand Down
112 changes: 110 additions & 2 deletions client-src/elements/chromedash-form-field_test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import {assert, fixture} from '@open-wc/testing';
import '@shoelace-style/shoelace/dist/components/option/option.js';
import {html} from 'lit';
import {html, render} from 'lit';
import {
ChromedashFormField,
enumLabelToFeatureKey,
} from './chromedash-form-field';
import {
ALL_INTENT_USAGE_BY_FEATURE_TYPE,
UsageType,
STAGE_BLINK_INCUBATE,
STAGE_BLINK_ORIGIN_TRIAL,
STAGE_BLINK_SHIPPING,
Expand Down Expand Up @@ -211,8 +213,15 @@ describe('chromedash-form-field', () => {
});

it('skips unused obsolete multiselect choices', async () => {
const formattedFeature = {
id: 12345,
feature_type_int: 0,
};
const component = await fixture(
html` <chromedash-form-field name="rollout_platforms">
html` <chromedash-form-field
name="rollout_platforms"
.feature=${formattedFeature}
>
</chromedash-form-field>`
);
assert.exists(component);
Expand Down Expand Up @@ -310,4 +319,103 @@ describe('chromedash-form-field', () => {
assert.equal(enumLabelToFeatureKey('fOoBaR123'), 'f-oo-ba-r-123');
});
});

describe('renderUsageIcons', () => {
let component;
const container = document.createElement('div');

const MOCK_FEATURE_TYPE_NEW_FEATURE = 0;
const MOCK_FEATURE_TYPE_DEPRECATION = 1;
ALL_INTENT_USAGE_BY_FEATURE_TYPE[MOCK_FEATURE_TYPE_NEW_FEATURE] = new Set([
UsageType.Prototype,
UsageType.Experiment,
UsageType.Ship,
]);

beforeEach(async () => {
component = new ChromedashFormField();
});

it('renders specific usage icons for a given array of usage', () => {
const usage = new Set<UsageType>([UsageType.Prototype, UsageType.Ship]);
const fieldIntentInfo = {[MOCK_FEATURE_TYPE_NEW_FEATURE]: usage};

const result = component.renderUsageIcons(
fieldIntentInfo,
MOCK_FEATURE_TYPE_NEW_FEATURE
);
render(result, container);

const spans = container.querySelectorAll('.usage-tag');
assert.equal(spans.length, 2, 'Should render two tags');

const firstSpan = spans[0];
assert.equal(firstSpan.textContent!.trim(), 'I2P');
assert.isTrue(firstSpan.classList.contains('usage-tag--prototype'));
assert.equal(
firstSpan.getAttribute('title'),
'This field is used to populate the Intent to Prototype template'
);

const secondSpan = spans[1];
assert.equal(secondSpan.textContent!.trim(), 'I2S');
assert.isTrue(secondSpan.classList.contains('usage-tag--ship'));
assert.equal(
secondSpan.getAttribute('title'),
'This field is used to populate the Intent to Ship template'
);
});

it('renders the "All" tag when intents match the feature type\'s full set', () => {
const allIntents =
ALL_INTENT_USAGE_BY_FEATURE_TYPE[MOCK_FEATURE_TYPE_NEW_FEATURE];
const fieldIntentInfo = {[MOCK_FEATURE_TYPE_NEW_FEATURE]: allIntents};

const result = component.renderUsageIcons(
fieldIntentInfo,
MOCK_FEATURE_TYPE_NEW_FEATURE
);
render(result, container);

const spans = container.querySelectorAll('.usage-tag');
assert.equal(spans.length, 1, 'Should render only one "All" tag');

const span = spans[0];
assert.equal(span.textContent!.trim(), 'All');
assert.isTrue(span.classList.contains('usage-tag--all'));
assert.equal(
span.getAttribute('title'),
'This field is used to populate all intent templates when provided'
);
});

it('returns an empty template when no usage is defined for the feature type', () => {
const fieldIntentInfo = {
[MOCK_FEATURE_TYPE_NEW_FEATURE]: new Set<UsageType>([
UsageType.Prototype,
]),
};
// Use a feature type that is not in the fieldIntentInfo object.
const result = component.renderUsageIcons(
fieldIntentInfo,
MOCK_FEATURE_TYPE_DEPRECATION
);
render(result, container);
// An empty lit-html template renders as a comment block.
assert.equal(container.innerHTML.trim(), '<!---->');
});

it('returns an empty template for an empty array of usage', () => {
const fieldIntentInfo = {
[MOCK_FEATURE_TYPE_NEW_FEATURE]: new Set<UsageType>([]),
};
const result = component.renderUsageIcons(
fieldIntentInfo,
MOCK_FEATURE_TYPE_NEW_FEATURE
);
render(result, container);
// An empty lit-html template renders as a comment block.
assert.equal(container.innerHTML.trim(), '<!---->');
});
});
});
54 changes: 54 additions & 0 deletions client-src/elements/form-field-enums.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,14 @@ export const WEBFEATURE_USE_COUNTER_TYPES: Record<
],
};

export enum FeatureType {
Incubate = 0,
Existing = 1,
CodeChange = 2,
Deprecation = 3,
Enterprise = 4,
}

// FEATURE_TYPES object is organized as [intValue, stringLabel, description],
// the descriptions are used only for the descriptions of feature_type_radio_group
export const FEATURE_TYPES_WITHOUT_ENTERPRISE: Record<
Expand Down Expand Up @@ -373,6 +381,52 @@ export const SHIPPED_MILESTONE_FIELDS = new Set<string>([
'shipped_webview_milestone',
]);

// Types of documents in which fields are used.
export enum UsageType {
Prototype,
DeveloperTesting,
Experiment,
Ship,
PSA,
DeprecateAndRemove,
CrossFunctionReview,
ReleaseNotes,
}

// All intent types that are relevant to a given feature type.
export const ALL_FEATURE_TYPE_INCUBATE_INTENTS = new Set<UsageType>([
UsageType.Prototype,
UsageType.DeveloperTesting,
UsageType.Experiment,
UsageType.Ship,
]);

export const ALL_FEATURE_TYPE_EXISTING_INTENTS = new Set<UsageType>([
UsageType.Prototype,
UsageType.DeveloperTesting,
UsageType.Experiment,
UsageType.Ship,
]);

export const ALL_FEATURE_TYPE_CODE_CHANGE_INTENTS = new Set<UsageType>([
UsageType.DeveloperTesting,
UsageType.PSA,
]);

export const ALL_FEATURE_TYPE_DEPRECATION_INTENTS = new Set<UsageType>([
UsageType.DeprecateAndRemove,
UsageType.DeveloperTesting,
UsageType.Experiment,
UsageType.Ship,
]);

export const ALL_INTENT_USAGE_BY_FEATURE_TYPE = {
[FeatureType.Incubate]: ALL_FEATURE_TYPE_INCUBATE_INTENTS,
[FeatureType.Existing]: ALL_FEATURE_TYPE_EXISTING_INTENTS,
[FeatureType.CodeChange]: ALL_FEATURE_TYPE_CODE_CHANGE_INTENTS,
[FeatureType.Deprecation]: ALL_FEATURE_TYPE_DEPRECATION_INTENTS,
};

// Every mutable field that exists on the Stage entity and every key
// in MilestoneSet.MILESTONE_FIELD_MAPPING should be listed here.
export const STAGE_SPECIFIC_FIELDS = new Set<string>([
Expand Down
Loading