0
0
mirror of https://github.com/go-gitea/gitea.git synced 2026-06-29 22:57:20 +02:00
gitea/web_src/js/components/projects/WorkflowStore.ts
Lunny Xiao cc11cdaf4e
update
2026-05-16 18:36:45 -07:00

389 lines
13 KiB
TypeScript

import {reactive} from 'vue';
import {GET, POST} from '../../modules/fetch.ts';
import {showErrorToast} from '../../modules/toast.ts';
// Minimum props the store needs from the Vue component
type StoreProps = {
projectLink: string;
eventId: string;
locale: {
atLeastOneActionRequired: string;
saveWorkflowFailed: string;
updateWorkflowFailed: string;
deleteWorkflowFailed: string;
};
};
type WorkflowFilters = {
issue_type: string;
source_column: string;
target_column: string;
labels: string[];
};
type WorkflowIssueStateAction = '' | 'close' | 'reopen';
type WorkflowActions = {
column: string;
add_labels: string[];
remove_labels: string[];
issue_state: WorkflowIssueStateAction;
};
type WorkflowDraftState = {
filters: WorkflowFilters;
actions: WorkflowActions;
};
export type ProjectColumn = {
id: number;
title: string;
};
export type ProjectLabel = {
id: number;
name: string;
color: string;
};
type WorkflowCapabilities = {
available_filters?: string[];
available_actions?: string[];
};
export type WorkflowEvent = {
id: number;
event_id: string;
workflow_event?: string;
display_name?: string;
summary?: string;
enabled?: boolean;
capabilities?: WorkflowCapabilities;
filters?: Array<{type: string, value: string}>;
actions?: Array<{type: string, value: string}>;
_isEditing?: boolean;
_clonedFromEventId?: string;
is_configured?: boolean;
} & Record<string, unknown>;
export type WorkflowStoreState = {
workflowEvents: WorkflowEvent[];
selectedItem: string | null;
selectedWorkflow: WorkflowEvent | null;
projectColumns: ProjectColumn[];
projectLabels: ProjectLabel[];
saving: boolean;
loading: boolean;
workflowFilters: WorkflowFilters;
workflowActions: WorkflowActions;
workflowDrafts: Record<string, WorkflowDraftState>;
getDraft(event_id: string): WorkflowDraftState | undefined;
updateDraft(event_id: string, filters: WorkflowFilters, actions: WorkflowActions): void;
clearDraft(event_id: string): void;
loadEvents(): Promise<WorkflowEvent[]>;
loadProjectOptions(): Promise<void>;
loadWorkflowData(event_id: string): Promise<void>;
saveWorkflow(): Promise<boolean>;
saveWorkflowStatus(desiredEnabled: boolean): Promise<void>;
deleteWorkflow(): Promise<void>;
};
const createDefaultFilters = (): WorkflowFilters => ({issue_type: '', source_column: '', target_column: '', labels: []});
const createDefaultActions = (): WorkflowActions => ({column: '', add_labels: [], remove_labels: [], issue_state: ''});
const getErrorMessage = (error: unknown): string => error instanceof Error ? error.message : String(error);
function convertFilters(workflow?: WorkflowEvent | null): WorkflowFilters {
const filters = createDefaultFilters();
if (workflow?.filters && Array.isArray(workflow.filters)) {
for (const filter of workflow.filters) {
if (filter.type === 'issue_type') {
filters.issue_type = filter.value;
} else if (filter.type === 'source_column') {
filters.source_column = filter.value;
} else if (filter.type === 'target_column') {
filters.target_column = filter.value;
} else if (filter.type === 'labels') {
filters.labels.push(filter.value);
}
}
}
return filters;
}
function convertActions(workflow?: WorkflowEvent | null): WorkflowActions {
const actions = createDefaultActions();
if (workflow?.actions && Array.isArray(workflow.actions)) {
for (const action of workflow.actions) {
if (action.type === 'column') {
// Backend returns string, keep as string to match column.id type
actions.column = action.value;
} else if (action.type === 'add_labels') {
// Backend returns string, keep as string to match label.id type
actions.add_labels.push(action.value);
} else if (action.type === 'remove_labels') {
// Backend returns string, keep as string to match label.id type
actions.remove_labels.push(action.value);
} else if (action.type === 'issue_state') {
actions.issue_state = action.value as WorkflowIssueStateAction;
}
}
}
return actions;
}
const cloneFilters = (filters: WorkflowFilters): WorkflowFilters => ({
issue_type: filters.issue_type,
source_column: filters.source_column,
target_column: filters.target_column,
labels: Array.from(filters.labels),
});
const cloneActions = (actions: WorkflowActions): WorkflowActions => ({
column: actions.column,
add_labels: Array.from(actions.add_labels),
remove_labels: Array.from(actions.remove_labels),
issue_state: actions.issue_state,
});
export function createWorkflowStore(props: StoreProps): WorkflowStoreState {
const store: WorkflowStoreState = reactive<WorkflowStoreState>({
workflowEvents: [] as WorkflowEvent[],
selectedItem: props.eventId || null,
selectedWorkflow: null,
projectColumns: [],
projectLabels: [],
saving: false,
loading: false,
workflowFilters: createDefaultFilters(),
workflowActions: createDefaultActions(),
workflowDrafts: {},
getDraft(event_id: string): WorkflowDraftState | undefined {
return store.workflowDrafts[event_id];
},
updateDraft(event_id: string, filters: WorkflowFilters, actions: WorkflowActions) {
store.workflowDrafts[event_id] = {
filters: cloneFilters(filters),
actions: cloneActions(actions),
};
},
clearDraft(event_id: string) {
delete store.workflowDrafts[event_id];
},
async loadEvents(): Promise<WorkflowEvent[]> {
const response = await GET(`${props.projectLink}/workflows/events`);
const data = await response.json();
store.workflowEvents = data as WorkflowEvent[];
return store.workflowEvents;
},
async loadProjectOptions(): Promise<void> {
try {
const response = await GET(`${props.projectLink}/workflows/options`);
const data = await response.json();
store.projectColumns = data.columns as ProjectColumn[];
store.projectLabels = data.labels as ProjectLabel[];
} catch (error) {
console.error('Failed to load project columns and labels:', error);
store.projectColumns = [];
store.projectLabels = [];
}
},
async loadWorkflowData(event_id: string): Promise<void> {
store.loading = true;
try {
const draft = store.getDraft(event_id);
if (draft) {
store.workflowFilters = cloneFilters(draft.filters);
store.workflowActions = cloneActions(draft.actions);
return;
}
// Find the workflow from existing workflowEvents
const workflow = store.workflowEvents.find((e: WorkflowEvent) => e.event_id === event_id);
store.workflowFilters = convertFilters(workflow);
store.workflowActions = convertActions(workflow);
store.updateDraft(event_id, store.workflowFilters, store.workflowActions);
} finally {
store.loading = false;
}
},
async saveWorkflow(): Promise<boolean> {
if (!store.selectedWorkflow) return false;
// Validate: at least one action must be configured
const hasAtLeastOneAction = Boolean(
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);
return false;
}
store.saving = true;
try {
const event_id = store.selectedWorkflow.event_id;
const postData = {
event_id,
filters: store.workflowFilters,
actions: store.workflowActions,
};
const response = await POST(`${props.projectLink}/workflows/${event_id}`, {
data: postData,
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
let errorMessage = `${props.locale.saveWorkflowFailed}: ${response.status} ${response.statusText}`;
try {
const errorData = await response.json();
if (errorData.errorMessage) {
errorMessage = errorData.errorMessage;
}
} catch {
const errorText = await response.text();
console.error('Response error:', errorText);
errorMessage += `\n${errorText}`;
}
showErrorToast(errorMessage);
return false;
}
const result = await response.json();
if (result.success && result.workflow) {
const wasNewWorkflow = store.selectedWorkflow.id === 0;
// Clear draft for the old event_id before reloading (id=0 means unsaved)
if (wasNewWorkflow) store.clearDraft(store.selectedWorkflow.event_id);
await store.loadEvents();
const reloadedWorkflow = store.workflowEvents.find((w: WorkflowEvent) => w.event_id === result.workflow.event_id);
const savedWorkflow = {
...result.workflow,
_isEditing: false,
is_configured: true,
} satisfies WorkflowEvent;
if (reloadedWorkflow) {
reloadedWorkflow._isEditing = false;
store.selectedWorkflow = reloadedWorkflow;
store.selectedItem = reloadedWorkflow.event_id;
} else {
store.selectedWorkflow = savedWorkflow;
store.selectedItem = savedWorkflow.event_id;
}
store.workflowFilters = convertFilters(store.selectedWorkflow);
store.workflowActions = convertActions(store.selectedWorkflow);
store.updateDraft(store.selectedWorkflow!.event_id, store.workflowFilters, store.workflowActions);
if (wasNewWorkflow && store.selectedWorkflow!.event_id) {
const newUrl = `${props.projectLink}/workflows/${store.selectedWorkflow!.event_id}`;
window.history.replaceState({event_id: store.selectedWorkflow!.event_id}, '', newUrl);
}
return true;
}
console.error('Unexpected response format:', result);
showErrorToast(`${props.locale.saveWorkflowFailed}: Unexpected response format`);
return false;
} catch (error) {
console.error('Failed to save workflow:', error);
showErrorToast(`${props.locale.saveWorkflowFailed}: ${getErrorMessage(error)}`);
return false;
} finally {
store.saving = false;
}
},
async saveWorkflowStatus(desiredEnabled: boolean): Promise<void> {
const selected = store.selectedWorkflow;
if (!selected || selected.id === 0) return;
const previousEnabled = Boolean(selected.enabled);
selected.enabled = desiredEnabled;
try {
const formData = new FormData();
formData.append('enabled', desiredEnabled.toString());
const response = await POST(`${props.projectLink}/workflows/${selected.id}/status`, {
data: formData,
});
if (!response.ok) {
const errorText = await response.text();
console.error('Failed to update workflow status:', errorText);
showErrorToast(`${props.locale.updateWorkflowFailed}: ${response.status} ${response.statusText}`);
// Revert the status change on error
selected.enabled = previousEnabled;
return;
}
const result = await response.json();
if (result.success) {
// Update workflow in the list
const existingIndex = store.workflowEvents.findIndex((e: WorkflowEvent) => e.event_id === selected.event_id);
if (existingIndex >= 0) {
store.workflowEvents[existingIndex].enabled = desiredEnabled;
}
} else {
// Revert the status change on failure
selected.enabled = previousEnabled;
showErrorToast(`${props.locale.updateWorkflowFailed}: Unexpected error`);
}
} catch (error) {
console.error('Failed to update workflow status:', error);
// Revert the status change on error
selected.enabled = previousEnabled;
showErrorToast(`${props.locale.updateWorkflowFailed}: ${getErrorMessage(error)}`);
}
},
async deleteWorkflow(): Promise<void> {
const selected = store.selectedWorkflow;
if (!selected || selected.id === 0) return;
try {
const response = await POST(`${props.projectLink}/workflows/${selected.id}/delete`, {
data: new FormData(),
});
if (!response.ok) {
const errorText = await response.text();
console.error('Failed to delete workflow:', errorText);
showErrorToast(`${props.locale.deleteWorkflowFailed}: ${response.status} ${response.statusText}`);
return;
}
// Remove workflow from the list
const existingIndex = store.workflowEvents.findIndex((e: WorkflowEvent) => e.event_id === selected.event_id);
if (existingIndex >= 0) {
store.workflowEvents.splice(existingIndex, 1);
}
} catch (error) {
console.error('Error deleting workflow:', error);
showErrorToast(`${props.locale.deleteWorkflowFailed}: ${getErrorMessage(error)}`);
}
},
});
return store;
}