0
0
mirror of https://github.com/go-gitea/gitea.git synced 2026-02-24 19:07:24 +01:00

new layout

This commit is contained in:
Lunny Xiao 2025-09-03 13:09:28 -07:00
parent 5481800fe8
commit 6af504562b
No known key found for this signature in database
GPG Key ID: C3B7C91B632F738A
6 changed files with 903 additions and 207 deletions

View File

@ -117,11 +117,59 @@ type WorkflowAction struct {
ActionValue string
}
// WorkflowEventCapabilities defines what filters and actions are available for each event
type WorkflowEventCapabilities struct {
AvailableFilters []string `json:"available_filters"`
AvailableActions []WorkflowActionType `json:"available_actions"`
}
// GetWorkflowEventCapabilities returns the capabilities for each workflow event
func GetWorkflowEventCapabilities() map[WorkflowEvent]WorkflowEventCapabilities {
return map[WorkflowEvent]WorkflowEventCapabilities{
WorkflowEventItemAddedToProject: {
AvailableFilters: []string{"scope"}, // issue, pull_request
AvailableActions: []WorkflowActionType{WorkflowActionTypeColumn, WorkflowActionTypeLabel},
},
WorkflowEventItemReopened: {
AvailableFilters: []string{"scope"},
AvailableActions: []WorkflowActionType{WorkflowActionTypeColumn, WorkflowActionTypeLabel},
},
WorkflowEventItemClosed: {
AvailableFilters: []string{"scope"},
AvailableActions: []WorkflowActionType{WorkflowActionTypeColumn, WorkflowActionTypeLabel},
},
WorkflowEventCodeChangesRequested: {
AvailableFilters: []string{}, // only applies to pull requests
AvailableActions: []WorkflowActionType{WorkflowActionTypeColumn, WorkflowActionTypeLabel},
},
WorkflowEventCodeReviewApproved: {
AvailableFilters: []string{}, // only applies to pull requests
AvailableActions: []WorkflowActionType{WorkflowActionTypeColumn, WorkflowActionTypeLabel},
},
WorkflowEventPullRequestMerged: {
AvailableFilters: []string{}, // only applies to pull requests
AvailableActions: []WorkflowActionType{WorkflowActionTypeColumn, WorkflowActionTypeLabel, WorkflowActionTypeClose},
},
WorkflowEventAutoArchiveItems: {
AvailableFilters: []string{"scope"},
AvailableActions: []WorkflowActionType{WorkflowActionTypeColumn},
},
WorkflowEventAutoAddToProject: {
AvailableFilters: []string{"scope"},
AvailableActions: []WorkflowActionType{WorkflowActionTypeColumn, WorkflowActionTypeLabel},
},
WorkflowEventAutoCloseIssue: {
AvailableFilters: []string{}, // only applies to issues
AvailableActions: []WorkflowActionType{WorkflowActionTypeClose, WorkflowActionTypeLabel},
},
}
}
type Workflow struct {
ID int64
ProjectID int64 `xorm:"unique(s)"`
ProjectID int64 `xorm:"INDEX"`
Project *Project `xorm:"-"`
WorkflowEvent WorkflowEvent `xorm:"unique(s)"`
WorkflowEvent WorkflowEvent `xorm:"INDEX"`
WorkflowFilters []WorkflowFilter `xorm:"TEXT json"`
WorkflowActions []WorkflowAction `xorm:"TEXT json"`
CreatedUnix timeutil.TimeStamp `xorm:"created"`
@ -157,16 +205,12 @@ func init() {
db.RegisterModel(new(Workflow))
}
func FindWorkflowEvents(ctx context.Context, projectID int64) (map[WorkflowEvent]*Workflow, error) {
events := make(map[WorkflowEvent]*Workflow)
if err := db.GetEngine(ctx).Where("project_id=?", projectID).Find(&events); err != nil {
func FindWorkflowsByProjectID(ctx context.Context, projectID int64) ([]*Workflow, error) {
workflows := make([]*Workflow, 0)
if err := db.GetEngine(ctx).Where("project_id=?", projectID).Find(&workflows); err != nil {
return nil, err
}
res := make(map[WorkflowEvent]*Workflow, len(events))
for _, event := range events {
res[event.WorkflowEvent] = event
}
return res, nil
return workflows, nil
}
func GetWorkflowByID(ctx context.Context, id int64) (*Workflow, error) {

View File

@ -4,11 +4,11 @@
package projects
import (
"fmt"
"net/http"
"strconv"
"code.gitea.io/gitea/models/project"
"code.gitea.io/gitea/models/db"
issues_model "code.gitea.io/gitea/models/issues"
project_model "code.gitea.io/gitea/models/project"
"code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/web"
@ -20,6 +20,78 @@ var (
tmplOrgWorkflows = templates.TplName("org/projects/workflows")
)
// getFilterSummary returns a human-readable summary of the filters
func getFilterSummary(filters []project_model.WorkflowFilter) string {
if len(filters) == 0 {
return ""
}
for _, filter := range filters {
if filter.Type == "scope" {
switch filter.Value {
case "issue":
return " (Issues only)"
case "pull_request":
return " (Pull requests only)"
}
}
}
return ""
}
// convertFormToFilters converts form filters to WorkflowFilter objects
func convertFormToFilters(formFilters map[string]string) []project_model.WorkflowFilter {
filters := make([]project_model.WorkflowFilter, 0)
for key, value := range formFilters {
if value != "" {
filters = append(filters, project_model.WorkflowFilter{
Type: project_model.WorkflowFilterType(key),
Value: value,
})
}
}
return filters
}
// convertFormToActions converts form actions to WorkflowAction objects
func convertFormToActions(formActions map[string]any) []project_model.WorkflowAction {
actions := make([]project_model.WorkflowAction, 0)
for key, value := range formActions {
switch key {
case "column":
if strValue, ok := value.(string); ok && strValue != "" {
actions = append(actions, project_model.WorkflowAction{
ActionType: project_model.WorkflowActionTypeColumn,
ActionValue: strValue,
})
}
case "labels":
if labels, ok := value.([]string); ok && len(labels) > 0 {
for _, label := range labels {
if label != "" {
actions = append(actions, project_model.WorkflowAction{
ActionType: project_model.WorkflowActionTypeLabel,
ActionValue: label,
})
}
}
}
case "closeIssue":
if boolValue, ok := value.(bool); ok && boolValue {
actions = append(actions, project_model.WorkflowAction{
ActionType: project_model.WorkflowActionTypeClose,
ActionValue: "true",
})
}
}
}
return actions
}
func WorkflowsEvents(ctx *context.Context) {
projectID := ctx.PathParamInt64("id")
p, err := project_model.GetProjectByID(ctx, projectID)
@ -40,35 +112,60 @@ func WorkflowsEvents(ctx *context.Context) {
return
}
workflows, err := project_model.FindWorkflowEvents(ctx, projectID)
workflows, err := project_model.FindWorkflowsByProjectID(ctx, projectID)
if err != nil {
ctx.ServerError("GetWorkflows", err)
ctx.ServerError("FindWorkflowsByProjectID", err)
return
}
type WorkflowEvent struct {
EventID string `json:"event_id"`
DisplayName string `json:"display_name"`
type WorkflowConfig struct {
ID int64 `json:"id"`
EventID string `json:"event_id"`
DisplayName string `json:"display_name"`
Capabilities project_model.WorkflowEventCapabilities `json:"capabilities"`
Filters []project_model.WorkflowFilter `json:"filters"`
Actions []project_model.WorkflowAction `json:"actions"`
FilterSummary string `json:"filter_summary"` // Human readable filter description
}
outputWorkflows := make([]*WorkflowEvent, 0, len(workflows))
outputWorkflows := make([]*WorkflowConfig, 0)
events := project_model.GetWorkflowEvents()
capabilities := project_model.GetWorkflowEventCapabilities()
// Create a map for quick lookup of existing workflows
workflowMap := make(map[project_model.WorkflowEvent][]*project_model.Workflow)
for _, wf := range workflows {
workflowMap[wf.WorkflowEvent] = append(workflowMap[wf.WorkflowEvent], wf)
}
for _, event := range events {
var workflow *WorkflowEvent
for _, wf := range workflows {
if wf.WorkflowEvent == event {
workflow = &WorkflowEvent{
EventID: fmt.Sprintf("%d", wf.ID),
DisplayName: string(ctx.Tr(wf.WorkflowEvent.LangKey())),
}
break
existingWorkflows := workflowMap[event]
if len(existingWorkflows) > 0 {
// Add all existing workflows for this event
for _, wf := range existingWorkflows {
filterSummary := getFilterSummary(wf.WorkflowFilters)
outputWorkflows = append(outputWorkflows, &WorkflowConfig{
ID: wf.ID,
EventID: strconv.FormatInt(wf.ID, 10),
DisplayName: string(ctx.Tr(wf.WorkflowEvent.LangKey())) + filterSummary,
Capabilities: capabilities[event],
Filters: wf.WorkflowFilters,
Actions: wf.WorkflowActions,
FilterSummary: filterSummary,
})
}
} else {
// Add placeholder for creating new workflow
outputWorkflows = append(outputWorkflows, &WorkflowConfig{
ID: 0,
EventID: event.UUID(),
DisplayName: string(ctx.Tr(event.LangKey())),
Capabilities: capabilities[event],
Filters: []project_model.WorkflowFilter{},
Actions: []project_model.WorkflowAction{},
FilterSummary: "",
})
}
if workflow == nil {
workflow = &WorkflowEvent{
EventID: event.UUID(),
DisplayName: string(ctx.Tr(event.LangKey())),
}
}
outputWorkflows = append(outputWorkflows, workflow)
}
ctx.JSON(http.StatusOK, outputWorkflows)
@ -115,6 +212,53 @@ func WorkflowsColumns(ctx *context.Context) {
ctx.JSON(http.StatusOK, outputColumns)
}
func WorkflowsLabels(ctx *context.Context) {
projectID := ctx.PathParamInt64("id")
p, err := project_model.GetProjectByID(ctx, projectID)
if err != nil {
if project_model.IsErrProjectNotExist(err) {
ctx.NotFound(nil)
} else {
ctx.ServerError("GetProjectByID", err)
}
return
}
// Only repository projects have access to labels
if p.Type != project_model.TypeRepository {
ctx.JSON(http.StatusOK, []any{})
return
}
if p.RepoID != ctx.Repo.Repository.ID {
ctx.NotFound(nil)
return
}
// Get repository labels
labels, err := issues_model.GetLabelsByRepoID(ctx, p.RepoID, "", db.ListOptions{})
if err != nil {
ctx.ServerError("GetLabelsByRepoID", err)
return
}
type Label struct {
ID int64 `json:"id"`
Name string `json:"name"`
Color string `json:"color"`
}
outputLabels := make([]*Label, 0, len(labels))
for _, label := range labels {
outputLabels = append(outputLabels, &Label{
ID: label.ID,
Name: label.Name,
Color: label.Color,
})
}
ctx.JSON(http.StatusOK, outputLabels)
}
func Workflows(ctx *context.Context) {
workflowIDStr := ctx.PathParam("workflow_id")
if workflowIDStr == "events" {
@ -125,6 +269,10 @@ func Workflows(ctx *context.Context) {
WorkflowsColumns(ctx)
return
}
if workflowIDStr == "labels" {
WorkflowsLabels(ctx)
return
}
ctx.Data["WorkflowEvents"] = project_model.GetWorkflowEvents()
@ -153,9 +301,9 @@ func Workflows(ctx *context.Context) {
ctx.Data["PageIsProjectsWorkflows"] = true
ctx.Data["Project"] = p
workflows, err := project_model.FindWorkflowEvents(ctx, projectID)
workflows, err := project_model.FindWorkflowsByProjectID(ctx, projectID)
if err != nil {
ctx.ServerError("GetWorkflows", err)
ctx.ServerError("FindWorkflowsByProjectID", err)
return
}
for _, wf := range workflows {
@ -184,7 +332,7 @@ func Workflows(ctx *context.Context) {
}
}
ctx.Data["CurWorkflow"] = curWorkflow
ctx.Data["ProjectLink"] = project.ProjectLinkForRepo(ctx.Repo.Repository, projectID)
ctx.Data["ProjectLink"] = project_model.ProjectLinkForRepo(ctx.Repo.Repository, projectID)
if p.Type == project_model.TypeRepository {
ctx.HTML(200, tmplRepoWorkflows)
@ -220,19 +368,38 @@ func WorkflowsPost(ctx *context.Context) {
}
form := web.GetForm(ctx).(*WorkflowsPostForm)
// Convert form data to filters and actions
filters := convertFormToFilters(form.Filters)
actions := convertFormToActions(form.Actions)
eventID, _ := strconv.ParseInt(form.EventID, 10, 64)
if eventID == 0 {
// Create a new workflow
// Create a new workflow for the given event
wf := &project_model.Workflow{
ProjectID: projectID,
WorkflowEvent: project_model.WorkflowEvent(form.EventID),
WorkflowFilters: []project_model.WorkflowFilter{},
WorkflowActions: []project_model.WorkflowAction{},
WorkflowFilters: filters,
WorkflowActions: actions,
}
if err := project_model.CreateWorkflow(ctx, wf); err != nil {
ctx.ServerError("CreateWorkflow", err)
return
}
// Return the newly created workflow with filter summary
filterSummary := getFilterSummary(wf.WorkflowFilters)
ctx.JSON(http.StatusOK, map[string]any{
"success": true,
"workflow": map[string]any{
"id": wf.ID,
"event_id": strconv.FormatInt(wf.ID, 10),
"display_name": string(ctx.Tr(wf.WorkflowEvent.LangKey())) + filterSummary,
"filters": wf.WorkflowFilters,
"actions": wf.WorkflowActions,
"filter_summary": filterSummary,
},
})
} else {
// Update an existing workflow
wf, err := project_model.GetWorkflowByID(ctx, eventID)
@ -240,11 +407,30 @@ func WorkflowsPost(ctx *context.Context) {
ctx.ServerError("GetWorkflowByID", err)
return
}
wf.WorkflowFilters = []project_model.WorkflowFilter{}
wf.WorkflowActions = []project_model.WorkflowAction{}
if wf.ProjectID != projectID {
ctx.NotFound(nil)
return
}
wf.WorkflowFilters = filters
wf.WorkflowActions = actions
if err := project_model.UpdateWorkflow(ctx, wf); err != nil {
ctx.ServerError("UpdateWorkflow", err)
return
}
// Return the updated workflow with filter summary
filterSummary := getFilterSummary(wf.WorkflowFilters)
ctx.JSON(http.StatusOK, map[string]any{
"success": true,
"workflow": map[string]any{
"id": wf.ID,
"event_id": strconv.FormatInt(wf.ID, 10),
"display_name": string(ctx.Tr(wf.WorkflowEvent.LangKey())) + filterSummary,
"filters": wf.WorkflowFilters,
"actions": wf.WorkflowActions,
"filter_summary": filterSummary,
},
})
}
}

View File

@ -1433,6 +1433,7 @@ func registerWebRoutes(m *web.Router) {
m.Group("/{id}/workflows", func() {
m.Get("", projects.Workflows)
m.Get("/{workflow_id}", projects.Workflows)
m.Post("/{workflow_id}", web.Bind(projects.WorkflowsPostForm{}), projects.WorkflowsPost)
})
m.Group("", func() { //nolint:dupl // duplicates lines 1034-1054
m.Get("/new", repo.RenderNewProject)

View File

@ -44,18 +44,18 @@ func (m *workflowNotifier) NewIssue(ctx context.Context, issue *issues_model.Iss
return
}
eventWorkflows, err := project_model.FindWorkflowEvents(ctx, issue.Project.ID)
workflows, err := project_model.FindWorkflowsByProjectID(ctx, issue.Project.ID)
if err != nil {
log.Error("NewIssue: FindWorkflowEvents: %v", err)
log.Error("NewIssue: FindWorkflowsByProjectID: %v", err)
return
}
workflow := eventWorkflows[project_model.WorkflowEventItemAddedToProject]
if workflow == nil {
return
// Find workflows for the ItemAddedToProject event
for _, workflow := range workflows {
if workflow.WorkflowEvent == project_model.WorkflowEventItemAddedToProject {
fireIssueWorkflow(ctx, workflow, issue)
}
}
fireIssueWorkflow(ctx, workflow, issue)
}
func (m *workflowNotifier) IssueChangeStatus(ctx context.Context, doer *user_model.User, commitID string, issue *issues_model.Issue, actionComment *issues_model.Comment, isClosed bool) {
@ -71,19 +71,19 @@ func (m *workflowNotifier) IssueChangeStatus(ctx context.Context, doer *user_mod
return
}
eventWorkflows, err := project_model.FindWorkflowEvents(ctx, issue.Project.ID)
workflows, err := project_model.FindWorkflowsByProjectID(ctx, issue.Project.ID)
if err != nil {
log.Error("NewIssue: FindWorkflowEvents: %v", err)
log.Error("IssueChangeStatus: FindWorkflowsByProjectID: %v", err)
return
}
workflowEvent := util.Iif(isClosed, project_model.WorkflowEventItemClosed, project_model.WorkflowEventItemReopened)
workflow := eventWorkflows[workflowEvent]
if workflow == nil {
return
// Find workflows for the specific event
for _, workflow := range workflows {
if workflow.WorkflowEvent == workflowEvent {
fireIssueWorkflow(ctx, workflow, issue)
}
}
fireIssueWorkflow(ctx, workflow, issue)
}
func fireIssueWorkflow(ctx context.Context, workflow *project_model.Workflow, issue *issues_model.Issue) {

View File

@ -1,5 +1,5 @@
<script lang="ts" setup>
import {onMounted, useTemplateRef} from 'vue';
import {onMounted, useTemplateRef, computed, ref} from 'vue';
import {createWorkflowStore} from './WorkflowStore.ts';
import {svg} from '../../svg.ts';
@ -13,6 +13,13 @@ const props = defineProps({
const store = createWorkflowStore(props);
const selectWorkflowEvent = (event) => {
// Toggle selection - if already selected, deselect
if (store.selectedItem === event.event_id) {
store.selectedItem = null;
store.selectedWorkflow = null;
return;
}
store.selectedItem = event.event_id;
store.selectedWorkflow = event;
store.loadWorkflowData(event.event_id);
@ -24,28 +31,139 @@ const selectWorkflowEvent = (event) => {
const saveWorkflow = async () => {
await store.saveWorkflow();
};
const resetWorkflow = () => {
store.resetWorkflowData();
// After saving, refresh the list to show the new workflow
store.workflowEvents = await store.loadEvents();
};
const isWorkflowConfigured = (event) => {
// Check if the event_id is a number (saved workflow ID) vs UUID (unconfigured)
// If it's a number, it means the workflow has been saved to database
return !isNaN(parseInt(event.event_id));
return !Number.isNaN(parseInt(event.event_id));
};
// Get flat list of all workflows - directly use backend data
const workflowList = computed(() => {
return store.workflowEvents.map(workflow => ({
...workflow,
isConfigured: isWorkflowConfigured(workflow),
base_event_type: workflow.event_id.includes('-') ? workflow.event_id : workflow.event_id
}));
});
const createNewWorkflow = (baseEventType, capabilities, displayName) => {
const tempId = `new-${baseEventType}-${Date.now()}`;
const newWorkflow = {
id: 0,
event_id: tempId,
display_name: displayName,
capabilities: capabilities,
filters: [],
actions: [],
filter_summary: '',
base_event_type: baseEventType,
};
store.selectedWorkflow = newWorkflow;
store.selectedItem = tempId;
store.resetWorkflowData();
};
const cloneWorkflow = (sourceWorkflow) => {
const tempId = `clone-${sourceWorkflow.base_event_type || sourceWorkflow.workflow_event}-${Date.now()}`;
const clonedWorkflow = {
id: 0,
event_id: tempId,
display_name: sourceWorkflow.display_name.split(' (')[0], // Remove filter suffix
capabilities: sourceWorkflow.capabilities,
filters: [...(sourceWorkflow.filters || [])],
actions: [...(sourceWorkflow.actions || [])],
filter_summary: '',
base_event_type: sourceWorkflow.base_event_type || sourceWorkflow.workflow_event,
};
store.selectedWorkflow = clonedWorkflow;
store.selectedItem = tempId;
// Load the source workflow's data into the form
store.loadWorkflowData(sourceWorkflow.event_id);
};
const selectWorkflowItem = (item) => {
if (item.isConfigured) {
// This is a configured workflow, select it
selectWorkflowEvent(item);
} else {
// This is an unconfigured event, create new workflow
createNewWorkflow(item.base_event_type, item.capabilities, item.display_name);
}
};
const hasAvailableFilters = computed(() => {
return store.selectedWorkflow?.capabilities?.available_filters?.length > 0;
});
const hasFilter = (filterType) => {
return store.selectedWorkflow?.capabilities?.available_filters?.includes(filterType);
};
const hasAction = (actionType) => {
return store.selectedWorkflow?.capabilities?.available_actions?.includes(actionType);
};
const getActionsSummary = (workflow) => {
if (!workflow.actions || workflow.actions.length === 0) {
return '';
}
const actions = [];
for (const action of workflow.actions) {
if (action.action_type === 'column') {
const column = store.projectColumns.find((c) => c.id === action.action_value);
if (column) {
actions.push(`Move to "${column.title}"`);
}
} else if (action.action_type === 'label') {
const label = store.projectLabels.find((l) => l.id === action.action_value);
if (label) {
actions.push(`Add label "${label.name}"`);
}
} else if (action.action_type === 'close') {
actions.push('Close issue');
}
}
return actions.join(', ');
};
onMounted(async () => {
// Load all necessary data
store.workflowEvents = await store.loadEvents();
await store.loadProjectColumns();
await store.loadProjectLabels();
// Set initial selected workflow if eventID is provided
// Auto-select logic
if (props.eventID) {
// If eventID is provided in URL, select that specific workflow
const selectedEvent = store.workflowEvents.find((e) => e.event_id === props.eventID);
if (selectedEvent) {
store.selectedItem = props.eventID;
store.selectedWorkflow = selectedEvent;
await store.loadWorkflowData(props.eventID);
}
} else {
// Auto-select first configured workflow, or first item if none configured
const items = workflowList.value;
if (items.length > 0) {
// Find first configured workflow
let firstConfigured = items.find(item => item.isConfigured);
if (firstConfigured) {
// Select first configured workflow
selectWorkflowItem(firstConfigured);
} else {
// No configured workflows, select first item
selectWorkflowItem(items[0]);
}
}
}
@ -64,87 +182,45 @@ onMounted(async () => {
<template>
<div ref="elRoot" class="workflow-container">
<!-- Left Sidebar - Workflow List -->
<div class="workflow-sidebar">
<div class="ui fluid vertical menu">
<a
v-for="event in store.workflowEvents"
:key="event.event_id"
class="item"
:class="{ active: store.selectedItem === event.event_id }"
:href="`${props.projectLink}/workflows/${event.event_id}`"
@click.prevent="selectWorkflowEvent(event)"
>
<span class="workflow-status" :class="{ configured: isWorkflowConfigured(event) }">
<span v-if="isWorkflowConfigured(event)" v-html="svg('octicon-dot-fill')" class="status-icon configured"></span>
<span v-else class="status-icon unconfigured"></span>
</span>
{{ event.display_name }}
</a>
<div class="sidebar-header">
<h3>Project Workflows</h3>
</div>
</div>
<div class="workflow-main">
<div class="workflow-content">
<div v-if="!store.selectedWorkflow" class="ui placeholder segment">
<div class="ui icon header">
<i class="settings icon"/>
Select a workflow event to configure
</div>
</div>
<div v-else class="workflow-editor">
<div class="ui header">
<i class="settings icon"/>
{{ store.selectedWorkflow.display_name }}
</div>
<div class="workflow-form">
<div class="ui form">
<div class="field">
<label>When</label>
<div class="ui segment">
<div class="description">
This workflow will run when: <strong>{{ store.selectedWorkflow.display_name }}</strong>
</div>
</div>
<div class="sidebar-content">
<!-- Flat Workflow List -->
<div class="workflow-items">
<div
v-for="item in workflowList"
:key="item.event_id"
class="workflow-item"
:class="{ active: store.selectedItem === item.event_id }"
@click="selectWorkflowItem(item)"
>
<div class="workflow-content">
<div class="workflow-info">
<span class="status-indicator">
<span v-html="svg('octicon-dot-fill')"
:class="item.isConfigured ? 'status-active' : 'status-inactive'"/>
</span>
<div class="workflow-title">{{ item.display_name }}</div>
</div>
<div class="field">
<label>Filters</label>
<div class="ui segment">
<div class="field">
<label>Apply to</label>
<select class="ui dropdown" v-model="store.workflowFilters.scope">
<option value="">Issues And Pull Requests</option>
<option value="issue">Issues</option>
<option value="pull_request">Pull requests</option>
</select>
</div>
</div>
</div>
<div class="field">
<label>Actions</label>
<div class="ui segment">
<div class="field">
<label>Move to column</label>
<select class="ui dropdown" v-model="store.workflowActions.column">
<option value="">Select column...</option>
<option v-for="column in store.projectColumns" :key="column.id" :value="column.id">
{{ column.title }}
</option>
</select>
</div>
<div class="field">
<div class="ui checkbox">
<input type="checkbox" v-model="store.workflowActions.closeIssue" id="close-issue">
<label for="close-issue">Close issue</label>
</div>
</div>
</div>
</div>
<div class="actions">
<button class="ui primary button" @click="saveWorkflow" :loading="store.saving">
Save workflow
<div class="workflow-actions">
<button
class="ui tiny basic button"
@click.stop="createNewWorkflow(item.base_event_type, item.capabilities, item.display_name.split('(')[0])"
:title="item.isConfigured ? 'Create another workflow' : 'Create workflow'"
>
<i class="plus icon"/>
</button>
<button
v-if="item.isConfigured"
class="ui tiny basic button clone-btn"
@click.stop="cloneWorkflow(item)"
title="Clone this workflow"
>
<i class="copy icon"/>
</button>
</div>
</div>
@ -152,107 +228,386 @@ onMounted(async () => {
</div>
</div>
</div>
<!-- Right Main Content - Editor -->
<div class="workflow-main">
<!-- Default State -->
<div v-if="!store.selectedWorkflow" class="workflow-placeholder">
<div class="placeholder-content">
<div class="placeholder-icon">
<i class="huge settings icon"/>
</div>
<h3>Select a workflow to configure</h3>
<p>Choose an event from the left sidebar to create or configure workflows.</p>
</div>
</div>
<!-- Workflow Editor -->
<div v-else class="workflow-editor">
<div class="editor-header">
<div class="editor-title">
<h2>
<i class="settings icon"/>
{{ store.selectedWorkflow.display_name }}
</h2>
<p>Configure automated actions for this workflow</p>
</div>
<div class="editor-actions-header">
<button class="ui basic button" @click="store.selectedWorkflow = null; store.selectedItem = null;">
<i class="times icon"/>
Close
</button>
</div>
</div>
<div class="editor-content">
<div class="ui form">
<div class="field">
<label>When</label>
<div class="ui segment">
<div class="description">
This workflow will run when: <strong>{{ store.selectedWorkflow.display_name }}</strong>
</div>
</div>
</div>
<!-- Filters Section -->
<div class="field" v-if="hasAvailableFilters">
<label>Filters</label>
<div class="ui segment">
<div class="field" v-if="hasFilter('scope')">
<label>Apply to</label>
<select class="ui dropdown" v-model="store.workflowFilters.scope">
<option value="">Issues And Pull Requests</option>
<option value="issue">Issues</option>
<option value="pull_request">Pull requests</option>
</select>
</div>
</div>
</div>
<!-- Actions Section -->
<div class="field">
<label>Actions</label>
<div class="ui segment">
<div class="field" v-if="hasAction('column')">
<label>Move to column</label>
<select class="ui dropdown" v-model="store.workflowActions.column">
<option value="">Select column...</option>
<option v-for="column in store.projectColumns" :key="column.id" :value="column.id">
{{ column.title }}
</option>
</select>
</div>
<div class="field" v-if="hasAction('label')">
<label>Add labels</label>
<select class="ui multiple dropdown" v-model="store.workflowActions.labels">
<option value="">Select labels...</option>
<option v-for="label in store.projectLabels" :key="label.id" :value="label.id">
{{ label.name }}
</option>
</select>
</div>
<div class="field" v-if="hasAction('close')">
<div class="ui checkbox">
<input type="checkbox" v-model="store.workflowActions.closeIssue" id="close-issue">
<label for="close-issue">Close issue</label>
</div>
</div>
</div>
</div>
<div class="editor-actions">
<button class="ui primary button" @click="saveWorkflow" :class="{ loading: store.saving }">
<i class="save icon"/>
Save Workflow
</button>
<button class="ui basic button" @click="store.selectedWorkflow = null; store.selectedItem = null;">
<i class="times icon"/>
Cancel
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
/* Main Layout */
.workflow-container {
display: flex;
gap: 1rem;
width: 100%;
min-height: 500px;
height: calc(100vh - 200px);
min-height: 600px;
border: 1px solid #e1e4e8;
border-radius: 8px;
overflow: hidden;
background: white;
}
.workflow-sidebar {
width: 300px;
width: 350px;
flex-shrink: 0;
background: #f6f8fa;
border-right: 1px solid #e1e4e8;
display: flex;
flex-direction: column;
}
.workflow-main {
flex: 1;
min-width: 0;
background: white;
display: flex;
flex-direction: column;
}
/* Sidebar */
.sidebar-header {
padding: 1rem 1.25rem;
border-bottom: 1px solid #e1e4e8;
background: #f6f8fa;
}
.sidebar-header h3 {
margin: 0;
color: #24292e;
font-size: 1.1rem;
font-weight: 600;
}
.sidebar-content {
flex: 1;
padding: 1rem;
overflow-y: auto;
}
/* Workflow Items */
.workflow-items {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.workflow-item {
padding: 0.75rem 1rem;
cursor: pointer;
transition: all 0.2s ease;
border-radius: 6px;
margin-bottom: 0.25rem;
}
.workflow-item:hover {
background: #f6f8fa;
}
.workflow-item.active {
background: #f1f8ff;
border-left: 3px solid #0366d6;
}
.workflow-content {
padding: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
}
.workflow-info {
flex: 1;
display: flex;
align-items: center;
gap: 0.5rem;
}
.workflow-title {
font-weight: 500;
color: #24292e;
font-size: 0.9rem;
line-height: 1.3;
}
.workflow-actions {
display: flex;
gap: 0.25rem;
}
.clone-btn {
opacity: 0;
transition: opacity 0.2s ease;
flex-shrink: 0;
padding: 0.25rem 0.5rem !important;
font-size: 0.75rem;
border: none !important;
background: transparent !important;
}
.workflow-item:hover .clone-btn {
opacity: 1;
}
.status-indicator .status-active {
color: #28a745;
font-size: 0.75rem;
}
.status-indicator .status-inactive {
color: #d1d5da;
font-size: 0.75rem;
}
/* Main Content Area */
.workflow-placeholder {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
}
.placeholder-content {
text-align: center;
max-width: 400px;
}
.placeholder-icon {
margin-bottom: 1.5rem;
color: #d1d5da;
}
.placeholder-content h3 {
color: #24292e;
margin-bottom: 0.5rem;
font-weight: 600;
}
.placeholder-content p {
color: #586069;
margin-bottom: 2rem;
line-height: 1.5;
}
/* Editor */
.workflow-editor {
margin-top: 1rem;
flex: 1;
display: flex;
flex-direction: column;
}
.workflow-form .field {
.editor-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 1.5rem;
border-bottom: 1px solid #e1e4e8;
background: #fafbfc;
}
.editor-title h2 {
margin: 0 0 0.25rem 0;
color: #24292e;
font-size: 1.25rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 0.5rem;
}
.editor-title p {
margin: 0;
color: #586069;
font-size: 0.9rem;
}
.editor-actions-header {
flex-shrink: 0;
}
.editor-content {
flex: 1;
padding: 1.5rem;
overflow-y: auto;
}
.editor-content .field {
margin-bottom: 1.5rem;
}
.workflow-form .field label {
font-weight: bold;
.editor-content .field label {
font-weight: 600;
color: #24292e;
margin-bottom: 0.5rem;
display: block;
}
.workflow-form .ui.segment {
.editor-content .ui.segment {
background: #fafbfc;
border: 1px solid #e1e4e8;
padding: 1rem;
margin-bottom: 0.5rem;
}
.workflow-form .description {
color: #666;
font-style: italic;
.editor-content .description {
color: #586069;
font-size: 0.9rem;
}
.workflow-form .actions {
.editor-actions {
display: flex;
gap: 0.5rem;
margin-top: 2rem;
padding-top: 1rem;
border-top: 1px solid #ddd;
padding-top: 1.5rem;
border-top: 1px solid #e1e4e8;
}
.ui.placeholder.segment {
text-align: center;
padding: 3rem;
}
.ui.vertical.menu .item.active {
background-color: #f0f0f0;
font-weight: bold;
}
.workflow-status {
display: inline-flex;
align-items: center;
margin-right: 0.5rem;
}
.status-icon {
display: inline-block;
width: 8px;
height: 8px;
margin-right: 0.25rem;
}
.status-icon.configured {
color: #28a745;
}
.status-icon.configured svg {
width: 8px;
height: 8px;
fill: currentColor;
}
.status-icon.unconfigured {
border: 1px solid #6c757d;
border-radius: 50%;
background-color: transparent;
}
/* Responsive */
@media (max-width: 768px) {
.workflow-container {
flex-direction: column;
height: auto;
}
.workflow-sidebar {
width: 100%;
max-height: 40vh;
border-right: none;
border-bottom: 1px solid #e1e4e8;
}
.editor-header {
flex-direction: column;
gap: 1rem;
align-items: stretch;
}
.editor-content {
padding: 1rem;
}
.editor-actions {
flex-direction: column;
}
}
@media (max-width: 480px) {
.sidebar-header {
flex-direction: column;
gap: 0.5rem;
align-items: stretch;
}
.workflow-item {
padding: 0.75rem;
}
.editor-actions button {
width: 100%;
}
}
</style>

View File

@ -7,7 +7,10 @@ export function createWorkflowStore(props: { projectLink: string, eventID: strin
selectedItem: props.eventID,
selectedWorkflow: null,
projectColumns: [],
projectLabels: [], // Add labels data
saving: false,
showCreateDialog: false, // For create workflow dialog
selectedEventType: null, // For workflow creation
workflowFilters: {
scope: '', // 'issue', 'pull_request', or ''
@ -15,6 +18,7 @@ export function createWorkflowStore(props: { projectLink: string, eventID: strin
workflowActions: {
column: '', // column ID to move to
labels: [], // selected label IDs
closeIssue: false,
},
@ -35,24 +39,59 @@ export function createWorkflowStore(props: { projectLink: string, eventID: strin
},
async loadWorkflowData(eventId: string) {
// Load project columns for the dropdown
// Load project columns and labels for the dropdowns
await store.loadProjectColumns();
await store.loadProjectLabels();
// Find the workflow from existing workflowEvents
const workflow = store.workflowEvents.find((e) => e.event_id === eventId);
if (workflow && workflow.filters && workflow.actions) {
// Load existing configuration from the workflow data
store.workflowFilters = workflow.filters || {scope: ''};
store.workflowActions = workflow.actions || {column: '', closeIssue: false};
// Convert backend filter format to frontend format
const frontendFilters = {scope: ''};
if (workflow.filters && Array.isArray(workflow.filters)) {
for (const filter of workflow.filters) {
if (filter.type === 'scope') {
frontendFilters.scope = filter.value;
}
}
}
// Convert backend action format to frontend format
const frontendActions = {column: '', labels: [], closeIssue: false};
if (workflow.actions && Array.isArray(workflow.actions)) {
for (const action of workflow.actions) {
if (action.action_type === 'column') {
frontendActions.column = action.action_value;
} else if (action.action_type === 'label') {
frontendActions.labels.push(action.action_value);
} else if (action.action_type === 'close') {
frontendActions.closeIssue = action.action_value === 'true';
}
}
}
store.workflowFilters = frontendFilters;
store.workflowActions = frontendActions;
} else {
// Reset to defaults for new workflow
store.resetWorkflowData();
}
},
async loadProjectLabels() {
try {
const response = await GET(`${props.projectLink}/workflows/labels`);
store.projectLabels = await response.json();
} catch (error) {
console.error('Failed to load project labels:', error);
store.projectLabels = [];
}
},
resetWorkflowData() {
store.workflowFilters = {scope: ''};
store.workflowActions = {column: '', closeIssue: false};
store.workflowActions = {column: '', labels: [], closeIssue: false};
},
async saveWorkflow() {
@ -60,21 +99,92 @@ export function createWorkflowStore(props: { projectLink: string, eventID: strin
store.saving = true;
try {
const workflowData = {
event_id: store.selectedWorkflow.event_id,
filters: store.workflowFilters,
actions: store.workflowActions,
};
// For new workflows, use the base event type
const eventId = store.selectedWorkflow.base_event_type || store.selectedWorkflow.event_id;
const response = await POST(`${props.projectLink}/workflows/${store.selectedWorkflow.event_id}`, {
data: workflowData,
// Convert frontend data format to backend form format
const formData = new FormData();
formData.append('event_id', eventId);
// Add filters as form fields
for (const [key, value] of Object.entries(store.workflowFilters)) {
if (value !== '') {
formData.append(`filters[${key}]`, value);
}
}
// Add actions as form fields
for (const [key, value] of Object.entries(store.workflowActions)) {
if (key === 'labels' && Array.isArray(value)) {
// Handle label array
for (const labelId of value) {
if (labelId !== '') {
formData.append(`actions[labels][]`, labelId);
}
}
} else if (key === 'closeIssue') {
// Handle boolean
formData.append(`actions[${key}]`, value.toString());
} else if (value !== '') {
// Handle string fields
formData.append(`actions[${key}]`, value);
}
}
console.log('Saving workflow with FormData');
console.log('URL:', `${props.projectLink}/workflows/${eventId}`);
// Log form data entries
for (const [key, value] of formData.entries()) {
console.log(`${key}: ${value}`);
}
const response = await POST(`${props.projectLink}/workflows/${eventId}`, {
data: formData,
});
console.log('Response status:', response.status);
console.log('Response headers:', response.headers);
if (!response.ok) {
throw new Error('Failed to save workflow');
const errorText = await response.text();
console.error('Response error:', errorText);
alert(`Failed to save workflow: ${response.status} ${response.statusText}\n${errorText}`);
return;
}
const result = await response.json();
console.log('Response result:', result);
if (result.success && result.workflow) {
// For new workflows, add to the store
if (store.selectedWorkflow.id === 0 || store.selectedWorkflow.event_id.startsWith('new-')) {
store.workflowEvents.push(result.workflow);
// Update URL to use the new workflow ID
const newUrl = `${props.projectLink}/workflows/${result.workflow.event_id}`;
window.history.replaceState({eventId: result.workflow.event_id}, '', newUrl);
} else {
// Update existing workflow
const existingIndex = store.workflowEvents.findIndex((e) => e.event_id === store.selectedWorkflow.event_id);
if (existingIndex >= 0) {
store.workflowEvents[existingIndex] = {
...store.workflowEvents[existingIndex],
...result.workflow,
};
}
}
// Update selected workflow and selectedItem
store.selectedWorkflow = result.workflow;
store.selectedItem = result.workflow.event_id;
alert('Workflow saved successfully!');
} else {
console.error('Unexpected response format:', result);
alert('Failed to save workflow: Unexpected response format');
}
} catch (error) {
console.error('Error saving workflow:', error);
alert(`Error saving workflow: ${error.message}`);
} finally {
store.saving = false;
}