0
0
mirror of https://github.com/go-gitea/gitea.git synced 2026-06-30 16:12:13 +02:00
gitea/web_src/js/components/projects/ProjectWorkflow.vue
Lunny Xiao cc11cdaf4e
update
2026-05-16 18:36:45 -07:00

447 lines
16 KiB
Vue

<script lang="ts" setup>
import {onMounted, onUnmounted, computed, ref, watch, provide, toRaw} from 'vue';
import {debounce} from 'throttle-debounce';
import {createWorkflowStore} from './WorkflowStore.ts';
import type {WorkflowEvent} from './WorkflowStore.ts';
import {confirmModal} from '../../features/comp/ConfirmModal.ts';
import WorkflowSidebar from './WorkflowSidebar.vue';
import WorkflowEditor from './WorkflowEditor.vue';
const props = defineProps<{
projectLink: string;
eventId: string;
canWriteProjects: boolean;
locale: {
defaultWorkflows: string;
moveToColumn: string;
viewWorkflowConfiguration: string;
configureWorkflow: string;
when: string;
runWhen: string;
filters: string;
applyTo: string;
whenMovedFromColumn: string;
whenMovedToColumn: string;
onlyIfHasLabels: string;
actions: string;
addLabels: string;
removeLabels: string;
anyLabel: string;
anyColumn: string;
issueState: string;
none: string;
noChange: string;
edit: string;
delete: string;
save: string;
clone: string;
cancel: string;
disable: string;
disabled: string;
enabled: string;
enable: string;
issuesAndPullRequests: string;
issuesOnly: string;
pullRequestsOnly: string;
selectColumn: string;
closeIssue: string;
reopenIssue: string;
saveWorkflowFailed: string;
updateWorkflowFailed: string;
deleteWorkflowFailed: string;
atLeastOneActionRequired: string;
cloneTooltip: string;
deleteConfirm: string;
};
}>();
const store = createWorkflowStore(props);
// Provide store to child components (WorkflowEditor) so they can bind
// v-model directly without triggering vue/no-mutating-props.
provide('workflowStore', store);
// Snapshot stored before entering edit / clone mode so Cancel can restore it.
type SelectionSnapshot = {selectedItem: string | null; selectedWorkflow: WorkflowEvent | null};
const previousSelection = ref<SelectionSnapshot | null>(null);
// ── Edit-mode state ───────────────────────────────────────────────────────────
// Dedicated reactive ref for tracking edit mode (more reliable than _isEditing on workflow objects).
const editModeActive = ref(false);
// Unsaved workflows (id=0) are always in edit mode; saved ones follow editModeActive.
const isInEditMode = computed(() => {
if (!props.canWriteProjects) return false;
if (!store.selectedWorkflow) return false;
if (store.selectedWorkflow.id === 0) return true;
return editModeActive.value;
});
const setEditMode = (on: boolean) => {
editModeActive.value = on;
};
// Show cancel only when there is something meaningful to cancel to.
const showCancelButton = computed(() => {
if (!store.selectedWorkflow) return false;
return store.selectedWorkflow.id > 0 ||
Boolean(store.selectedWorkflow._clonedFromEventId) ||
store.selectedWorkflow.event_id.startsWith('clone-');
});
// A workflow is "temporary" (pending unsaved clone) when it has no DB id.
const isTemporaryWorkflow = (wf?: WorkflowEvent | null) => {
if (!wf || wf.id > 0) return false;
return Boolean(wf._clonedFromEventId) ||
wf.event_id.startsWith('clone-') ||
wf.event_id.startsWith('new-');
};
// Clone is allowed when the selected workflow is saved and has no pending clone.
const canCloneSelectedWorkflow = computed(() => {
if (!props.canWriteProjects) return false;
const sel = store.selectedWorkflow;
if (!sel || sel.id <= 0) return false;
return !store.workflowEvents.some(
(w: WorkflowEvent) => w.id === 0 && w._clonedFromEventId === sel.event_id,
);
});
// ── Sidebar helpers ───────────────────────────────────────────────────────────
// id > 0 means the workflow is saved in the database.
const isWorkflowConfigured = (wf: WorkflowEvent) => wf.id > 0;
const workflowList = computed<WorkflowEvent[]>(() =>
store.workflowEvents.map((wf: WorkflowEvent) => ({
...wf,
is_configured: isWorkflowConfigured(wf),
display_name: wf.display_name || wf.workflow_event || wf.event_id,
}))
);
const getStatusClass = (item: WorkflowEvent) => {
if (!item.is_configured) return 'status-inactive';
return item.enabled === false ? 'status-disabled' : 'status-active';
};
const getWorkflowDisplayName = (item: WorkflowEvent, _index: number) => {
const displayName = item.display_name || item.workflow_event || item.event_id || '';
const sameType = workflowList.value.filter(
(w: WorkflowEvent) => w.workflow_event === item.workflow_event && (w.is_configured || w.id === 0),
);
if (sameType.length <= 1) return displayName;
// Sort so saved workflows appear before temporary clones.
const ordered = [...sameType].sort((a, b) => {
const at = isTemporaryWorkflow(a), bt = isTemporaryWorkflow(b);
if (at !== bt) return at ? 1 : -1;
return workflowList.value.indexOf(a) - workflowList.value.indexOf(b);
});
const pos = ordered.findIndex((w: WorkflowEvent) => w.event_id === item.event_id);
return `${displayName} #${pos + 1}`;
};
// ── Draft persistence ─────────────────────────────────────────────────────────
// Persist the current form state into the draft store whenever filters/actions change.
const persistDraft = () => {
const key = store.selectedWorkflow?.event_id;
if (key) store.updateDraft(key, store.workflowFilters, store.workflowActions);
};
watch(() => store.workflowFilters, persistDraft, {deep: true});
watch(() => store.workflowActions, persistDraft, {deep: true});
// ── Navigation ────────────────────────────────────────────────────────────────
const selectWorkflowEvent = async (event: WorkflowEvent) => {
if (store.loading || store.selectedItem === event.event_id) return;
try {
editModeActive.value = false; // exit edit mode when switching workflows
store.selectedItem = event.event_id;
store.selectedWorkflow = event;
await store.loadWorkflowData(event.event_id);
window.history.pushState({event_id: event.event_id}, '', `${props.projectLink}/workflows/${event.event_id}`);
} catch (error) {
console.error('Error selecting workflow:', error);
}
};
const selectWorkflowItem = async (item: WorkflowEvent) => {
if (store.loading) return;
// Guard: clicking an already-selected item must never clear previousSelection,
// because an in-progress clone stores its "return to source" anchor there.
if (store.selectedItem === item.event_id) return;
previousSelection.value = null;
if (item.is_configured) {
await selectWorkflowEvent(item);
} else {
// For unconfigured placeholders, prefer any id=0 object already in the list.
const existing = store.workflowEvents.find(
(w: WorkflowEvent) => w.id === 0 && w.workflow_event === item.workflow_event,
);
await selectWorkflowEvent(existing || item);
}
};
const debouncedSelectWorkflowItem = debounce(150, (item: WorkflowEvent) => {
void selectWorkflowItem(item);
});
// Auto-selects first configured workflow, falling back to first item.
const autoSelectFirstWorkflow = () => {
const items = workflowList.value;
if (!items.length) return;
const first = items.find((i: WorkflowEvent) => i.is_configured) ?? items[0];
void selectWorkflowItem(first);
};
// Removes a temporary (unsaved) clone from the event list and clears its draft.
const removeTemporaryWorkflow = (wf?: WorkflowEvent | null) => {
if (!wf || !isTemporaryWorkflow(wf)) return;
const idx = store.workflowEvents.findIndex((w: WorkflowEvent) => w.event_id === wf.event_id);
if (idx >= 0) store.workflowEvents.splice(idx, 1);
store.clearDraft(wf.event_id);
};
// ── Workflow actions ──────────────────────────────────────────────────────────
const toggleEditMode = async () => {
if (!props.canWriteProjects) return;
if (!isInEditMode.value) {
// Enter edit mode: snapshot current selection for Cancel.
previousSelection.value = {
selectedItem: store.selectedItem,
selectedWorkflow: store.selectedWorkflow ? {...store.selectedWorkflow} : null,
};
setEditMode(true);
return;
}
// Cancel edit mode.
const canceled = store.selectedWorkflow;
const wasTemp = isTemporaryWorkflow(canceled);
if (wasTemp) removeTemporaryWorkflow(canceled);
if (previousSelection.value) {
store.selectedItem = previousSelection.value.selectedItem;
store.selectedWorkflow = previousSelection.value.selectedWorkflow;
if (previousSelection.value.selectedWorkflow) {
void store.loadWorkflowData(previousSelection.value.selectedWorkflow.event_id);
}
previousSelection.value = null;
} else if (wasTemp) {
// No snapshot: find the nearest workflow of the same event type.
const baseType = canceled?.workflow_event;
const fallback =
store.workflowEvents.find(
(w: WorkflowEvent) => baseType && (w.workflow_event === baseType || w.event_id === baseType),
) ?? store.workflowEvents[0];
if (fallback) {
store.selectedItem = fallback.event_id;
store.selectedWorkflow = fallback;
void store.loadWorkflowData(fallback.event_id);
} else {
store.selectedItem = null;
store.selectedWorkflow = null;
}
}
setEditMode(false);
};
const saveWorkflow = async () => {
if (!props.canWriteProjects) return;
const ok = await store.saveWorkflow();
if (ok) {
debouncedSelectWorkflowItem.cancel(); // prevent stale debounced click from overriding new selection
previousSelection.value = null;
setEditMode(false);
}
};
const toggleWorkflowStatus = async () => {
if (!props.canWriteProjects) return;
const wf = store.selectedWorkflow;
if (!wf) return;
await store.saveWorkflowStatus(!wf.enabled);
};
const deleteWorkflow = async () => {
if (!props.canWriteProjects) return;
const current = store.selectedWorkflow;
if (!current) return;
if (!await confirmModal({content: props.locale.deleteConfirm, confirmButtonColor: 'red'})) return;
if (current.id === 0) {
// Unsaved temporary workflow: just remove from list.
const idx = store.workflowEvents.findIndex((w: WorkflowEvent) => w.event_id === current.event_id);
if (idx >= 0) store.workflowEvents.splice(idx, 1);
} else {
await store.deleteWorkflow();
await store.loadEvents();
}
// Select the nearest remaining workflow of the same event type.
const sameType = store.workflowEvents.filter(
(w: WorkflowEvent) => w.workflow_event === current.workflow_event,
);
let next: WorkflowEvent | null =
sameType.find((w: WorkflowEvent) => w.is_configured || w.id > 0) ??
sameType[0] ??
store.workflowEvents.find((w: WorkflowEvent) => w.is_configured || w.id > 0) ??
store.workflowEvents[0] ??
null;
if (next) {
await selectWorkflowItem(next);
if (props.canWriteProjects && !next.is_configured && next.id === 0) {
previousSelection.value = null;
setEditMode(true);
return;
}
} else {
store.selectedItem = null;
store.selectedWorkflow = null;
window.history.pushState({}, '', `${props.projectLink}/workflows`);
}
previousSelection.value = null;
setEditMode(false);
};
const cloneWorkflow = async (sourceWorkflow?: WorkflowEvent | null) => {
if (!props.canWriteProjects) return;
if (!sourceWorkflow || !canCloneSelectedWorkflow.value) return;
// Temporary clones use the event-type string as their event_id
// (e.g. "item_opened") so the backend's "create" path is triggered on save.
const tempId = sourceWorkflow.workflow_event ?? sourceWorkflow.event_id;
const cloned: WorkflowEvent = {
id: 0,
event_id: tempId,
display_name: sourceWorkflow.display_name || sourceWorkflow.workflow_event || sourceWorkflow.event_id,
workflow_event: sourceWorkflow.workflow_event,
_clonedFromEventId: sourceWorkflow.event_id,
capabilities: sourceWorkflow.capabilities,
// toRaw() strips the Vue reactive Proxy before structuredClone; without
// it the browser throws DataCloneError because Proxies are not cloneable.
filters: structuredClone(toRaw(sourceWorkflow.filters) ?? []),
actions: structuredClone(toRaw(sourceWorkflow.actions) ?? []),
enabled: false,
is_configured: false,
};
// Insert right after the source so same-type workflows stay together.
const srcIdx = store.workflowEvents.findIndex(
(w: WorkflowEvent) => w.event_id === sourceWorkflow.event_id,
);
if (srcIdx >= 0) store.workflowEvents.splice(srcIdx + 1, 0, cloned);
else store.workflowEvents.push(cloned);
// Remember where we came from so Cancel can return.
previousSelection.value = {
selectedItem: store.selectedItem,
selectedWorkflow: store.selectedWorkflow ? {...store.selectedWorkflow} : {...sourceWorkflow},
};
store.selectedItem = tempId;
store.selectedWorkflow = cloned;
await store.loadWorkflowData(tempId);
setEditMode(true);
window.history.pushState({event_id: tempId}, '', `${props.projectLink}/workflows/${tempId}`);
};
// ── Lifecycle ─────────────────────────────────────────────────────────────────
const popstateHandler = (e: PopStateEvent) => {
if (!e.state?.event_id) return;
const found = store.workflowEvents.find(
(ev: WorkflowEvent) => ev.event_id === e.state.event_id,
);
if (found) {
void selectWorkflowEvent(found);
return;
}
// Fallback: unconfigured placeholder for this event type.
const placeholder = workflowList.value.find(
(item: WorkflowEvent) =>
!item.is_configured &&
(item.workflow_event === e.state.event_id || item.event_id === e.state.event_id),
);
if (placeholder) void selectWorkflowEvent(placeholder);
};
onMounted(async () => {
await Promise.all([store.loadEvents(), store.loadProjectOptions()]);
if (props.eventId) {
const exact = store.workflowEvents.find(
(e: WorkflowEvent) => e.event_id === props.eventId,
);
if (exact) {
store.selectedItem = props.eventId;
store.selectedWorkflow = exact;
await store.loadWorkflowData(props.eventId);
} else {
const placeholder = workflowList.value.find(
(item: WorkflowEvent) =>
!item.is_configured &&
(item.workflow_event === props.eventId || item.event_id === props.eventId),
);
if (placeholder) await selectWorkflowEvent(placeholder);
else autoSelectFirstWorkflow();
}
} else {
autoSelectFirstWorkflow();
}
window.addEventListener('popstate', popstateHandler);
});
onUnmounted(() => {
debouncedSelectWorkflowItem.cancel();
window.removeEventListener('popstate', popstateHandler);
});
</script>
<template>
<div class="workflow-container">
<WorkflowSidebar
:workflows="workflowList"
:selected-id="store.selectedItem"
:heading="locale.defaultWorkflows"
:get-display-name="getWorkflowDisplayName"
:get-status-class="getStatusClass"
@select="debouncedSelectWorkflowItem"
/>
<WorkflowEditor
:locale="locale"
:can-write-projects="canWriteProjects"
:is-in-edit-mode="isInEditMode"
:show-cancel-button="showCancelButton"
:can-clone-selected-workflow="canCloneSelectedWorkflow"
@toggle-edit-mode="toggleEditMode"
@save-workflow="saveWorkflow"
@delete-workflow="deleteWorkflow"
@toggle-workflow-status="toggleWorkflowStatus"
@clone-workflow="cloneWorkflow"
/>
</div>
</template>
<style scoped>
.workflow-container {
display: flex;
width: 100%;
height: calc(100vh - 200px);
min-height: 600px;
border: 1px solid var(--color-secondary);
border-radius: 8px;
overflow: hidden;
background: var(--color-body);
}
</style>