blob: a5ec82727acf5ac196580020ac60a15a63789df2 [file] [log] [blame]
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {customElement, property, query, state} from 'lit/decorators.js';
import {css, html, LitElement, PropertyValues} from 'lit';
import {sharedStyles} from '../../../styles/shared-styles';
import {materialStyles} from '../../../styles/gr-material-styles';
import {grFormStyles} from '../../../styles/gr-form-styles';
import {ChangeInfo, FlowActionInfo, FlowInput} from '../../../api/rest-api';
import {getAppContext} from '../../../services/app-context';
import {NumericChangeId, ServerInfo} from '../../../types/common';
import '../../shared/gr-button/gr-button';
import '../../shared/gr-dialog/gr-dialog';
import '../../core/gr-search-autocomplete/gr-search-autocomplete';
import '@material/web/select/outlined-select.js';
import '@material/web/select/select-option.js';
import '@material/web/textfield/outlined-text-field.js';
import '../../shared/gr-copy-clipboard/gr-copy-clipboard';
import {resolve} from '../../../models/dependency';
import {configModelToken} from '../../../models/config/config-model';
import {flowsModelToken} from '../../../models/flows/flows-model';
import './gr-flow-rule';
import {subscribe} from '../../lit/subscription-controller';
import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
import {modalStyles} from '../../../styles/gr-modal-styles';
import {
AutocompleteSuggestion,
fetchAccountSuggestions,
} from '../../../utils/autocomplete-util';
import {ValueChangedEvent} from '../../../types/events';
import {SuggestionProvider} from '../../core/gr-search-autocomplete/gr-search-autocomplete';
import {when} from 'lit/directives/when.js';
import {MdOutlinedTextField} from '@material/web/textfield/outlined-text-field.js';
import {
computeFlowString,
Stage,
STAGE_SEPARATOR,
} from '../../../utils/flows-util';
import {FlowCustomConditionInfo} from '../../../api/flows';
import {changeModelToken} from '../../../models/change/change-model';
import {combineLatest} from 'rxjs';
const MAX_AUTOCOMPLETE_RESULTS = 10;
@customElement('gr-create-flow')
export class GrCreateFlow extends LitElement {
@property({type: Number}) changeNum?: NumericChangeId;
// Property so that we can mock it in tests
@property({type: String}) hostUrl?: string;
@query('#createModal')
private createModal?: HTMLDialogElement;
@state()
// private but used in tests
stages: Stage[] = [];
@state()
// private but used in tests
currentCondition = '';
@state()
// private but used in tests
currentAction = '';
@state()
// private but used in tests
currentParameter = '';
@state() private currentConditionPrefix = 'Gerrit';
@state() private guidedBuilderExpanded = true;
@state() private loading = false;
@state() private serverConfig?: ServerInfo;
@state() flowString = '';
@state()
// private but used in tests
flowActions: FlowActionInfo[] = [];
private readonly restApiService = getAppContext().restApiService;
private readonly getConfigModel = resolve(this, configModelToken);
private readonly getFlowsModel = resolve(this, flowsModelToken);
private readonly getChangeModel = resolve(this, changeModelToken);
private readonly projectSuggestions: SuggestionProvider = (
predicate,
expression
) => this.fetchProjects(predicate, expression);
private readonly groupSuggestions: SuggestionProvider = (
predicate,
expression
) => this.fetchGroups(predicate, expression);
private customConditions: FlowCustomConditionInfo[] = [];
private readonly accountSuggestions: SuggestionProvider = (
predicate,
expression
) => {
const accountFetcher = (expr: string) =>
this.restApiService.queryAccounts(
expr,
MAX_AUTOCOMPLETE_RESULTS,
undefined,
undefined,
throwingErrorCallback
);
return fetchAccountSuggestions(
accountFetcher,
predicate,
expression,
this.serverConfig
);
};
constructor() {
super();
subscribe(
this,
() => this.getConfigModel().serverConfig$,
config => (this.serverConfig = config)
);
subscribe(
this,
() =>
combineLatest([
this.getChangeModel().change$,
this.getFlowsModel().provider$,
]),
async ([change, provider]) => {
if (!change || !provider) return;
// TODO: add handling for multiple providers
this.customConditions = await provider.getCustomConditions(
change as ChangeInfo
);
}
);
this.hostUrl = window.location.origin + window.location.pathname;
}
static override get styles() {
return [
materialStyles,
sharedStyles,
grFormStyles,
modalStyles,
css`
md-outlined-text-field,
gr-search-autocomplete,
md-outlined-select {
--md-outlined-field-top-space: 10px;
--md-outlined-field-bottom-space: 10px;
}
.raw-flow-container {
display: flex;
align-items: center;
gap: var(--spacing-s);
}
.main {
width: 680px; /* 85ch equivalent to prevent screenshot flakiness */
}
.guided-builder-header {
display: flex;
align-items: center;
gap: var(--spacing-s);
justify-content: center;
color: var(--link-color);
margin-top: var(--spacing-l);
margin-bottom: var(--spacing-m);
border: 1px solid var(--border-color);
border-radius: var(--border-radius, 4px);
padding: var(--spacing-m);
cursor: pointer;
user-select: none;
}
.add-stage-box {
display: flex;
flex-direction: column;
gap: var(--spacing-s);
background-color: var(--background-color-secondary);
border: 1px solid var(--border-color);
border-radius: var(--border-radius, 4px);
padding: var(--spacing-m);
margin-top: var(--spacing-m);
}
.add-stage-box md-outlined-text-field,
.add-stage-box gr-search-autocomplete,
.add-stage-box md-outlined-select {
background-color: var(--background-color-primary);
border-radius: var(--border-radius, 4px);
}
.stage-label {
color: var(--deemphasized-text-color);
font-size: var(--font-size-small);
}
.stage-row {
display: flex;
align-items: center;
gap: var(--spacing-s);
margin-bottom: var(--spacing-m);
}
.stage-row:last-child {
margin-bottom: 0;
}
.stage-row > md-outlined-select {
width: 15em;
}
.stage-row > md-outlined-text-field {
background-color: var(--background-color-primary);
border-radius: var(--border-radius, 4px);
}
.stage-row > gr-search-autocomplete {
background-color: var(--background-color-primary);
--gr-search-bar-border-radius: var(--border-radius, 4px);
--view-background-color: transparent;
--gr-autocomplete-height: 42px;
}
.stage-row > md-outlined-text-field,
.stage-row > gr-search-autocomplete {
flex: 1;
}
.stages-list {
display: flex;
flex-direction: column;
gap: var(--spacing-m);
}
.stage-list-item {
display: flex;
align-items: center;
gap: var(--spacing-m);
}
.stage-number {
font-weight: var(--font-weight-bold);
color: var(--deemphasized-text-color);
min-width: 1.5em;
}
.flow-rule {
flex: 1;
border: 1px solid var(--border-color);
padding: var(--spacing-m) var(--spacing-l);
border-radius: var(--border-radius);
background-color: var(--background-color-primary);
}
.full-width-text-field {
width: 100%;
margin-top: var(--spacing-s);
margin-bottom: var(--spacing-m);
}
`,
];
}
override willUpdate(changedProperties: PropertyValues) {
if (changedProperties.has('changeNum')) {
this.getFlowActions();
}
if (
changedProperties.has('stages') &&
!changedProperties.has('flowString')
) {
this.flowString = computeFlowString(this.stages);
}
}
private async getFlowActions() {
if (!this.changeNum) return;
const actions = await this.restApiService.listFlowActions(this.changeNum);
this.flowActions = actions ?? [];
if (this.flowActions.length > 0) {
this.currentAction = this.flowActions[0].name;
}
}
private renderStages() {
return when(
this.stages.length > 0,
() => html`
<div class="stages-list">
${this.stages.map(
(stage, index) => html`
<div class="stage-list-item">
<span class="stage-number">${index + 1}</span>
<gr-flow-rule
class="flow-rule"
.condition=${stage.condition}
.action=${stage.action}
.parameterStr=${stage.parameterStr}
></gr-flow-rule>
<gr-button
link
@click=${() => this.handleRemoveStage(index)}
title="Delete stage"
>
<gr-icon icon="delete" filled></gr-icon>
</gr-button>
</div>
`
)}
</div>
`
);
}
private parseStagesFromRawFlow(rawFlow: string) {
if (!rawFlow) {
this.stages = [];
return;
}
const stageStrings = rawFlow.split(STAGE_SEPARATOR);
this.stages = stageStrings.map(stageStr => {
const stage = {
condition: '',
action: '',
parameterStr: '',
};
if (stageStr.includes('->')) {
const [condition, actionStr] = stageStr.split('->').map(s => s.trim());
stage.condition = condition;
const actionParts = actionStr.split(' ').filter(part => part);
stage.action = actionParts[0] ?? '';
if (actionParts.length > 1) {
stage.parameterStr = actionParts.slice(1).join(' ');
}
} else {
stage.condition = stageStr.trim();
}
return stage;
});
}
override render() {
return html`
<gr-button
aria-label="Create Flow"
@click=${() => {
this.createModal?.showModal();
}}
>
Create Flow
</gr-button>
${this.renderCreateFlowDialog()}
`;
}
private renderCustomConditions() {
return this.customConditions.map(
condition => html`<md-select-option value=${condition.name}>
<div slot="headline">${condition.name}</div>
</md-select-option>`
);
}
private renderConditions() {
return html`<md-select-option value="Gerrit">
<div slot="headline">Gerrit</div>
</md-select-option>
<md-select-option value="Other">
<div slot="headline">Other</div>
</md-select-option>
${this.renderCustomConditions()}`;
}
private renderCreateFlowDialog() {
return html`
<dialog id="createModal" tabindex="-1">
<gr-dialog
confirm-label="Create flow"
cancel-label="Close"
?disabled=${this.loading}
@confirm=${this.handleCreateFlow}
@cancel=${() => {
this.createModal?.close();
}}
>
<div slot="header">Create new flow</div>
<div class="main" slot="main">
<div class="raw-flow-container">
<md-outlined-text-field
class="full-width-text-field"
type="textarea"
rows="4"
label="Copy and Paste existing flows"
.value=${this.flowString}
@input=${(e: InputEvent) => {
this.flowString = (e.target as MdOutlinedTextField).value;
this.parseStagesFromRawFlow(this.flowString);
}}
></md-outlined-text-field>
<gr-copy-clipboard
.text=${this.flowString}
buttonTitle="Copy raw flow to clipboard"
hideinput
></gr-copy-clipboard>
</div>
<div
class="guided-builder-header"
@click=${(e: Event) => this.toggleGuidedBuilder(e)}
@keydown=${(e: KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
this.toggleGuidedBuilder(e);
}
}}
role="button"
tabindex="0"
aria-expanded=${this.guidedBuilderExpanded ? 'true' : 'false'}
>
<gr-icon
icon=${this.guidedBuilderExpanded
? 'expand_less'
: 'expand_more'}
filled
></gr-icon>
<span>Guided Builder</span>
</div>
${when(
this.guidedBuilderExpanded,
() => html`
<div>${this.renderStages()}</div>
<div class="add-stage-box">
<div class="stage-label">Condition: IF</div>
<div class="stage-row">
<md-outlined-select
value=${this.currentConditionPrefix}
@change=${(e: Event) => {
const select = e.target as HTMLSelectElement;
this.currentConditionPrefix = select.value;
}}
>
${this.renderConditions()}
</md-outlined-select>
${this.currentConditionPrefix === 'Gerrit'
? html`<gr-search-autocomplete
.placeholder=${'Create condition'}
.value=${this.currentCondition}
.projectSuggestions=${this.projectSuggestions}
.groupSuggestions=${this.groupSuggestions}
.accountSuggestions=${this.accountSuggestions}
@text-changed=${this.handleGerritConditionTextChanged}
></gr-search-autocomplete>`
: html`<md-outlined-text-field
label="Condition"
.value=${this.currentCondition}
@input=${(e: InputEvent) =>
(this.currentCondition = (
e.target as MdOutlinedTextField
).value)}
></md-outlined-text-field>`}
</div>
<div class="stage-label">Action: Then</div>
<div class="stage-row">
<md-outlined-select
label="Action"
.value=${this.currentAction}
@change=${(e: Event) => {
const select = e.target as HTMLSelectElement;
this.currentAction = select.value;
}}
>
${this.flowActions.map(
action => html`
<md-select-option .value=${action.name}>
<div slot="headline">${action.name}</div>
</md-select-option>
`
)}
</md-outlined-select>
<md-outlined-text-field
label="Parameters"
.placeholder=${this.getParametersPlaceholder(
this.currentAction
)}
.value=${this.currentParameter}
@input=${(e: InputEvent) =>
(this.currentParameter = (
e.target as MdOutlinedTextField
).value)}
></md-outlined-text-field>
</div>
<div class="stage-row" style="margin-top: var(--spacing-m);">
<gr-button
link
aria-label="Add Stage"
@click=${this.handleAddStage}
>Add Stage</gr-button
>
</div>
</div>
`
)}
</div>
</gr-dialog>
</dialog>
`;
}
private toggleGuidedBuilder(e: Event) {
e.stopPropagation();
e.preventDefault();
this.guidedBuilderExpanded = !this.guidedBuilderExpanded;
}
private handleGerritConditionTextChanged(e: ValueChangedEvent) {
this.currentCondition = e.detail.value ?? '';
}
// TODO: Move into the common util file
fetchProjects(
predicate: string,
expression: string
): Promise<AutocompleteSuggestion[]> {
return this.restApiService
.getSuggestedRepos(
expression,
MAX_AUTOCOMPLETE_RESULTS,
throwingErrorCallback
)
.then(projects => {
if (!projects) {
return [];
}
const keys = Object.keys(projects);
return keys.map(key => {
return {text: predicate + ':' + key};
});
});
}
fetchGroups(
predicate: string,
expression: string
): Promise<AutocompleteSuggestion[]> {
if (expression.length === 0) {
return Promise.resolve([]);
}
return this.restApiService
.getSuggestedGroups(
expression,
undefined,
MAX_AUTOCOMPLETE_RESULTS,
throwingErrorCallback
)
.then(groups => {
if (!groups) {
return [];
}
const keys = Object.keys(groups);
return keys.map(key => {
return {text: predicate + ':' + key};
});
});
}
private handleAddStage() {
if (this.currentCondition.trim() === '' && this.currentAction.trim() === '')
return;
const condition =
this.currentConditionPrefix === 'Gerrit'
? `${this.hostUrl} is ${this.currentCondition}`
: this.currentCondition;
this.stages = [
...this.stages,
{
condition,
action: this.currentAction,
parameterStr: this.currentParameter,
},
];
this.currentCondition = '';
this.currentAction = this.flowActions[0]?.name ?? '';
this.currentParameter = '';
}
private handleRemoveStage(index: number) {
this.stages = this.stages.filter((_, i) => i !== index);
}
private async handleCreateFlow() {
if (!this.changeNum) return;
const allStages = [...this.stages];
if (this.currentCondition.trim() !== '') {
const condition =
this.currentConditionPrefix === 'Gerrit'
? `${this.hostUrl} is ${this.currentCondition}`
: this.currentCondition;
allStages.push({
condition,
action: this.currentAction,
parameterStr: this.currentParameter,
});
}
if (allStages.length === 0) return; // Or show an error
this.loading = true;
const flowInput: FlowInput = {
stage_expressions: allStages.map(stage => {
if (stage.action) {
const action: {name: string; parameters?: string[]} = {
name: stage.action,
};
if (stage.parameterStr.length > 0) {
action.parameters = stage.parameterStr.split(' ');
}
return {
condition: stage.condition,
action,
};
}
return {condition: stage.condition};
}),
};
await this.getFlowsModel().createFlow(flowInput);
this.stages = [];
this.currentCondition = '';
this.currentAction = '';
this.currentParameter = '';
this.loading = false;
this.createModal?.close();
}
// TODO: we want to at some point dynamically fetch this information.
// The list of actions is being fetched dynamically but
// this code hardcodes these actions for now.
private getParametersPlaceholder(action: string) {
if (action === 'add-reviewer') return 'user@example.com';
if (action === 'vote') return '<Label>+/-<Value>';
if (action === 'submit') return 'no parameter required';
return 'Parameters';
}
}
declare global {
interface HTMLElementTagNameMap {
'gr-create-flow': GrCreateFlow;
}
}