0
0
mirror of https://github.com/go-gitea/gitea.git synced 2026-05-18 22:57:44 +02:00

Support add labels and remove labels for issue's project column changed event

This commit is contained in:
Lunny Xiao 2025-10-23 00:47:34 -07:00
parent 836bf98507
commit 74fc30ff71
No known key found for this signature in database
GPG Key ID: C3B7C91B632F738A
9 changed files with 392 additions and 100 deletions

View File

@ -91,6 +91,7 @@ type WorkflowFilterType string
const ( const (
WorkflowFilterTypeIssueType WorkflowFilterType = "issue_type" // issue, pull_request, etc. WorkflowFilterTypeIssueType WorkflowFilterType = "issue_type" // issue, pull_request, etc.
WorkflowFilterTypeColumn WorkflowFilterType = "column" // target column for item_column_changed event
) )
type WorkflowFilter struct { type WorkflowFilter struct {
@ -138,8 +139,8 @@ func GetWorkflowEventCapabilities() map[WorkflowEvent]WorkflowEventCapabilities
AvailableActions: []WorkflowActionType{WorkflowActionTypeColumn, WorkflowActionTypeAddLabels, WorkflowActionTypeRemoveLabels}, AvailableActions: []WorkflowActionType{WorkflowActionTypeColumn, WorkflowActionTypeAddLabels, WorkflowActionTypeRemoveLabels},
}, },
WorkflowEventItemColumnChanged: { WorkflowEventItemColumnChanged: {
AvailableFilters: []WorkflowFilterType{WorkflowFilterTypeIssueType}, AvailableFilters: []WorkflowFilterType{WorkflowFilterTypeIssueType, WorkflowFilterTypeColumn},
AvailableActions: []WorkflowActionType{WorkflowActionTypeColumn, WorkflowActionTypeAddLabels, WorkflowActionTypeRemoveLabels, WorkflowActionTypeClose}, AvailableActions: []WorkflowActionType{WorkflowActionTypeAddLabels, WorkflowActionTypeRemoveLabels, WorkflowActionTypeClose},
}, },
WorkflowEventCodeChangesRequested: { WorkflowEventCodeChangesRequested: {
AvailableFilters: []WorkflowFilterType{}, // only applies to pull requests AvailableFilters: []WorkflowFilterType{}, // only applies to pull requests

View File

@ -75,6 +75,7 @@ func convertFormToActions(formActions map[string]any) []project_model.WorkflowAc
} }
} }
case "add_labels": case "add_labels":
// Handle both []string and []interface{} from JSON unmarshaling
if labels, ok := value.([]string); ok && len(labels) > 0 { if labels, ok := value.([]string); ok && len(labels) > 0 {
for _, label := range labels { for _, label := range labels {
if label != "" { if label != "" {
@ -84,8 +85,18 @@ func convertFormToActions(formActions map[string]any) []project_model.WorkflowAc
}) })
} }
} }
} else if labelInterfaces, ok := value.([]interface{}); ok && len(labelInterfaces) > 0 {
for _, labelInterface := range labelInterfaces {
if label, ok := labelInterface.(string); ok && label != "" {
actions = append(actions, project_model.WorkflowAction{
Type: project_model.WorkflowActionTypeAddLabels,
Value: label,
})
}
}
} }
case "remove_labels": case "remove_labels":
// Handle both []string and []interface{} from JSON unmarshaling
if labels, ok := value.([]string); ok && len(labels) > 0 { if labels, ok := value.([]string); ok && len(labels) > 0 {
for _, label := range labels { for _, label := range labels {
if label != "" { if label != "" {
@ -95,6 +106,15 @@ func convertFormToActions(formActions map[string]any) []project_model.WorkflowAc
}) })
} }
} }
} else if labelInterfaces, ok := value.([]interface{}); ok && len(labelInterfaces) > 0 {
for _, labelInterface := range labelInterfaces {
if label, ok := labelInterface.(string); ok && label != "" {
actions = append(actions, project_model.WorkflowAction{
Type: project_model.WorkflowActionTypeRemoveLabels,
Value: label,
})
}
}
} }
case "closeIssue": case "closeIssue":
if boolValue, ok := value.(bool); ok && boolValue { if boolValue, ok := value.(bool); ok && boolValue {

View File

@ -43,6 +43,7 @@ type Notifier interface {
IssueChangeLabels(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, IssueChangeLabels(ctx context.Context, doer *user_model.User, issue *issues_model.Issue,
addedLabels, removedLabels []*issues_model.Label) addedLabels, removedLabels []*issues_model.Label)
IssueChangeProjects(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, newProject *project_model.Project) IssueChangeProjects(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, newProject *project_model.Project)
IssueChangeProjectColumn(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, newColumnID int64)
NewPullRequest(ctx context.Context, pr *issues_model.PullRequest, mentions []*user_model.User) NewPullRequest(ctx context.Context, pr *issues_model.PullRequest, mentions []*user_model.User)
MergePullRequest(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest) MergePullRequest(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest)

View File

@ -282,6 +282,12 @@ func IssueChangeProjects(ctx context.Context, doer *user_model.User, issue *issu
} }
} }
func IssueChangeProjectColumn(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, newColumnID int64) {
for _, notifier := range notifiers {
notifier.IssueChangeProjectColumn(ctx, doer, issue, newColumnID)
}
}
// CreateRepository notifies create repository to notifiers // CreateRepository notifies create repository to notifiers
func CreateRepository(ctx context.Context, doer, u *user_model.User, repo *repo_model.Repository) { func CreateRepository(ctx context.Context, doer, u *user_model.User, repo *repo_model.Repository) {
for _, notifier := range notifiers { for _, notifier := range notifiers {

View File

@ -147,6 +147,9 @@ func (*NullNotifier) IssueChangeLabels(ctx context.Context, doer *user_model.Use
func (*NullNotifier) IssueChangeProjects(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, newProject *project_model.Project) { func (*NullNotifier) IssueChangeProjects(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, newProject *project_model.Project) {
} }
func (*NullNotifier) IssueChangeProjectColumn(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, newColumnID int64) {
}
// CreateRepository places a place holder function // CreateRepository places a place holder function
func (*NullNotifier) CreateRepository(ctx context.Context, doer, u *user_model.User, repo *repo_model.Repository) { func (*NullNotifier) CreateRepository(ctx context.Context, doer, u *user_model.User, repo *repo_model.Repository) {
} }

View File

@ -12,30 +12,32 @@ import (
project_model "code.gitea.io/gitea/models/project" project_model "code.gitea.io/gitea/models/project"
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/optional"
"code.gitea.io/gitea/services/notify"
) )
// MoveIssuesOnProjectColumn moves or keeps issues in a column and sorts them inside that column // MoveIssuesOnProjectColumn moves or keeps issues in a column and sorts them inside that column
func MoveIssuesOnProjectColumn(ctx context.Context, doer *user_model.User, column *project_model.Column, sortedIssueIDs map[int64]int64) error { func MoveIssuesOnProjectColumn(ctx context.Context, doer *user_model.User, column *project_model.Column, sortedIssueIDs map[int64]int64) error {
return db.WithTx(ctx, func(ctx context.Context) error { issueIDs := make([]int64, 0, len(sortedIssueIDs))
issueIDs := make([]int64, 0, len(sortedIssueIDs)) for _, issueID := range sortedIssueIDs {
for _, issueID := range sortedIssueIDs { issueIDs = append(issueIDs, issueID)
issueIDs = append(issueIDs, issueID) }
} count, err := db.GetEngine(ctx).
count, err := db.GetEngine(ctx). Where("project_id=?", column.ProjectID).
Where("project_id=?", column.ProjectID). In("issue_id", issueIDs).
In("issue_id", issueIDs). Count(new(project_model.ProjectIssue))
Count(new(project_model.ProjectIssue)) if err != nil {
if err != nil { return err
return err }
} if int(count) != len(sortedIssueIDs) {
if int(count) != len(sortedIssueIDs) { return errors.New("all issues have to be added to a project first")
return errors.New("all issues have to be added to a project first") }
}
issues, err := issues_model.GetIssuesByIDs(ctx, issueIDs) issues, err := issues_model.GetIssuesByIDs(ctx, issueIDs)
if err != nil { if err != nil {
return err return err
} }
if err := db.WithTx(ctx, func(ctx context.Context) error {
if _, err := issues.LoadRepositories(ctx); err != nil { if _, err := issues.LoadRepositories(ctx); err != nil {
return err return err
} }
@ -83,7 +85,15 @@ func MoveIssuesOnProjectColumn(ctx context.Context, doer *user_model.User, colum
} }
} }
return nil return nil
}) }); err != nil {
return err
}
for _, issue := range issues {
notify.IssueChangeProjectColumn(ctx, doer, issue, column.ID)
}
return nil
} }
// LoadIssuesFromProject load issues assigned to each project column inside the given project // LoadIssuesFromProject load issues assigned to each project column inside the given project
@ -207,7 +217,7 @@ func LoadIssueNumbersForProject(ctx context.Context, project *project_model.Proj
} }
func MoveIssueToAnotherColumn(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, column *project_model.Column) error { func MoveIssueToAnotherColumn(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, column *project_model.Column) error {
return db.WithTx(ctx, func(ctx context.Context) error { if err := db.WithTx(ctx, func(ctx context.Context) error {
if err := project_model.MoveIssueToAnotherColumn(ctx, issue.ID, column); err != nil { if err := project_model.MoveIssueToAnotherColumn(ctx, issue.ID, column); err != nil {
return err return err
} }
@ -230,5 +240,10 @@ func MoveIssueToAnotherColumn(ctx context.Context, doer *user_model.User, issue
return err return err
} }
return nil return nil
}) }); err != nil {
return err
}
notify.IssueChangeProjectColumn(ctx, doer, issue, column.ID)
return nil
} }

View File

@ -5,6 +5,7 @@ package projects
import ( import (
"context" "context"
"errors"
"slices" "slices"
"strconv" "strconv"
"strings" "strings"
@ -130,6 +131,40 @@ func (*workflowNotifier) IssueChangeProjects(ctx context.Context, doer *user_mod
} }
} }
func (*workflowNotifier) IssueChangeProjectColumn(ctx context.Context, doer *user_model.User, issue *issues_model.Issue, newColumnID int64) {
if err := issue.LoadRepo(ctx); err != nil {
log.Error("IssueChangeStatus: LoadRepo: %v", err)
return
}
if err := issue.LoadProject(ctx); err != nil {
log.Error("NewIssue: LoadProject: %v", err)
return
}
newColumn, err := project_model.GetColumn(ctx, newColumnID)
if err != nil {
log.Error("IssueChangeProjectColumn: GetColumn: %v", err)
return
}
if issue.Project == nil || issue.Project.ID != newColumn.ProjectID {
return
}
workflows, err := project_model.FindWorkflowsByProjectID(ctx, issue.Project.ID)
if err != nil {
log.Error("IssueChangeStatus: FindWorkflowsByProjectID: %v", err)
return
}
// Find workflows for the ItemOpened event
for _, workflow := range workflows {
if workflow.WorkflowEvent == project_model.WorkflowEventItemColumnChanged {
fireIssueWorkflow(ctx, workflow, issue)
}
}
}
func (*workflowNotifier) MergePullRequest(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest) { func (*workflowNotifier) MergePullRequest(ctx context.Context, doer *user_model.User, pr *issues_model.PullRequest) {
if err := pr.LoadIssue(ctx); err != nil { if err := pr.LoadIssue(ctx); err != nil {
log.Error("NewIssue: LoadIssue: %v", err) log.Error("NewIssue: LoadIssue: %v", err)
@ -211,6 +246,20 @@ func fireIssueWorkflow(ctx context.Context, workflow *project_model.Workflow, is
if !(slices.Contains(values, "issue") && !issue.IsPull) || (slices.Contains(values, "pull") && issue.IsPull) { if !(slices.Contains(values, "issue") && !issue.IsPull) || (slices.Contains(values, "pull") && issue.IsPull) {
return return
} }
case project_model.WorkflowFilterTypeColumn:
columnID, _ := strconv.ParseInt(filter.Value, 10, 64)
if columnID == 0 {
log.Error("Invalid column ID: %s", filter.Value)
return
}
issueProjectColumnID, err := issue.ProjectColumnID(ctx)
if err != nil {
log.Error("Issue.ProjectColumnID: %v", err)
return
}
if issueProjectColumnID != columnID {
return
}
default: default:
log.Error("Unsupported filter type: %s", filter.Type) log.Error("Unsupported filter type: %s", filter.Type)
return return
@ -235,9 +284,37 @@ func fireIssueWorkflow(ctx context.Context, workflow *project_model.Workflow, is
continue continue
} }
case project_model.WorkflowActionTypeAddLabels: case project_model.WorkflowActionTypeAddLabels:
// TODO: implement adding labels labelID, _ := strconv.ParseInt(action.Value, 10, 64)
if labelID == 0 {
log.Error("Invalid label ID: %s", action.Value)
continue
}
label, err := issues_model.GetLabelByID(ctx, labelID)
if err != nil {
log.Error("GetLabelByID: %v", err)
continue
}
if err := issue_service.AddLabel(ctx, issue, user_model.NewProjectWorkflowsUser(), label); err != nil {
log.Error("AddLabels: %v", err)
continue
}
case project_model.WorkflowActionTypeRemoveLabels: case project_model.WorkflowActionTypeRemoveLabels:
// TODO: implement removing labels labelID, _ := strconv.ParseInt(action.Value, 10, 64)
if labelID == 0 {
log.Error("Invalid label ID: %s", action.Value)
continue
}
label, err := issues_model.GetLabelByID(ctx, labelID)
if err != nil {
log.Error("GetLabelByID: %v", err)
continue
}
if err := issue_service.RemoveLabel(ctx, issue, user_model.NewProjectWorkflowsUser(), label); err != nil {
if !issues_model.IsErrRepoLabelNotExist(err) {
log.Error("RemoveLabels: %v", err)
}
continue
}
case project_model.WorkflowActionTypeClose: case project_model.WorkflowActionTypeClose:
if err := issue_service.CloseIssue(ctx, issue, user_model.NewProjectWorkflowsUser(), ""); err != nil { if err := issue_service.CloseIssue(ctx, issue, user_model.NewProjectWorkflowsUser(), ""); err != nil {
log.Error("CloseIssue: %v", err) log.Error("CloseIssue: %v", err)

View File

@ -1,8 +1,9 @@
<script lang="ts" setup> <script lang="ts" setup>
import {onMounted, onUnmounted, useTemplateRef, computed, ref, nextTick} from 'vue'; import {onMounted, onUnmounted, useTemplateRef, computed, ref, nextTick, watch} from 'vue';
import {createWorkflowStore} from './WorkflowStore.ts'; import {createWorkflowStore} from './WorkflowStore.ts';
import {svg} from '../../svg.ts'; import {svg} from '../../svg.ts';
import {confirmModal} from '../../features/comp/ConfirmModal.ts'; import {confirmModal} from '../../features/comp/ConfirmModal.ts';
import {fomanticQuery} from '../../modules/fomantic/base.ts';
const elRoot = useTemplateRef('elRoot'); const elRoot = useTemplateRef('elRoot');
@ -48,7 +49,7 @@ const toggleEditMode = () => {
if (previousSelection.value) { if (previousSelection.value) {
// If there was a previous selection, return to it // If there was a previous selection, return to it
if (store.selectedWorkflow && store.selectedWorkflow.id === 0) { if (store.selectedWorkflow && store.selectedWorkflow.id === 0) {
// Remove temporary cloned workflow from list // Remove temporary unsaved workflow from list
const tempIndex = store.workflowEvents.findIndex((w) => const tempIndex = store.workflowEvents.findIndex((w) =>
w.event_id === store.selectedWorkflow.event_id, w.event_id === store.selectedWorkflow.event_id,
); );
@ -97,7 +98,7 @@ const deleteWorkflow = async () => {
const currentDisplayName = (store.selectedWorkflow.display_name || store.selectedWorkflow.workflow_event || store.selectedWorkflow.event_id) const currentDisplayName = (store.selectedWorkflow.display_name || store.selectedWorkflow.workflow_event || store.selectedWorkflow.event_id)
.replace(/\s*\([^)]*\)\s*/g, ''); .replace(/\s*\([^)]*\)\s*/g, '');
// If deleting a temporary workflow (new), just remove from list // If deleting a temporary workflow (unsaved), just remove from list
if (store.selectedWorkflow.id === 0) { if (store.selectedWorkflow.id === 0) {
const tempIndex = store.workflowEvents.findIndex((w) => const tempIndex = store.workflowEvents.findIndex((w) =>
w.event_id === store.selectedWorkflow.event_id, w.event_id === store.selectedWorkflow.event_id,
@ -331,6 +332,53 @@ const isItemSelected = (item) => {
return store.selectedItem === item.base_event_type; return store.selectedItem === item.base_event_type;
}; };
// Toggle label selection for add_labels or remove_labels
const toggleLabel = (actionType, labelId) => {
const labels = store.workflowActions[actionType];
const index = labels.indexOf(labelId);
if (index > -1) {
labels.splice(index, 1);
} else {
labels.push(labelId);
}
};
// Calculate text color based on background color for better contrast
const getLabelTextColor = (hexColor) => {
if (!hexColor) return '#000';
// Remove # if present
const color = hexColor.replace('#', '');
// Convert to RGB
const r = parseInt(color.substring(0, 2), 16);
const g = parseInt(color.substring(2, 4), 16);
const b = parseInt(color.substring(4, 6), 16);
// Calculate relative luminance
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
// Return black for light backgrounds, white for dark backgrounds
return luminance > 0.5 ? '#000' : '#fff';
};
// Initialize Fomantic UI dropdowns for label selection
const initLabelDropdowns = async () => {
await nextTick();
const dropdowns = elRoot.value?.querySelectorAll('.ui.dropdown');
if (dropdowns) {
dropdowns.forEach((dropdown) => {
fomanticQuery(dropdown).dropdown({
action: 'nothing', // Don't hide on selection for multiple selection
fullTextSearch: true,
});
});
}
};
// Watch for edit mode changes to initialize dropdowns
watch(isInEditMode, async (newVal) => {
if (newVal) {
await initLabelDropdowns();
}
});
onMounted(async () => { onMounted(async () => {
// Load all necessary data // Load all necessary data
store.workflowEvents = await store.loadEvents(); store.workflowEvents = await store.loadEvents();
@ -518,28 +566,60 @@ onUnmounted(() => {
<p v-else>View workflow configuration</p> <p v-else>View workflow configuration</p>
</div> </div>
<div class="editor-actions-header"> <div class="editor-actions-header">
<!-- Edit/Cancel Button (only for configured workflows) --> <!-- Edit Mode Buttons -->
<button <template v-if="isInEditMode">
v-if="store.selectedWorkflow && store.selectedWorkflow.id > 0" <!-- Save Button -->
class="btn" <button
:class="isInEditMode ? 'btn-outline-secondary' : 'btn-primary'" class="btn btn-primary"
@click="toggleEditMode" @click="saveWorkflow"
> :disabled="store.saving"
<i :class="isInEditMode ? 'times icon' : 'edit icon'"/> >
{{ isInEditMode ? 'Cancel' : 'Edit' }} <i class="save icon"/>
</button> Save
</button>
<!-- Enable/Disable Button (only for configured workflows) --> <!-- Cancel Button -->
<button <button
v-if="store.selectedWorkflow && store.selectedWorkflow.id > 0 && !isInEditMode" class="btn btn-outline-secondary"
class="btn" @click="toggleEditMode"
:class="store.selectedWorkflow.enabled ? 'btn-outline-danger' : 'btn-success'" >
@click="toggleWorkflowStatus" <i class="times icon"/>
:title="store.selectedWorkflow.enabled ? 'Disable workflow' : 'Enable workflow'" Cancel
> </button>
<i :class="store.selectedWorkflow.enabled ? 'pause icon' : 'play icon'"/>
{{ store.selectedWorkflow.enabled ? 'Disable' : 'Enable' }} <!-- Delete Button (only for configured workflows) -->
</button> <button
v-if="store.selectedWorkflow && store.selectedWorkflow.id > 0"
class="btn btn-danger"
@click="deleteWorkflow"
>
<i class="trash icon"/>
Delete
</button>
</template>
<!-- View Mode Buttons (only for configured workflows) -->
<template v-else-if="store.selectedWorkflow && store.selectedWorkflow.id > 0">
<!-- Edit Button -->
<button
class="btn btn-primary"
@click="toggleEditMode"
>
<i class="edit icon"/>
Edit
</button>
<!-- Enable/Disable Button -->
<button
class="btn"
:class="store.selectedWorkflow.enabled ? 'btn-outline-danger' : 'btn-success'"
@click="toggleWorkflowStatus"
:title="store.selectedWorkflow.enabled ? 'Disable workflow' : 'Enable workflow'"
>
<i :class="store.selectedWorkflow.enabled ? 'pause icon' : 'play icon'"/>
{{ store.selectedWorkflow.enabled ? 'Disable' : 'Enable' }}
</button>
</template>
</div> </div>
</div> </div>
@ -575,6 +655,23 @@ onUnmounted(() => {
'Issues And Pull Requests' }} 'Issues And Pull Requests' }}
</div> </div>
</div> </div>
<div class="field" v-if="hasFilter('column')">
<label>When moved to column</label>
<select
v-if="isInEditMode"
class="form-select"
v-model="store.workflowFilters.column"
>
<option value="">Any column</option>
<option v-for="column in store.projectColumns" :key="column.id" :value="String(column.id)">
{{ column.title }}
</option>
</select>
<div v-else class="readonly-value">
{{ store.projectColumns.find(c => String(c.id) === store.workflowFilters.column)?.title || 'Any column' }}
</div>
</div>
</div> </div>
</div> </div>
@ -599,22 +696,75 @@ onUnmounted(() => {
</div> </div>
</div> </div>
<div class="field" v-if="hasAction('label')"> <div class="field" v-if="hasAction('add_labels')">
<label>Add labels</label> <label>Add labels</label>
<select <div v-if="isInEditMode" class="ui fluid multiple search selection dropdown label-dropdown">
v-if="isInEditMode" <input type="hidden" :value="store.workflowActions.add_labels.join(',')">
class="form-select" <i class="dropdown icon"></i>
v-model="store.workflowActions.add_labels" <div class="text" :class="{ default: !store.workflowActions.add_labels?.length }">
multiple <span v-if="!store.workflowActions.add_labels?.length">Select labels...</span>
> <template v-else>
<option value="">Select labels...</option> <span v-for="labelId in store.workflowActions.add_labels" :key="labelId"
<option v-for="label in store.projectLabels" :key="label.id" :value="String(label.id)"> class="ui label"
{{ label.name }} :style="`background-color: ${store.projectLabels.find(l => String(l.id) === labelId)?.color}; color: ${getLabelTextColor(store.projectLabels.find(l => String(l.id) === labelId)?.color)}`">
</option> {{ store.projectLabels.find(l => String(l.id) === labelId)?.name }}
</select> </span>
<div v-else class="readonly-value"> </template>
{{ store.workflowActions.add_labels?.map(id => </div>
store.projectLabels.find(l => String(l.id) === id)?.name).join(', ') || 'None' }} <div class="menu">
<div class="item" v-for="label in store.projectLabels" :key="label.id"
:data-value="String(label.id)"
@click.prevent="toggleLabel('add_labels', String(label.id))"
:class="{ active: store.workflowActions.add_labels.includes(String(label.id)), selected: store.workflowActions.add_labels.includes(String(label.id)) }">
<span class="ui label" :style="`background-color: ${label.color}; color: ${getLabelTextColor(label.color)}`">
{{ label.name }}
</span>
</div>
</div>
</div>
<div v-else class="ui labels">
<span v-if="!store.workflowActions.add_labels?.length" class="text-muted">None</span>
<span v-for="labelId in store.workflowActions.add_labels" :key="labelId"
class="ui label"
:style="`background-color: ${store.projectLabels.find(l => String(l.id) === labelId)?.color}; color: ${getLabelTextColor(store.projectLabels.find(l => String(l.id) === labelId)?.color)}`">
{{ store.projectLabels.find(l => String(l.id) === labelId)?.name }}
</span>
</div>
</div>
<div class="field" v-if="hasAction('remove_labels')">
<label>Remove labels</label>
<div v-if="isInEditMode" class="ui fluid multiple search selection dropdown label-dropdown">
<input type="hidden" :value="store.workflowActions.remove_labels.join(',')">
<i class="dropdown icon"></i>
<div class="text" :class="{ default: !store.workflowActions.remove_labels?.length }">
<span v-if="!store.workflowActions.remove_labels?.length">Select labels...</span>
<template v-else>
<span v-for="labelId in store.workflowActions.remove_labels" :key="labelId"
class="ui label"
:style="`background-color: ${store.projectLabels.find(l => String(l.id) === labelId)?.color}; color: ${getLabelTextColor(store.projectLabels.find(l => String(l.id) === labelId)?.color)}`">
{{ store.projectLabels.find(l => String(l.id) === labelId)?.name }}
</span>
</template>
</div>
<div class="menu">
<div class="item" v-for="label in store.projectLabels" :key="label.id"
:data-value="String(label.id)"
@click.prevent="toggleLabel('remove_labels', String(label.id))"
:class="{ active: store.workflowActions.remove_labels.includes(String(label.id)), selected: store.workflowActions.remove_labels.includes(String(label.id)) }">
<span class="ui label" :style="`background-color: ${label.color}; color: ${getLabelTextColor(label.color)}`">
{{ label.name }}
</span>
</div>
</div>
</div>
<div v-else class="ui labels">
<span v-if="!store.workflowActions.remove_labels?.length" class="text-muted">None</span>
<span v-for="labelId in store.workflowActions.remove_labels" :key="labelId"
class="ui label"
:style="`background-color: ${store.projectLabels.find(l => String(l.id) === labelId)?.color}; color: ${getLabelTextColor(store.projectLabels.find(l => String(l.id) === labelId)?.color)}`">
{{ store.projectLabels.find(l => String(l.id) === labelId)?.name }}
</span>
</div> </div>
</div> </div>
@ -633,21 +783,6 @@ onUnmounted(() => {
</div> </div>
</div> </div>
<!-- Fixed bottom actions (only show in edit mode) -->
<div v-if="isInEditMode" class="editor-actions">
<button class="btn btn-primary" @click="saveWorkflow" :disabled="store.saving">
<i class="save icon"/>
Save Workflow
</button>
<button
v-if="store.selectedWorkflow && store.selectedWorkflow.id > 0"
class="btn btn-danger"
@click="deleteWorkflow"
>
<i class="trash icon"/>
Delete
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -861,14 +996,6 @@ onUnmounted(() => {
font-size: 0.9rem; font-size: 0.9rem;
} }
.editor-actions {
display: flex;
gap: 0.5rem;
padding: 1.5rem;
border-top: 1px solid #e1e4e8;
background: white;
flex-shrink: 0;
}
/* Responsive */ /* Responsive */
@media (max-width: 768px) { @media (max-width: 768px) {
@ -893,10 +1020,6 @@ onUnmounted(() => {
.editor-content { .editor-content {
padding: 1rem; padding: 1rem;
} }
.editor-actions {
flex-direction: column;
}
} }
@media (max-width: 480px) { @media (max-width: 480px) {
@ -910,8 +1033,13 @@ onUnmounted(() => {
padding: 0.75rem; padding: 0.75rem;
} }
.editor-actions button { .editor-actions-header {
width: 100%; flex-wrap: wrap;
}
.editor-actions-header button {
flex: 1 1 auto;
min-width: 80px;
} }
} }
@ -1125,4 +1253,34 @@ onUnmounted(() => {
background-color: #c82333; background-color: #c82333;
border-color: #bd2130; border-color: #bd2130;
} }
/* Label selector styles */
.label-dropdown.ui.dropdown .menu > .item.active,
.label-dropdown.ui.dropdown .menu > .item.selected {
background: rgba(0, 0, 0, 0.05);
font-weight: normal;
}
.label-dropdown.ui.dropdown .menu > .item .ui.label {
margin: 0;
}
.label-dropdown.ui.dropdown > .text > .ui.label {
margin: 0.125rem;
}
.ui.labels {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.ui.labels .ui.label {
margin: 0;
}
.text-muted {
color: #6c757d;
}
</style> </style>

View File

@ -16,11 +16,13 @@ export function createWorkflowStore(props: { projectLink: string, eventID: strin
workflowFilters: { workflowFilters: {
issue_type: '', // 'issue', 'pull_request', or '' issue_type: '', // 'issue', 'pull_request', or ''
column: '', // target column ID for item_column_changed event
}, },
workflowActions: { workflowActions: {
column: '', // column ID to move to column: '', // column ID to move to
add_labels: [], // selected label IDs add_labels: [], // selected label IDs
remove_labels: [], // selected label IDs to remove
closeIssue: false, closeIssue: false,
}, },
@ -58,14 +60,16 @@ export function createWorkflowStore(props: { projectLink: string, eventID: strin
// Load existing configuration from the workflow data // Load existing configuration from the workflow data
// Convert backend filter format to frontend format // Convert backend filter format to frontend format
const frontendFilters = {issue_type: ''}; const frontendFilters = {issue_type: '', column: ''};
// Convert backend action format to frontend format // Convert backend action format to frontend format
const frontendActions = {column: '', add_labels: [], closeIssue: false}; const frontendActions = {column: '', add_labels: [], remove_labels: [], closeIssue: false};
if (workflow) { if (workflow) {
for (const filter of workflow.filters) { for (const filter of workflow.filters) {
if (filter.type === 'issue_type') { if (filter.type === 'issue_type') {
frontendFilters.issue_type = filter.value; frontendFilters.issue_type = filter.value;
} else if (filter.type === 'column') {
frontendFilters.column = filter.value;
} }
} }
@ -76,6 +80,9 @@ export function createWorkflowStore(props: { projectLink: string, eventID: strin
} else if (action.type === 'add_labels') { } else if (action.type === 'add_labels') {
// Backend returns string, keep as string to match label.id type // Backend returns string, keep as string to match label.id type
frontendActions.add_labels.push(action.value); frontendActions.add_labels.push(action.value);
} else if (action.type === 'remove_labels') {
// Backend returns string, keep as string to match label.id type
frontendActions.remove_labels.push(action.value);
} else if (action.type === 'close') { } else if (action.type === 'close') {
frontendActions.closeIssue = action.value === 'true'; frontendActions.closeIssue = action.value === 'true';
} }
@ -100,8 +107,8 @@ export function createWorkflowStore(props: { projectLink: string, eventID: strin
}, },
resetWorkflowData() { resetWorkflowData() {
store.workflowFilters = {issue_type: ''}; store.workflowFilters = {issue_type: '', column: ''};
store.workflowActions = {column: '', add_labels: [], closeIssue: false}; store.workflowActions = {column: '', add_labels: [], remove_labels: [], closeIssue: false};
}, },
async saveWorkflow() { async saveWorkflow() {
@ -163,13 +170,15 @@ export function createWorkflowStore(props: { projectLink: string, eventID: strin
// Convert backend data to frontend format and update form // Convert backend data to frontend format and update form
// Use the selectedWorkflow which now points to the reloaded workflow with complete data // Use the selectedWorkflow which now points to the reloaded workflow with complete data
const frontendFilters = {issue_type: ''}; const frontendFilters = {issue_type: '', column: ''};
const frontendActions = {column: '', add_labels: [], closeIssue: false}; const frontendActions = {column: '', add_labels: [], remove_labels: [], closeIssue: false};
if (store.selectedWorkflow.filters && Array.isArray(store.selectedWorkflow.filters)) { if (store.selectedWorkflow.filters && Array.isArray(store.selectedWorkflow.filters)) {
for (const filter of store.selectedWorkflow.filters) { for (const filter of store.selectedWorkflow.filters) {
if (filter.type === 'issue_type') { if (filter.type === 'issue_type') {
frontendFilters.issue_type = filter.value; frontendFilters.issue_type = filter.value;
} else if (filter.type === 'column') {
frontendFilters.column = filter.value;
} }
} }
} }
@ -180,6 +189,8 @@ export function createWorkflowStore(props: { projectLink: string, eventID: strin
frontendActions.column = action.value; frontendActions.column = action.value;
} else if (action.type === 'add_labels') { } else if (action.type === 'add_labels') {
frontendActions.add_labels.push(action.value); frontendActions.add_labels.push(action.value);
} else if (action.type === 'remove_labels') {
frontendActions.remove_labels.push(action.value);
} else if (action.type === 'close') { } else if (action.type === 'close') {
frontendActions.closeIssue = action.value === 'true'; frontendActions.closeIssue = action.value === 'true';
} }