| /** |
| * @license |
| * Copyright 2025 Google LLC |
| * SPDX-License-Identifier: Apache-2.0 |
| */ |
| import {customElement, query, state} from 'lit/decorators.js'; |
| import {css, html, LitElement, TemplateResult} from 'lit'; |
| import {sharedStyles} from '../../../styles/shared-styles'; |
| import {grFormStyles} from '../../../styles/gr-form-styles'; |
| import {resolve} from '../../../models/dependency'; |
| import {changeModelToken} from '../../../models/change/change-model'; |
| import {subscribe} from '../../lit/subscription-controller'; |
| import {FlowInfo, FlowStageInfo, FlowStageState} from '../../../api/rest-api'; |
| import {flowsModelToken} from '../../../models/flows/flows-model'; |
| import {NumericChangeId} from '../../../types/common'; |
| import './gr-create-flow'; |
| import {when} from 'lit/directives/when.js'; |
| import '../../shared/gr-dialog/gr-dialog'; |
| import '@material/web/select/filled-select'; |
| import '@material/web/select/select-option'; |
| import '../../shared/gr-account-label/gr-account-label'; |
| import '../../shared/gr-avatar/gr-avatar'; |
| import '../../shared/gr-date-formatter/gr-date-formatter'; |
| import {formatActionName} from '../../../utils/flows-util'; |
| import './gr-flow-rule'; |
| import {computeFlowStringFromFlowStageInfo} from '../../../utils/flows-util'; |
| import {materialStyles} from '../../../styles/gr-material-styles'; |
| |
| @customElement('gr-flows') |
| export class GrFlows extends LitElement { |
| @query('#deleteFlowModal') |
| deleteFlowModal?: HTMLDialogElement; |
| |
| @state() private flows: FlowInfo[] = []; |
| |
| @state() private changeNum?: NumericChangeId; |
| |
| @state() isOwner = false; |
| |
| @state() private loading = true; |
| |
| @state() private flowIdToDelete?: string; |
| |
| private readonly getChangeModel = resolve(this, changeModelToken); |
| |
| private readonly getFlowsModel = resolve(this, flowsModelToken); |
| |
| static override get styles() { |
| return [ |
| materialStyles, |
| sharedStyles, |
| grFormStyles, |
| css` |
| .container { |
| padding: var(--spacing-l); |
| } |
| b { |
| font-weight: bolder; |
| } |
| hr { |
| margin-top: var(--spacing-l); |
| margin-bottom: var(--spacing-l); |
| border: 0; |
| border-top: 1px solid var(--border-color); |
| } |
| .header-actions { |
| margin-bottom: var(--spacing-l); |
| } |
| .flows-header { |
| display: flex; |
| justify-content: space-between; |
| align-items: center; |
| margin-bottom: var(--spacing-m); |
| } |
| .heading-with-button { |
| display: flex; |
| align-items: center; |
| } |
| .main-heading { |
| font-size: var(--font-size-h2); |
| font-weight: var(--font-weight-bold); |
| margin-bottom: 0; |
| } |
| .flow { |
| border: 1px solid var(--border-color); |
| border-radius: var(--border-radius); |
| margin-bottom: var(--spacing-l); |
| background: var(--background-color-primary); |
| width: fit-content; |
| } |
| .flow-header { |
| background-color: var(--background-color-secondary); |
| padding: 0 var(--spacing-l); |
| border-bottom: 1px solid var(--border-color); |
| display: flex; |
| justify-content: space-between; |
| align-items: center; |
| border-radius: var(--border-radius) var(--border-radius) 0 0; |
| } |
| .flow-title { |
| font-weight: var(--font-weight-bold); |
| font-family: var(--header-font-family); |
| } |
| .flow-actions { |
| display: flex; |
| align-items: center; |
| } |
| .flow-info { |
| display: flex; |
| justify-content: space-between; |
| flex-wrap: wrap; |
| gap: var(--spacing-m); |
| padding: var(--spacing-m) var(--spacing-l); |
| font-size: var(--font-size-small); |
| color: var(--deemphasized-text-color); |
| } |
| .owner-container { |
| display: flex; |
| align-items: center; |
| gap: var(--spacing-s); |
| } |
| .stages { |
| padding: var(--spacing-m) var(--spacing-l); |
| } |
| gr-flow-rule { |
| margin-bottom: var(--spacing-m); |
| } |
| gr-flow-rule:last-child { |
| margin-bottom: 0; |
| } |
| .refresh { |
| top: -4px; |
| } |
| .no-flows-message { |
| padding-bottom: var(--spacing-l); |
| } |
| `, |
| ]; |
| } |
| |
| constructor() { |
| super(); |
| subscribe( |
| this, |
| () => this.getChangeModel().changeNum$, |
| changeNum => { |
| this.changeNum = changeNum; |
| } |
| ); |
| subscribe( |
| this, |
| () => this.getChangeModel().isOwner$, |
| x => (this.isOwner = x) |
| ); |
| subscribe( |
| this, |
| () => this.getFlowsModel().flows$, |
| flows => { |
| this.flows = flows; |
| } |
| ); |
| subscribe( |
| this, |
| () => this.getFlowsModel().loading$, |
| loading => { |
| this.loading = loading; |
| } |
| ); |
| } |
| |
| private async deleteFlow() { |
| if (!this.flowIdToDelete) return; |
| await this.getFlowsModel().deleteFlow(this.flowIdToDelete); |
| this.closeConfirmDialog(); |
| } |
| |
| private openConfirmDialog(flowId: string) { |
| this.deleteFlowModal?.showModal(); |
| this.flowIdToDelete = flowId; |
| } |
| |
| private closeConfirmDialog() { |
| this.deleteFlowModal?.close(); |
| this.flowIdToDelete = undefined; |
| } |
| |
| override render() { |
| return html` |
| <div class="container"> |
| <div class="header-actions"> |
| ${when( |
| this.isOwner, |
| () => |
| html`<gr-create-flow |
| .changeNum=${this.changeNum} |
| ></gr-create-flow>`, |
| () => |
| html`<b>Note:</b> New flows can only be added by change owner.` |
| )} |
| </div> |
| <div class="flows-header"> |
| <div class="heading-with-button"> |
| <h2 class="main-heading">Scheduled Flows</h2> |
| ${when( |
| this.flows.length > 0, |
| () => |
| html`<gr-button |
| link |
| @click=${() => this.getFlowsModel().reload()} |
| aria-label="Refresh flows" |
| title="Refresh flows" |
| class="refresh" |
| > |
| <gr-icon icon="refresh"></gr-icon> |
| </gr-button>` |
| )} |
| </div> |
| </div> |
| ${this.renderFlowsList()} |
| </div> |
| ${this.renderDeleteFlowModal()} |
| `; |
| } |
| |
| private renderDeleteFlowModal() { |
| return html` <dialog id="deleteFlowModal"> |
| <gr-dialog |
| confirm-label="Delete" |
| @confirm=${() => this.deleteFlow()} |
| @cancel=${() => this.closeConfirmDialog()} |
| > |
| <div class="header" slot="header">Delete Flow</div> |
| <div class="main" slot="main"> |
| Are you sure you want to delete this flow? |
| </div> |
| </gr-dialog> |
| </dialog>`; |
| } |
| |
| private getFlowTitle(flow: FlowInfo) { |
| const lastStage = flow.stages[flow.stages.length - 1]; |
| const name = lastStage?.expression?.action?.name; |
| if (!name) return 'Flow'; |
| return formatActionName(name); |
| } |
| |
| private renderStageRow(stage: FlowStageInfo): TemplateResult { |
| const action = stage.expression.action; |
| |
| return html` |
| <gr-flow-rule |
| .state=${stage.state} |
| .message=${stage.message} |
| .condition=${stage.expression.condition} |
| .action=${action?.name} |
| .parameters=${action?.parameters} |
| ></gr-flow-rule> |
| `; |
| } |
| |
| private isFlowSuccessful(flow: FlowInfo): boolean { |
| if (!flow.stages || flow.stages.length === 0) { |
| return false; |
| } |
| const lastStage = flow.stages[flow.stages.length - 1]; |
| return lastStage.state === FlowStageState.DONE; |
| } |
| |
| private renderFlowsList() { |
| if (this.loading) { |
| return html`<p>Loading...</p>`; |
| } |
| if (this.flows.length === 0) { |
| return html`<div class="no-flows-message"> |
| <p>No flows found for this change.</p> |
| </div>`; |
| } |
| |
| return html` |
| <div> |
| ${this.flows.map( |
| (flow: FlowInfo) => html` |
| <div class="flow"> |
| <div class="flow-header"> |
| <div class="flow-title">${this.getFlowTitle(flow)}</div> |
| <div class="flow-actions"> |
| <gr-copy-clipboard |
| .text=${computeFlowStringFromFlowStageInfo(flow.stages)} |
| buttonTitle="Copy flow string to clipboard" |
| hideinput |
| .smallIcon=${false} |
| ></gr-copy-clipboard> |
| ${when( |
| this.isOwner, |
| () => html` |
| <gr-button |
| link |
| ?disabled=${this.isFlowSuccessful(flow)} |
| @click=${() => this.openConfirmDialog(flow.uuid)} |
| title="Delete flow" |
| > |
| <gr-icon icon="delete"></gr-icon> |
| </gr-button> |
| ` |
| )} |
| </div> |
| </div> |
| |
| <div class="flow-info"> |
| <div class="owner-container"> |
| Owner: |
| <gr-avatar |
| .account=${flow.owner} |
| .imageSize=${16} |
| ></gr-avatar> |
| <gr-account-label .account=${flow.owner}></gr-account-label> |
| </div> |
| ${when( |
| flow.last_evaluated, |
| () => html` |
| <div> |
| Last Evaluation: |
| <gr-date-formatter |
| withTooltip |
| .dateStr=${flow.last_evaluated} |
| ></gr-date-formatter> |
| </div> |
| ` |
| )} |
| </div> |
| |
| <div class="stages"> |
| ${flow.stages.map(stage => this.renderStageRow(stage))} |
| </div> |
| </div> |
| ` |
| )} |
| </div> |
| `; |
| } |
| } |
| |
| declare global { |
| interface HTMLElementTagNameMap { |
| 'gr-flows': GrFlows; |
| } |
| } |