Skip to content
Open
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
172 changes: 171 additions & 1 deletion packages/react-ui/src/app/routes/flows/index.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
import {
Button,
DataTable,
DataTableBulkAction,
FOLDER_ID_PARAM_NAME,
PaginationParams,
toast,
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 { 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 +23,65 @@ 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,
} 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 +115,130 @@ 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(
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 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,
})}
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) => (
<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}
>
<Trash2 className="h-4 w-4" />
{t('Delete')}
</Button>
</ConfirmationDeleteDialog>
),
},
],
[
deleteFlows,
isDeleteFlowsPending,
isExportFlowsPending,
onTableRefresh,
exportSelectedFlows,
completeBulkAction,
deleteSelectedFlows,
moveSelectedFlows,
refetchFolderTree,
selectedRows,
],
);

if (!hasAccess) {
Expand All @@ -93,6 +260,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
59 changes: 59 additions & 0 deletions packages/server/api/src/app/flows/flow/flow.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
CountFlowsRequest,
CreateEmptyFlowRequest,
CreateFlowFromTemplateRequest,
DeleteFlowsRequest,
ErrorCode,
ExecutionType,
FlowOperationRequest,
Expand All @@ -17,6 +18,7 @@ import {
GetFlowTemplateRequestQuery,
ListFlowsRequest,
ListFlowVersionRequest,
MoveFlowsRequest,
OpenOpsId,
openOpsId,
Permission,
Expand Down Expand Up @@ -94,6 +96,16 @@ export const flowController: FastifyPluginAsyncTypebox = async (app) => {
return updatedFlow;
});

app.post('/move', MoveFlowsRequestOptions, async (request, reply) => {
await flowService.moveMany({
flowIds: request.body.flowIds,
folderId: request.body.folderId ?? null,
projectId: request.principal.projectId,
});

return reply.status(StatusCodes.NO_CONTENT).send();
});

app.get('/', ListFlowsRequestOptions, async (request) => {
// TODO: use ListFlowsRequest.versionState to filter flows by version state
return flowService.list({
Expand Down Expand Up @@ -143,6 +155,16 @@ export const flowController: FastifyPluginAsyncTypebox = async (app) => {
return reply.status(StatusCodes.NO_CONTENT).send();
});

app.delete('/', DeleteFlowsRequestOptions, async (request, reply) => {
await flowService.deleteMany({
flowIds: request.query.flowIds,
userId: request.principal.id,
projectId: request.principal.projectId,
});

return reply.status(StatusCodes.NO_CONTENT).send();
});

app.get('/:id/versions', GetFlowVersionRequestOptions, async (request) => {
const flow = await flowService.getOneOrThrow({
id: request.params.id,
Expand Down Expand Up @@ -306,6 +328,24 @@ const UpdateFlowRequestOptions = {
},
};

const MoveFlowsRequestOptions = {
config: {
security: getProjectScopedRoutePolicy({
allowedPrincipals: [PrincipalType.USER, PrincipalType.SERVICE],
permission: Permission.WRITE_FLOW,
}),
},
schema: {
tags: ['flows'],
description: 'Move multiple flows to another folder in a single request.',
security: [SERVICE_KEY_SECURITY_OPENAPI],
body: MoveFlowsRequest,
response: {
[StatusCodes.NO_CONTENT]: Type.Never(),
},
},
};

const ListFlowsRequestOptions = {
config: {
security: getProjectScopedRoutePolicy({
Expand Down Expand Up @@ -429,6 +469,25 @@ const DeleteFlowRequestOptions = {
},
};

const DeleteFlowsRequestOptions = {
config: {
security: getProjectScopedRoutePolicy({
allowedPrincipals: [PrincipalType.USER, PrincipalType.SERVICE],
permission: Permission.DELETE_FLOW,
}),
},
schema: {
tags: ['flows'],
security: [SERVICE_KEY_SECURITY_OPENAPI],
description:
'Permanently delete multiple flows and all associated versions. This operation cannot be undone.',
querystring: DeleteFlowsRequest,
response: {
[StatusCodes.NO_CONTENT]: Type.Never(),
},
},
};

const RunFlowRequestOptions = {
config: {
security: getProjectScopedRoutePolicy({
Expand Down
Loading
Loading