adjust workflow page header

pull/30205/head
Lunny Xiao 2025-09-04 10:51:40 +07:00
parent 990d8000fd
commit 58a368021f
No known key found for this signature in database
GPG Key ID: C3B7C91B632F738A
8 changed files with 86 additions and 47 deletions

@ -556,8 +556,9 @@ var globalVars = sync.OnceValue(func() *globalVarsStruct {
emailRegexp: regexp.MustCompile("^[a-zA-Z0-9.!#$%&'*+-/=?^_`{|}~]*@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$"),
systemUserNewFuncs: map[int64]func() *User{
GhostUserID: NewGhostUser,
ActionsUserID: NewActionsUser,
GhostUserID: NewGhostUser,
ActionsUserID: NewActionsUser,
WorkflowsUserID: NewWorkflowsUser,
},
}
})

@ -36,6 +36,8 @@ func GetPossibleUserFromMap(userID int64, usererMaps map[int64]*User) *User {
return NewGhostUser()
case ActionsUserID:
return NewActionsUser()
case WorkflowsUserID:
return NewWorkflowsUser()
case 0:
return nil
default:

@ -66,6 +66,37 @@ func (u *User) IsGiteaActions() bool {
return u != nil && u.ID == ActionsUserID
}
const (
WorkflowsUserID int64 = -3
WorkflowsUserName = "gitea-workflows"
WorkflowsUserEmail = "workflows@gitea.io"
)
func IsGiteaWorkflowsUserName(name string) bool {
return strings.EqualFold(name, WorkflowsUserName)
}
// NewWorkflowsUser creates and returns a fake user for running the workflows.
func NewWorkflowsUser() *User {
return &User{
ID: WorkflowsUserID,
Name: WorkflowsUserName,
LowerName: WorkflowsUserName,
IsActive: true,
FullName: "Gitea Workflows",
Email: WorkflowsUserEmail,
KeepEmailPrivate: true,
LoginName: WorkflowsUserName,
Type: UserTypeBot,
AllowCreateOrganization: true,
Visibility: structs.VisibleTypePublic,
}
}
func (u *User) IsGiteaWorkflows() bool {
return u != nil && u.ID == WorkflowsUserID
}
func GetSystemUserByName(name string) *User {
if IsGhostUserName(name) {
return NewGhostUser()
@ -73,5 +104,8 @@ func GetSystemUserByName(name string) *User {
if IsGiteaActionsUserName(name) {
return NewActionsUser()
}
if IsGiteaWorkflowsUserName(name) {
return NewWorkflowsUser()
}
return nil
}

@ -310,9 +310,7 @@ func Workflows(ctx *context.Context) {
}
ctx.Data["Title"] = ctx.Tr("projects.workflows")
ctx.Data["PageIsWorkflows"] = true
ctx.Data["PageIsProjects"] = true
ctx.Data["PageIsProjectsWorkflows"] = true
ctx.Data["IsProjectsPage"] = true
ctx.Data["Project"] = p
workflows, err := project_model.FindWorkflowsByProjectID(ctx, projectID)

@ -13,6 +13,7 @@ import (
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/util"
issue_service "code.gitea.io/gitea/services/issue"
notify_service "code.gitea.io/gitea/services/notify"
)
@ -41,6 +42,7 @@ func (m *workflowNotifier) NewIssue(ctx context.Context, issue *issues_model.Iss
return
}
if issue.Project == nil {
// TODO: handle item opened
return
}
@ -95,7 +97,7 @@ func fireIssueWorkflow(ctx context.Context, workflow *project_model.Workflow, is
return
}
default:
log.Error("NewIssue: Unsupported filter type: %s", filter.Type)
log.Error("Unsupported filter type: %s", filter.Type)
return
}
}
@ -105,15 +107,22 @@ func fireIssueWorkflow(ctx context.Context, workflow *project_model.Workflow, is
case project_model.WorkflowActionTypeColumn:
column, err := project_model.GetColumnByProjectIDAndColumnName(ctx, issue.Project.ID, action.ActionValue)
if err != nil {
log.Error("NewIssue: GetColumnByProjectIDAndColumnName: %v", err)
log.Error("GetColumnByProjectIDAndColumnName: %v", err)
continue
}
if err := project_model.AddIssueToColumn(ctx, issue.ID, column); err != nil {
log.Error("NewIssue: AddIssueToColumn: %v", err)
log.Error("AddIssueToColumn: %v", err)
continue
}
case project_model.WorkflowActionTypeAddLabels:
case project_model.WorkflowActionTypeRemoveLabels:
case project_model.WorkflowActionTypeClose:
if err := issue_service.CloseIssue(ctx, issue, user_model.NewWorkflowsUser(), ""); err != nil {
log.Error("CloseIssue: %v", err)
continue
}
default:
log.Error("NewIssue: Unsupported action type: %s", action.ActionType)
log.Error("Unsupported action type: %s", action.ActionType)
}
}
}

@ -1,7 +1,4 @@
<div class="ui container padded projects-view">
<div class="project-header">
<h2>{{.Project.Title}} - {{ctx.Locale.Tr "projects.workflows"}}</h2>
</div>
<div id="project-workflows"
data-project-link="{{.ProjectLink}}"
data-event-id="{{.workflowIDStr}}"

@ -2,11 +2,9 @@
<div role="main" aria-label="{{.Title}}" class="page-content repository projects view-project">
{{template "repo/header" .}}
<div class="ui container padded">
<div class="flex-text-block tw-justify-end tw-mb-4">
<a class="ui small button" href="{{.RepoLink}}/labels">{{ctx.Locale.Tr "repo.labels"}}</a>
<a class="ui small button" href="{{.RepoLink}}/milestones">{{ctx.Locale.Tr "repo.milestones"}}</a>
<a class="ui small primary button" href="{{.RepoLink}}/issues/new/choose?project={{.Project.ID}}">{{ctx.Locale.Tr "repo.issues.new"}}</a>
</div>
<div class="tw-flex tw-justify-between tw-items-center tw-mb-4">
<a class="ui" href="{{.ProjectLink}}">{{svg "octicon-arrow-left"}} {{ctx.Locale.Tr "projects.workflows"}} {{.Project.Title}}</a>
</div>
</div>
{{template "projects/workflows" .}}
</div>

@ -18,12 +18,12 @@ const previousSelection = ref(null);
// Helper to check if current workflow is in edit mode
const isInEditMode = computed(() => {
if (!store.selectedWorkflow) return false;
// Unconfigured workflows (id === 0) are always in edit mode
if (store.selectedWorkflow.id === 0) {
return true;
}
// Configured workflows use the _isEditing flag
return store.selectedWorkflow._isEditing || false;
});
@ -48,14 +48,14 @@ const toggleEditMode = () => {
// If there was a previous selection, return to it
if (store.selectedWorkflow && store.selectedWorkflow.id === 0) {
// Remove temporary cloned workflow from list
const tempIndex = store.workflowEvents.findIndex(w =>
const tempIndex = store.workflowEvents.findIndex(w =>
w.event_id === store.selectedWorkflow.event_id
);
if (tempIndex >= 0) {
store.workflowEvents.splice(tempIndex, 1);
}
}
// Restore previous selection
store.selectedItem = previousSelection.value.selectedItem;
store.selectedWorkflow = previousSelection.value.selectedWorkflow;
@ -96,7 +96,7 @@ const deleteWorkflow = async () => {
// If deleting a temporary workflow (clone/new), just remove from list
if (store.selectedWorkflow.id === 0) {
const tempIndex = store.workflowEvents.findIndex(w =>
const tempIndex = store.workflowEvents.findIndex(w =>
w.event_id === store.selectedWorkflow.event_id
);
if (tempIndex >= 0) {
@ -133,7 +133,7 @@ const deleteWorkflow = async () => {
const selectWorkflowEvent = async (event) => {
// Prevent rapid successive clicks
if (store.loading) return;
// Toggle selection - if already selected, deselect
if (store.selectedItem === event.event_id) {
store.selectedItem = null;
@ -144,10 +144,10 @@ const selectWorkflowEvent = async (event) => {
try {
store.selectedItem = event.event_id;
store.selectedWorkflow = event;
// Wait for DOM update before proceeding
await nextTick();
await store.loadWorkflowData(event.event_id);
// Update URL without page reload
@ -164,7 +164,7 @@ const selectWorkflowEvent = async (event) => {
const saveWorkflow = async () => {
await store.saveWorkflow();
// The store.saveWorkflow already handles reloading events
// Clear previous selection after successful save
previousSelection.value = null;
setEditMode(false);
@ -180,7 +180,7 @@ const getFilterDescription = (workflow) => {
if (!workflow.filters || !Array.isArray(workflow.filters) || workflow.filters.length === 0) {
return '';
}
const descriptions = [];
for (const filter of workflow.filters) {
if (filter.type === 'issue_type' && filter.value) {
@ -192,7 +192,7 @@ const getFilterDescription = (workflow) => {
}
// Add more filter types here as needed
}
return descriptions.length > 0 ? ` (${descriptions.join(', ')})` : '';
};
@ -212,7 +212,7 @@ const workflowList = computed(() => {
if (!workflows || workflows.length === 0) {
return [];
}
return workflows.map((workflow) => ({
...workflow,
isConfigured: isWorkflowConfigured(workflow),
@ -262,7 +262,7 @@ const cloneWorkflow = (sourceWorkflow) => {
// Extract base name without filter descriptions
const baseName = (sourceWorkflow.display_name || sourceWorkflow.workflow_event || sourceWorkflow.event_id)
.replace(/\s*\([^)]*\)\s*/g, ''); // Remove any parenthetical descriptions
const clonedWorkflow = {
id: 0,
event_id: tempId,
@ -290,7 +290,7 @@ const cloneWorkflow = (sourceWorkflow) => {
// Load the source workflow's data into the form
store.loadWorkflowData(sourceWorkflow.event_id);
// Cloned workflows (id: 0) are always in edit mode by default
// Update URL for cloned workflow
const newUrl = `${props.projectLink}/workflows/${tempId}`;
window.history.pushState({eventId: tempId}, '', newUrl);
@ -302,27 +302,27 @@ let selectTimeout = null;
const selectWorkflowItem = async (item) => {
// Prevent rapid successive clicks with debounce
if (store.loading || selectTimeout) return;
selectTimeout = setTimeout(() => {
selectTimeout = null;
}, 300);
previousSelection.value = null; // Clear previous selection when manually selecting
// Don't reset edit mode when switching - each workflow keeps its own state
// Wait for DOM update to prevent conflicts
await nextTick();
if (item.isConfigured) {
// This is a configured workflow, select it
await selectWorkflowEvent(item);
} else {
// This is an unconfigured event - check if we already have a workflow object for it
const existingWorkflow = store.workflowEvents.find(w =>
w.id === 0 &&
const existingWorkflow = store.workflowEvents.find(w =>
w.id === 0 &&
(w.base_event_type === item.base_event_type || w.workflow_event === item.base_event_type)
);
if (existingWorkflow) {
// We already have an unconfigured workflow for this event type, select it
await selectWorkflowEvent(existingWorkflow);
@ -330,7 +330,7 @@ const selectWorkflowItem = async (item) => {
// This is truly a new unconfigured event, create new workflow
createNewWorkflow(item.base_event_type, item.capabilities, item.display_name);
}
// Update URL for workflow
const newUrl = `${props.projectLink}/workflows/${item.base_event_type}`;
window.history.pushState({eventId: item.base_event_type}, '', newUrl);
@ -353,18 +353,18 @@ const getStatusClass = (item) => {
if (!item.isConfigured) {
return 'status-inactive'; // Gray dot for unconfigured
}
// For configured workflows, check enabled status
if (item.enabled === false) {
return 'status-disabled'; // Red dot for disabled
}
return 'status-active'; // Green dot for enabled
};
const isItemSelected = (item) => {
if (!store.selectedItem) return false;
if (item.isConfigured || item.id === 0) {
// For configured workflows or temporary workflows (clones/new), match by event_id
return store.selectedItem === item.event_id;
@ -409,7 +409,7 @@ onMounted(async () => {
store.workflowEvents = await store.loadEvents();
await store.loadProjectColumns();
await store.loadProjectLabels();
// Add native event listener to prevent conflicts with Gitea
await nextTick();
const workflowItemsContainer = elRoot.value.querySelector('.workflow-items');
@ -445,7 +445,7 @@ onMounted(async () => {
} else {
// Check if eventID matches a base event type (unconfigured workflow)
const items = workflowList.value;
const matchingUnconfigured = items.find((item) =>
const matchingUnconfigured = items.find((item) =>
!item.isConfigured && (item.base_event_type === props.eventID || item.event_id === props.eventID)
);
if (matchingUnconfigured) {
@ -495,7 +495,7 @@ const popstateHandler = (e) => {
} else {
// Check if it's a base event type
const items = workflowList.value;
const matchingUnconfigured = items.find((item) =>
const matchingUnconfigured = items.find((item) =>
!item.isConfigured && (item.base_event_type === e.state.eventId || item.event_id === e.state.eventId)
);
if (matchingUnconfigured) {
@ -515,7 +515,7 @@ onUnmounted(() => {
selectTimeout = null;
}
window.removeEventListener('popstate', popstateHandler);
// Remove native click event listener
const workflowItemsContainer = elRoot.value?.querySelector('.workflow-items');
if (workflowItemsContainer && workflowClickHandler) {
@ -529,7 +529,7 @@ onUnmounted(() => {
<!-- Left Sidebar - Workflow List -->
<div class="workflow-sidebar">
<div class="sidebar-header">
<h3>Project Workflows</h3>
<h3>Default Workflows</h3>
</div>
<div class="sidebar-content">