Add valid when saving project workflow event

pull/30205/head
Lunny Xiao 2025-10-28 11:52:37 +07:00
parent df50690df7
commit 0b41bfa135
No known key found for this signature in database
GPG Key ID: C3B7C91B632F738A
7 changed files with 152 additions and 3 deletions

@ -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

@ -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

@ -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"}}"
>
</div>
</div>

@ -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")
})
}

@ -49,6 +49,7 @@ const props = defineProps<{
saveWorkflowFailed: string;
updateWorkflowFailed: string;
deleteWorkflowFailed: string;
atLeastOneActionRequired: string;
},
}>();

@ -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;
}

@ -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, {