blob: 866a297571eff704e88e7492fc0545a6ab6f1039 [file] [log] [blame]
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {customElement, property, state} from 'lit/decorators.js';
import {css, html, LitElement, PropertyValues} from 'lit';
import {sharedStyles} from '../../../styles/shared-styles';
import {grFormStyles} from '../../../styles/gr-form-styles';
import {FlowInput} from '../../../api/rest-api';
import {getAppContext} from '../../../services/app-context';
import {NumericChangeId, ServerInfo} from '../../../types/common';
import '../../shared/gr-button/gr-button';
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-autogrow-textarea/gr-autogrow-textarea.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 {subscribe} from '../../lit/subscription-controller';
import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
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';
const MAX_AUTOCOMPLETE_RESULTS = 10;
const STAGE_SEPARATOR = ';';
@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;
@state()
private stages: {
condition: string;
action: string;
parameterStr: string;
}[] = [];
@state() private currentCondition = '';
@state() private currentAction = '';
@state() private currentParameter = '';
@state() private currentConditionPrefix = 'Gerrit';
@state() private loading = false;
@state() private serverConfig?: ServerInfo;
@state() flowString = '';
private readonly restApiService = getAppContext().restApiService;
private readonly getConfigModel = resolve(this, configModelToken);
private readonly getFlowsModel = resolve(this, flowsModelToken);
private readonly projectSuggestions: SuggestionProvider = (
predicate,
expression
) => this.fetchProjects(predicate, expression);
private readonly groupSuggestions: SuggestionProvider = (
predicate,
expression
) => this.fetchGroups(predicate, expression);
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)
);
}
static override get styles() {
return [
sharedStyles,
grFormStyles,
css`
.raw-flow-container {
display: flex;
align-items: center;
gap: var(--spacing-s);
}
gr-autogrow-textarea {
width: 72ch;
margin-bottom: var(--spacing-m);
border-color: var(--primary-text-color, black);
}
.add-stage-row {
display: flex;
align-items: center;
gap: var(--spacing-s);
}
.add-stage-row > md-outlined-select,
.add-stage-row > md-outlined-text-field,
.add-stage-row > gr-search-autocomplete {
width: 15em;
}
table {
border-collapse: collapse;
}
th,
td {
border: 1px solid var(--border-color);
padding: var(--spacing-s);
text-align: left;
}
`,
];
}
override firstUpdated() {
this.hostUrl = window.location.origin + window.location.pathname;
}
override updated(changedProperties: PropertyValues) {
if (changedProperties.has('stages')) {
this.computeFlowString();
}
}
private renderTable() {
return when(
this.stages.length > 0,
() => html`
<table>
<thead>
<tr>
<th>Stage</th>
<th>Condition</th>
<th>Action</th>
<th>Parameters</th>
<th></th>
</tr>
</thead>
<tbody>
${this.stages.map(
(stage, index) => html`
<tr>
<td>${index + 1}</td>
<td>${stage.condition}</td>
<td>${stage.action}</td>
<td>${stage.parameterStr}</td>
<td>
<gr-button
link
@click=${() => this.handleRemoveStage(index)}
title="Delete stage"
>
<gr-icon icon="delete" filled></gr-icon>
</gr-button>
</td>
</tr>
`
)}
</tbody>
</table>
`
);
}
private computeFlowString() {
const stageToString = (stage: {
condition: string;
action: string;
parameterStr: string;
}) => {
if (stage.action) {
if (stage.parameterStr) {
return `${stage.condition} -> ${stage.action} ${stage.parameterStr}`;
}
return `${stage.condition} -> ${stage.action}`;
}
return stage.condition;
};
this.flowString = this.stages.map(stageToString).join(STAGE_SEPARATOR);
}
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`
<div class="raw-flow-container">
<gr-autogrow-textarea
placeholder="raw flow"
label="Raw Flow"
.value=${this.flowString}
@input=${(e: InputEvent) => {
this.flowString = (e.target as HTMLTextAreaElement).value;
this.parseStagesFromRawFlow(this.flowString);
}}
></gr-autogrow-textarea>
<gr-copy-clipboard
.text=${this.flowString}
buttonTitle="Copy raw flow to clipboard"
hideinput
></gr-copy-clipboard>
</div>
<div>${this.renderTable()}</div>
<div class="add-stage-row">
<md-outlined-select
value=${this.currentConditionPrefix}
@change=${(e: Event) => {
const select = e.target as HTMLSelectElement;
this.currentConditionPrefix = select.value;
}}
>
<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>
</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>`}
<span> -> </span>
<md-outlined-text-field
label="Action"
.value=${this.currentAction}
@input=${(e: InputEvent) =>
(this.currentAction = (e.target as MdOutlinedTextField).value)}
></md-outlined-text-field>
<md-outlined-text-field
label="Parameters"
.value=${this.currentParameter}
@input=${(e: InputEvent) =>
(this.currentParameter = (e.target as MdOutlinedTextField).value)}
></md-outlined-text-field>
<gr-button aria-label="Add Stage" @click=${this.handleAddStage}
>Add Stage</gr-button
>
</div>
<gr-button
aria-label="Create Flow"
?disabled=${this.loading}
@click=${this.handleCreateFlow}
>
Create Flow
</gr-button>
`;
}
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.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() !== '' ||
this.currentAction.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;
}
}
declare global {
interface HTMLElementTagNameMap {
'gr-create-flow': GrCreateFlow;
}
}