Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions packages/react-ui/src/app/features/flows/lib/flows-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
CreateEmptyFlowRequest,
CreateFlowFromTemplateRequest,
CreateStepRunRequestBody,
DeleteFlowsRequest,
FlowImportTemplate,
FlowOperationRequest,
FlowRun,
Expand All @@ -18,6 +19,7 @@ import {
ListFlowVersionRequest,
ListFlowsRequest,
MinimalFlow,
MoveFlowsRequest,
PopulatedFlow,
RunFlowSuccessResponse,
SeekPage,
Expand Down Expand Up @@ -121,6 +123,13 @@ export const flowsApi = {
delete(flowId: string) {
return api.delete<void>(`/v1/flows/${flowId}`);
},
deleteMany(request: DeleteFlowsRequest) {
const query = qs.stringify(request, { arrayFormat: 'repeat' });
return api.delete<void>(`/v1/flows?${query}`);
},
moveMany(request: MoveFlowsRequest) {
return api.post<void, MoveFlowsRequest>('/v1/flows/move', request);
},
count() {
return api.get<number>('/v1/flows/count');
},
Expand Down
210 changes: 208 additions & 2 deletions packages/react-ui/src/app/routes/flows/index.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,21 @@
import {
Button,
DataTable,
DataTableBulkAction,
FOLDER_ID_PARAM_NAME,
PaginationParams,
toast,
TooltipWrapper,
WarningWithIcon,
} from '@openops/components/ui';
import { useMutation } from '@tanstack/react-query';
import { t } from 'i18next';
import { CornerUpLeft, Download, Trash2 } from 'lucide-react';
import qs from 'qs';
import { useCallback, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';

import { ConfirmationDeleteDialog } from '@/app/common/components/delete-dialog';
import { useCheckAccessAndRedirect } from '@/app/common/hooks/authorization-hooks';
import { useDefaultSidebarState } from '@/app/common/hooks/use-default-sidebar-state';
import { isModifierOrMiddleClick } from '@/app/common/navigation/table-navigation-helper';
Expand All @@ -15,29 +24,66 @@ import {
createColumns,
} from '@/app/features/flows/flows-columns';
import { flowsApi } from '@/app/features/flows/lib/flows-api';
import { flowsUtils } from '@/app/features/flows/lib/flows-utils';
import { FlowsFolderBreadcrumbsManager } from '@/app/features/folders/component/folder-breadcrumbs-manager';
import {
MoveToFolderDialog,
MoveToFolderFormSchema,
} from '@/app/features/folders/component/move-to-folder-dialog';
import { useRefetchFolderTree } from '@/app/features/folders/hooks/refetch-folder-tree';
import { FLOWS_TABLE_FILTERS } from '@/app/features/folders/lib/flows-table-filters';
import { isSortDirection } from '@/app/lib/sort-direction';
import {
FlowSortBy,
FlowStatus,
FlowVersionState,
Permission,
PopulatedFlow,
UNCATEGORIZED_FOLDER_ID,
} from '@openops/shared';

const isFlowSortBy = (sortBy?: string): sortBy is FlowSortBy => {
return !!sortBy && Object.values(FlowSortBy).includes(sortBy as FlowSortBy);
};

const getFlowIds = (rows: PopulatedFlow[]): string[] => {
return rows.map((flow) => flow.id);
};

const FlowsPage = () => {
useDefaultSidebarState('expanded');
const hasAccess = useCheckAccessAndRedirect(Permission.READ_FLOW);
const navigate = useNavigate();
const [tableRefresh, setTableRefresh] = useState(false);
const [selectedRows, setSelectedRows] = useState<PopulatedFlow[]>([]);
const refetchFolderTree = useRefetchFolderTree();
const onTableRefresh = useCallback(
() => setTableRefresh((prev) => !prev),
[],
);
const { mutateAsync: deleteFlows, isPending: isDeleteFlowsPending } =
useMutation({
mutationFn: async (flowIds: string[]) => {
await flowsApi.deleteMany({ flowIds });
},
});
const { mutateAsync: exportFlows, isPending: isExportFlowsPending } =
useMutation({
mutationFn: async (flows: PopulatedFlow[]) => {
await Promise.all(
flows.map((flow) =>
flowsUtils.downloadFlow(flow.id, flow.version.id),
),
);
},
onSuccess: () => {
toast({
title: t('Success'),
description: t('Workflows have been exported.'),
duration: 3000,
});
},
});

const [searchParams] = useSearchParams();

Expand Down Expand Up @@ -71,7 +117,164 @@ const FlowsPage = () => {
createColumns(onTableRefresh).filter(
(column) => column.accessorKey !== 'folderId',
),
[],
[onTableRefresh],
);

const resetSelectedRows = useCallback(() => {
setSelectedRows([]);
}, []);

const completeBulkAction = useCallback(
(resetSelection: () => void) => {
resetSelection();
resetSelectedRows();
onTableRefresh();
},
[onTableRefresh, resetSelectedRows],
);

const moveSelectedFlows = useCallback(

@alexandrudanpop alexandrudanpop May 6, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

move to Uncategoriezed does not work, I think we could remove that option (it does not exist also on flow level in the 3dots menu)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks! it's fixed

async (folderId: string) => {
await flowsApi.moveMany({
flowIds: getFlowIds(selectedRows),
folderId,
});
},
[selectedRows],
);

const deleteSelectedFlows = useCallback(async () => {
await deleteFlows(getFlowIds(selectedRows));
await refetchFolderTree();
}, [deleteFlows, refetchFolderTree, selectedRows]);

const bulkDeleteBlockedByEnabled = useMemo(
() => selectedRows.some((flow) => flow.status === FlowStatus.ENABLED),
[selectedRows],
);

const prevBulkDeleteBlockedByEnabled = useRef(false);
useEffect(() => {
if (bulkDeleteBlockedByEnabled && !prevBulkDeleteBlockedByEnabled.current) {
toast({
title: t('Enabled workflows cannot be deleted'),
description: t(
'Disable the selected workflows first, then you can delete them.',
),
duration: 5000,
});
}
prevBulkDeleteBlockedByEnabled.current = bulkDeleteBlockedByEnabled;
}, [bulkDeleteBlockedByEnabled]);

const exportSelectedFlows = useCallback(async () => {
await exportFlows(selectedRows);
}, [exportFlows, selectedRows]);

const bulkActions = useMemo<DataTableBulkAction<PopulatedFlow>[]>(
() => [
{
render: (_selectedRows, resetSelection) => (
<MoveToFolderDialog
displayName={t('{count} selected workflows', {
count: selectedRows.length,
})}
excludeFolderIds={[UNCATEGORIZED_FOLDER_ID]}
apiMutateFn={async (data: MoveToFolderFormSchema) => {
await moveSelectedFlows(data.folder);
return { success: true };
}}
onMoveTo={() => completeBulkAction(resetSelection)}
>
<Button variant="outline" size="sm" className="gap-2">
<CornerUpLeft className="h-4 w-4" />
{t('Move To')}
</Button>
</MoveToFolderDialog>
),
},
{
render: (_selectedRows, _resetSelection) => (
<Button
variant="outline"
size="sm"
className="gap-2"
loading={isExportFlowsPending}
onClick={exportSelectedFlows}
>
<Download className="h-4 w-4" />
{isExportFlowsPending ? t('Exporting') : t('Export')}
</Button>
),
},
{
render: (_selectedRows, resetSelection) => (
<TooltipWrapper
tooltipText={
bulkDeleteBlockedByEnabled
? t(
'Disable all selected workflows before you can delete them.',
)
: undefined
}
>
<span className="inline-flex">
<ConfirmationDeleteDialog
title={
<span className="text-primary text-[22px]">
{t('Delete workflows')}
</span>
}
className="max-w-[700px]"
message={
<span className="max-w-[652px] block text-primary text-base font-medium">
{t('Are you sure you want to delete {count} workflows?', {
count: selectedRows.length,
})}
</span>
}
mutationFn={async () => {
await deleteSelectedFlows();
completeBulkAction(resetSelection);
}}
entityName={t('workflows')}
content={
<WarningWithIcon
message={t(
'Deleting workflows will permanently remove all data and stop any ongoing runs.',
)}
/>
}
>
<Button
variant="destructive"
size="sm"
className="gap-2"
loading={isDeleteFlowsPending}
disabled={bulkDeleteBlockedByEnabled}
>
<Trash2 className="h-4 w-4" />
{t('Delete')}
</Button>
</ConfirmationDeleteDialog>
</span>
</TooltipWrapper>
),
},
],
[
deleteFlows,
isDeleteFlowsPending,
isExportFlowsPending,
onTableRefresh,
exportSelectedFlows,
completeBulkAction,
deleteSelectedFlows,
moveSelectedFlows,
refetchFolderTree,
selectedRows,
bulkDeleteBlockedByEnabled,
],
);

if (!hasAccess) {
Expand All @@ -93,6 +296,9 @@ const FlowsPage = () => {
columnVisibility={columnVisibility}
navigationExcludedColumns={['status', 'actions']}
refresh={tableRefresh}
enableSelection={true}
onSelectedRowsChange={(rows) => setSelectedRows(rows)}
bulkActions={bulkActions}
getRowHref={(row) =>
`/flows/${row.id}?${qs.stringify({
folderId: searchParams.get(FOLDER_ID_PARAM_NAME),
Expand Down
33 changes: 33 additions & 0 deletions packages/server/api/src/app/flows/flow/flow-validations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,44 @@ import {
ContentType,
ErrorCode,
Flow,
FlowStatus,
isNil,
PopulatedFlow,
} from '@openops/shared';
import dayjs from 'dayjs';

/**
* Ensures every requested flow id exists in the project (same count as returned rows).
* Call after loading flows with `In(flowIds)` and `projectId`.
*/
export function assertAllRequestedFlowsExistInProject(
requestedFlowIds: string[],
flowsFromDb: Flow[],
): void {
if (flowsFromDb.length !== requestedFlowIds.length) {
throw new ApplicationError({
code: ErrorCode.ENTITY_NOT_FOUND,
params: {},
});
}
}

export function assertNoFlowsAreEnabledForDeletion(flows: Flow[]): void {
const hasEnabled = flows.some((flow) => flow.status === FlowStatus.ENABLED);
if (hasEnabled) {
throw new ApplicationError({
code: ErrorCode.FLOW_OPERATION_INVALID,
params: {},
});
}
}

export async function assertNoFlowsAreInternal(flows: Flow[]): Promise<void> {
for (const flow of flows) {
await assertThatFlowIsNotInternal(flow);
}
}

export async function assertThatFlowIsNotInternal(flow: Flow): Promise<void> {
if (flow.isInternal) {
const message =
Expand Down
Loading
Loading