diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index fb038cdb76..ae877827c4 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -3977,6 +3977,8 @@ workflows.reopen_issue = Reopen issue workflows.save_workflow_failed = Failed to save workflow workflows.update_workflow_failed = Failed to update workflow status workflows.delete_workflow_failed = Failed to delete workflow +workflows.at_least_one_action_required = At least one action must be configured +workflows.error.at_least_one_action = At least one action must be configured [git.filemode] changed_filemode = %[1]s → %[2]s diff --git a/routers/web/projects/workflows.go b/routers/web/projects/workflows.go index 1897b8434a..0e62bcc3b5 100644 --- a/routers/web/projects/workflows.go +++ b/routers/web/projects/workflows.go @@ -442,6 +442,15 @@ func WorkflowsPost(ctx *context.Context) { filters := convertFormToFilters(form.Filters) actions := convertFormToActions(form.Actions) + // Validate: at least one action must be configured + if len(actions) == 0 { + ctx.JSON(http.StatusBadRequest, map[string]any{ + "error": "NoActions", + "message": ctx.Tr("projects.workflows.error.at_least_one_action"), + }) + return + } + eventID, _ := strconv.ParseInt(form.EventID, 10, 64) if eventID == 0 { // check if workflow event is valid diff --git a/templates/projects/workflows.tmpl b/templates/projects/workflows.tmpl index 76e17bc229..25ae4db00b 100644 --- a/templates/projects/workflows.tmpl +++ b/templates/projects/workflows.tmpl @@ -39,6 +39,7 @@ data-locale-save-workflow-failed="{{ctx.Locale.Tr "projects.workflows.save_workflow_failed"}}" data-locale-update-workflow-failed="{{ctx.Locale.Tr "projects.workflows.update_workflow_failed"}}" data-locale-delete-workflow-failed="{{ctx.Locale.Tr "projects.workflows.delete_workflow_failed"}}" + data-locale-at-least-one-action-required="{{ctx.Locale.Tr "projects.workflows.at_least_one_action_required"}}" > diff --git a/tests/integration/project_workflow_test.go b/tests/integration/project_workflow_test.go index 72de6bed08..1e84ce0763 100644 --- a/tests/integration/project_workflow_test.go +++ b/tests/integration/project_workflow_test.go @@ -444,3 +444,114 @@ func TestProjectWorkflowPermissions(t *testing.T) { fmt.Sprintf("/%s/%s/projects/%d/workflows/%d/delete?_csrf=%s", user.Name, repo.Name, project.ID, workflow.ID, GetUserCSRFToken(t, session2))) session2.MakeRequest(t, req, http.StatusNotFound) // we use 404 to avoid leaking existence } + +func TestProjectWorkflowValidation(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + + // Create a project + project := &project_model.Project{ + Title: "Test Project for Workflow Validation", + RepoID: repo.ID, + Type: project_model.TypeRepository, + TemplateType: project_model.TemplateTypeNone, + } + err := project_model.NewProject(t.Context(), project) + assert.NoError(t, err) + + session := loginUser(t, user.Name) + + // Test 1: Try to create a workflow without any actions (should fail) + t.Run("Create workflow without actions should fail", func(t *testing.T) { + workflowData := map[string]any{ + "event_id": string(project_model.WorkflowEventItemOpened), + "filters": map[string]any{ + string(project_model.WorkflowFilterTypeIssueType): "issue", + }, + "actions": map[string]any{ + // No actions provided - this should trigger validation error + }, + } + + body, err := json.Marshal(workflowData) + assert.NoError(t, err) + + req := NewRequestWithBody(t, "POST", + fmt.Sprintf("/%s/%s/projects/%d/workflows/item_opened?_csrf=%s", user.Name, repo.Name, project.ID, GetUserCSRFToken(t, session)), + strings.NewReader(string(body))) + req.Header.Set("Content-Type", "application/json") + resp := session.MakeRequest(t, req, http.StatusBadRequest) + + // Parse response + var result map[string]any + err = json.Unmarshal(resp.Body.Bytes(), &result) + assert.NoError(t, err) + assert.Equal(t, "NoActions", result["error"], "Error should be NoActions") + assert.NotEmpty(t, result["message"], "Error message should be provided") + }) + + // Test 2: Try to update a workflow to have no actions (should fail) + t.Run("Update workflow to remove all actions should fail", func(t *testing.T) { + // First create a valid workflow + column := &project_model.Column{ + Title: "Test Column", + ProjectID: project.ID, + } + err := project_model.NewColumn(t.Context(), column) + assert.NoError(t, err) + + workflow := &project_model.Workflow{ + ProjectID: project.ID, + WorkflowEvent: project_model.WorkflowEventItemOpened, + WorkflowFilters: []project_model.WorkflowFilter{ + { + Type: project_model.WorkflowFilterTypeIssueType, + Value: "issue", + }, + }, + WorkflowActions: []project_model.WorkflowAction{ + { + Type: project_model.WorkflowActionTypeColumn, + Value: strconv.FormatInt(column.ID, 10), + }, + }, + Enabled: true, + } + err = project_model.CreateWorkflow(t.Context(), workflow) + assert.NoError(t, err) + + // Try to update it to have no actions + updateData := map[string]any{ + "event_id": strconv.FormatInt(workflow.ID, 10), + "filters": map[string]any{ + string(project_model.WorkflowFilterTypeIssueType): "issue", + }, + "actions": map[string]any{ + // No actions - should fail + }, + } + + body, err := json.Marshal(updateData) + assert.NoError(t, err) + + req := NewRequestWithBody(t, "POST", + fmt.Sprintf("/%s/%s/projects/%d/workflows/%d?_csrf=%s", user.Name, repo.Name, project.ID, workflow.ID, GetUserCSRFToken(t, session)), + strings.NewReader(string(body))) + req.Header.Set("Content-Type", "application/json") + resp := session.MakeRequest(t, req, http.StatusBadRequest) + + // Parse response + var result map[string]any + err = json.Unmarshal(resp.Body.Bytes(), &result) + assert.NoError(t, err) + assert.Equal(t, "NoActions", result["error"], "Error should be NoActions") + assert.NotEmpty(t, result["message"], "Error message should be provided") + + // Verify the workflow was not changed + unchangedWorkflow, err := project_model.GetWorkflowByID(t.Context(), workflow.ID) + assert.NoError(t, err) + assert.Len(t, unchangedWorkflow.WorkflowActions, 1, "Workflow should still have the original action") + }) +} diff --git a/web_src/js/components/projects/ProjectWorkflow.vue b/web_src/js/components/projects/ProjectWorkflow.vue index d64abc3efe..5341ac9881 100644 --- a/web_src/js/components/projects/ProjectWorkflow.vue +++ b/web_src/js/components/projects/ProjectWorkflow.vue @@ -49,6 +49,7 @@ const props = defineProps<{ saveWorkflowFailed: string; updateWorkflowFailed: string; deleteWorkflowFailed: string; + atLeastOneActionRequired: string; }, }>(); diff --git a/web_src/js/components/projects/WorkflowStore.ts b/web_src/js/components/projects/WorkflowStore.ts index c707b719aa..0e6c485396 100644 --- a/web_src/js/components/projects/WorkflowStore.ts +++ b/web_src/js/components/projects/WorkflowStore.ts @@ -176,6 +176,19 @@ export function createWorkflowStore(props: any) { async saveWorkflow() { if (!store.selectedWorkflow) return; + // Validate: at least one action must be configured + const hasAtLeastOneAction = !!( + store.workflowActions.column || + store.workflowActions.add_labels.length > 0 || + store.workflowActions.remove_labels.length > 0 || + store.workflowActions.issue_state + ); + + if (!hasAtLeastOneAction) { + showErrorToast(props.locale.atLeastOneActionRequired || 'At least one action must be configured'); + return; + } + store.saving = true; try { // For new workflows, use the base event type @@ -196,9 +209,20 @@ export function createWorkflowStore(props: any) { }); if (!response.ok) { - const errorText = await response.text(); - console.error('Response error:', errorText); - showErrorToast(`${props.locale.failedToSaveWorkflow}: ${response.status} ${response.statusText}\n${errorText}`); + let errorMessage = `${props.locale.failedToSaveWorkflow}: ${response.status} ${response.statusText}`; + try { + const errorData = await response.json(); + if (errorData.message) { + errorMessage = errorData.message; + } else if (errorData.error === 'NoActions') { + errorMessage = props.locale.atLeastOneActionRequired || 'At least one action must be configured'; + } + } catch { + const errorText = await response.text(); + console.error('Response error:', errorText); + errorMessage += `\n${errorText}`; + } + showErrorToast(errorMessage); return; } diff --git a/web_src/js/features/projects/workflow.ts b/web_src/js/features/projects/workflow.ts index 54f5773c4f..75b98fa9c7 100644 --- a/web_src/js/features/projects/workflow.ts +++ b/web_src/js/features/projects/workflow.ts @@ -44,6 +44,7 @@ export async function initProjectWorkflow() { saveWorkflowFailed: workflowDiv.getAttribute('data-locale-save-workflow-failed'), updateWorkflowFailed: workflowDiv.getAttribute('data-locale-update-workflow-failed'), deleteWorkflowFailed: workflowDiv.getAttribute('data-locale-delete-workflow-failed'), + atLeastOneActionRequired: workflowDiv.getAttribute('data-locale-at-least-one-action-required'), }; const View = createApp(ProjectWorkflow, {