From 92a31eb4bf820847d4fc4e0e4c3d2fe2b647edb8 Mon Sep 17 00:00:00 2001 From: Roman Date: Wed, 15 Apr 2026 17:17:13 +0300 Subject: [PATCH 1/9] Implement sorting functionality for project tables --- .../components/connection-table.tsx | 31 +++++ .../flow-runs/hooks/useRunsTableColumns.tsx | 2 + .../src/app/features/flows/flows-columns.tsx | 4 + .../features/home/flows-table/flows-table.tsx | 2 + .../features/home/runs-table/runs-table.tsx | 2 + .../react-ui/src/app/routes/flows/index.tsx | 28 ++++- .../react-ui/src/app/routes/runs/index.tsx | 36 +++++- .../app-connection-service.ts | 58 ++++++++- .../app-connection.controller.ts | 2 + .../app/flows/flow-run/flow-run-controller.ts | 2 + .../app/flows/flow-run/flow-run-service.ts | 72 ++++++++++- .../api/src/app/flows/flow/flow.controller.ts | 2 + .../api/src/app/flows/flow/flow.service.ts | 63 +++++++++- .../ce/flows/flow-run/list-flow-runs.test.ts | 94 +++++++++++++- .../test/integration/ce/flows/flow.test.ts | 118 ++++++++++++++++++ .../app-connection-service.test.ts | 30 +++++ .../dto/read-app-connection-request.ts | 13 ++ .../flow-run/dto/list-flow-runs-request.ts | 14 +++ .../src/lib/flows/dto/list-flows-request.ts | 13 ++ .../src/ui/data-table-column-header.tsx | 44 ++++++- packages/ui-components/src/ui/data-table.tsx | 70 ++++++++++- 21 files changed, 680 insertions(+), 20 deletions(-) diff --git a/packages/react-ui/src/app/features/connections/components/connection-table.tsx b/packages/react-ui/src/app/features/connections/components/connection-table.tsx index 2dcdf64b22..eb2f2380b8 100644 --- a/packages/react-ui/src/app/features/connections/components/connection-table.tsx +++ b/packages/react-ui/src/app/features/connections/components/connection-table.tsx @@ -16,6 +16,8 @@ import { } from '@openops/components/ui'; import { AppConnection, + AppConnectionSortBy, + AppConnectionSortDirection, AppConnectionStatus, MinimalFlow, Permission, @@ -34,6 +36,26 @@ import { useConnectionsContext } from './connections-context'; import { DeleteConnectionDialog } from './delete-connection-dialog'; import { EditConnectionDialog } from './edit-connection-dialog'; +const isAppConnectionSortBy = ( + sortBy?: string, +): sortBy is AppConnectionSortBy => { + return ( + !!sortBy && + Object.values(AppConnectionSortBy).includes(sortBy as AppConnectionSortBy) + ); +}; + +const isAppConnectionSortDirection = ( + sortDirection?: string, +): sortDirection is AppConnectionSortDirection => { + return ( + !!sortDirection && + Object.values(AppConnectionSortDirection).includes( + sortDirection as AppConnectionSortDirection, + ) + ); +}; + type BlockIconWithBlockNameProps = { authProviderKey: string; }; @@ -160,6 +182,7 @@ const columns: ( return [ { accessorKey: 'authProviderKey', + enableSorting: false, header: ({ column }) => ( ), @@ -230,6 +253,7 @@ const columns: ( }, { accessorKey: 'actions', + enableSorting: false, header: ({ column }) => ( ), @@ -264,6 +288,12 @@ const fetchData = async ( cursor: pagination.cursor, limit: pagination.limit ?? 10, status: params.status, + sortBy: isAppConnectionSortBy(pagination.sortBy) + ? pagination.sortBy + : undefined, + sortDirection: isAppConnectionSortDirection(pagination.sortDirection) + ? pagination.sortDirection + : undefined, }); }; @@ -278,6 +308,7 @@ function AppConnectionsTable() { fetchData={fetchData} refresh={refresh} filters={filters} + enableSorting={true} /> diff --git a/packages/react-ui/src/app/features/flow-runs/hooks/useRunsTableColumns.tsx b/packages/react-ui/src/app/features/flow-runs/hooks/useRunsTableColumns.tsx index 81d60bcbe3..1f6f5cc97c 100644 --- a/packages/react-ui/src/app/features/flow-runs/hooks/useRunsTableColumns.tsx +++ b/packages/react-ui/src/app/features/flow-runs/hooks/useRunsTableColumns.tsx @@ -137,6 +137,7 @@ export const useRunsTableColumns = ({ }, { accessorKey: 'duration', + enableSorting: false, header: ({ column }) => ( ), @@ -151,6 +152,7 @@ export const useRunsTableColumns = ({ }, { accessorKey: 'actions', + enableSorting: false, header: () => null, cell: ({ row }) => { const isFailed = isFailedState(row.original.status); diff --git a/packages/react-ui/src/app/features/flows/flows-columns.tsx b/packages/react-ui/src/app/features/flows/flows-columns.tsx index 68846e9f15..f7f1f03ed1 100644 --- a/packages/react-ui/src/app/features/flows/flows-columns.tsx +++ b/packages/react-ui/src/app/features/flows/flows-columns.tsx @@ -34,6 +34,7 @@ export const nameColumn: FlowColumnDef = { export const integrationsColumn: FlowColumnDef = { accessorKey: 'integrations', + enableSorting: false, header: ({ column }) => ( ), @@ -98,6 +99,7 @@ export const updatedColumn: FlowColumnDef = { export const statusColumn: FlowColumnDef = { accessorKey: 'status', + enableSorting: false, header: ({ column }) => ( ), @@ -118,6 +120,7 @@ export const statusColumn: FlowColumnDef = { export const validColumn: FlowColumnDef = { accessorKey: 'valid', + enableSorting: false, header: ({ column }) => , cell: ({ row }) => { const valid = row.original.version.valid; @@ -154,6 +157,7 @@ export const createActionsColumn = ( onTableRefresh: () => void, ): FlowColumnDef => ({ accessorKey: 'actions', + enableSorting: false, header: ({ column }) => , cell: ({ row }) => { const flow = row.original; diff --git a/packages/react-ui/src/app/features/home/flows-table/flows-table.tsx b/packages/react-ui/src/app/features/home/flows-table/flows-table.tsx index 80ac2f7f9e..7aac67ea0d 100644 --- a/packages/react-ui/src/app/features/home/flows-table/flows-table.tsx +++ b/packages/react-ui/src/app/features/home/flows-table/flows-table.tsx @@ -53,6 +53,8 @@ const HomeFlowsTable = ({ { { + return !!sortBy && Object.values(FlowSortBy).includes(sortBy as FlowSortBy); +}; + +const isFlowSortDirection = ( + sortDirection?: string, +): sortDirection is FlowSortDirection => { + return ( + !!sortDirection && + Object.values(FlowSortDirection).includes( + sortDirection as FlowSortDirection, + ) + ); +}; const FlowsPage = () => { useDefaultSidebarState('expanded'); @@ -43,6 +64,10 @@ const FlowsPage = () => { return flowsApi.list({ cursor: pagination.cursor, limit: pagination.limit ?? 10, + sortBy: isFlowSortBy(pagination.sortBy) ? pagination.sortBy : undefined, + sortDirection: isFlowSortDirection(pagination.sortDirection) + ? pagination.sortDirection + : undefined, status: params.status, versionState: params.versionState, name: params.name, @@ -75,6 +100,7 @@ const FlowsPage = () => { columns={columns} fetchData={fetchData} filters={FLOWS_TABLE_FILTERS} + enableSorting={true} columnVisibility={columnVisibility} navigationExcludedColumns={['status', 'actions']} refresh={tableRefresh} diff --git a/packages/react-ui/src/app/routes/runs/index.tsx b/packages/react-ui/src/app/routes/runs/index.tsx index 684f82fe90..078d1e262f 100644 --- a/packages/react-ui/src/app/routes/runs/index.tsx +++ b/packages/react-ui/src/app/routes/runs/index.tsx @@ -1,5 +1,10 @@ import { DataTable, PaginationParams } from '@openops/components/ui'; -import { FlowRunStatus, FlowRunTriggerSource } from '@openops/shared'; +import { + FlowRunSortBy, + FlowRunSortDirection, + FlowRunStatus, + FlowRunTriggerSource, +} from '@openops/shared'; import { t } from 'i18next'; import { CheckIcon } from 'lucide-react'; import { useEffect, useMemo, useState } from 'react'; @@ -13,6 +18,30 @@ import { flowRunsApi } from '@/app/features/flow-runs/lib/flow-runs-api'; import { flowsHooks } from '@/app/features/flows/lib/flows-hooks'; import { formatUtils } from '@/app/lib/utils'; +const isFlowRunSortBy = (sortBy?: string): sortBy is FlowRunSortBy => { + return ( + !!sortBy && Object.values(FlowRunSortBy).includes(sortBy as FlowRunSortBy) + ); +}; + +const isFlowRunSortDirection = ( + sortDirection?: string, +): sortDirection is FlowRunSortDirection => { + return ( + !!sortDirection && + Object.values(FlowRunSortDirection).includes( + sortDirection as FlowRunSortDirection, + ) + ); +}; + +const toFlowRunSortBy = (sortBy?: string): FlowRunSortBy | undefined => { + if (sortBy === 'flowId') { + return FlowRunSortBy.FLOW_NAME; + } + return isFlowRunSortBy(sortBy) ? sortBy : undefined; +}; + const fetchData = async ( params: { flowId: string[]; @@ -31,6 +60,10 @@ const fetchData = async ( limit: pagination.limit ?? 10, createdAfter: pagination.createdAfter, createdBefore: pagination.createdBefore, + sortBy: toFlowRunSortBy(pagination.sortBy), + sortDirection: isFlowRunSortDirection(pagination.sortDirection) + ? pagination.sortDirection + : undefined, }); }; @@ -110,6 +143,7 @@ const FlowRunsPage = () => { { @@ -225,20 +229,26 @@ export const appConnectionService = { limit, connectionsIds, authProviders, + sortBy, + sortDirection, }: ListParams): Promise> { + const sortingConfig = resolveAppConnectionSorting({ + sortBy, + sortDirection, + }); const decodedCursor = paginationHelper.decodeCursor(cursorRequest); const paginator = buildPaginator({ entity: AppConnectionEntity, query: { limit, - order: 'DESC', + order: sortingConfig.order, afterCursor: decodedCursor.nextCursor, beforeCursor: decodedCursor.previousCursor, }, customPaginationColumn: { - columnPath: 'updated', - columnName: 'app_connection.updated', + columnPath: sortingConfig.columnPath, + columnName: sortingConfig.columnName, }, }); @@ -540,6 +550,8 @@ type ListParams = { status: AppConnectionStatus[] | undefined; limit: number; authProviders: string[] | undefined; + sortBy?: AppConnectionSortBy; + sortDirection?: AppConnectionSortDirection; }; type CountByProjectParams = { @@ -550,3 +562,43 @@ type ValidateConnectionValueParams = { connection: UpsertAppConnectionRequestBody; projectId: ProjectId; }; + +function resolveAppConnectionSorting({ + sortBy, + sortDirection, +}: { + sortBy?: AppConnectionSortBy; + sortDirection?: AppConnectionSortDirection; +}): { + columnPath: string; + columnName: string; + order: 'ASC' | 'DESC'; +} { + const resolvedSortBy = sortBy ?? DEFAULT_APP_CONNECTION_SORT_BY; + const resolvedSortDirection = + sortDirection ?? DEFAULT_APP_CONNECTION_SORT_DIRECTION; + + const sortByToColumnMap: Record< + AppConnectionSortBy, + { columnPath: string; columnName: string } + > = { + [AppConnectionSortBy.NAME]: { + columnPath: 'name', + columnName: 'app_connection.name', + }, + [AppConnectionSortBy.CREATED]: { + columnPath: 'created', + columnName: 'app_connection.created', + }, + [AppConnectionSortBy.UPDATED]: { + columnPath: 'updated', + columnName: 'app_connection.updated', + }, + }; + + return { + ...sortByToColumnMap[resolvedSortBy], + order: + resolvedSortDirection === AppConnectionSortDirection.ASC ? 'ASC' : 'DESC', + }; +} diff --git a/packages/server/api/src/app/app-connection/app-connection.controller.ts b/packages/server/api/src/app/app-connection/app-connection.controller.ts index 0f7f5671db..54bd9345d5 100644 --- a/packages/server/api/src/app/app-connection/app-connection.controller.ts +++ b/packages/server/api/src/app/app-connection/app-connection.controller.ts @@ -79,6 +79,8 @@ export const appConnectionController: FastifyPluginCallbackTypebox = ( cursorRequest: cursor ?? null, limit: limit ?? DEFAULT_PAGE_SIZE, authProviders, + sortBy: request.query.sortBy, + sortDirection: request.query.sortDirection, }); return { diff --git a/packages/server/api/src/app/flows/flow-run/flow-run-controller.ts b/packages/server/api/src/app/flows/flow-run/flow-run-controller.ts index 2fda81878d..7da9d1d21b 100644 --- a/packages/server/api/src/app/flows/flow-run/flow-run-controller.ts +++ b/packages/server/api/src/app/flows/flow-run/flow-run-controller.ts @@ -47,6 +47,8 @@ export const flowRunController: FastifyPluginCallbackTypebox = ( limit: Number(request.query.limit ?? DEFAULT_PAGING_LIMIT), createdAfter: request.query.createdAfter, createdBefore: request.query.createdBefore, + sortBy: request.query.sortBy, + sortDirection: request.query.sortDirection, }); }); diff --git a/packages/server/api/src/app/flows/flow-run/flow-run-service.ts b/packages/server/api/src/app/flows/flow-run/flow-run-service.ts index 3204a81241..e15d320890 100644 --- a/packages/server/api/src/app/flows/flow-run/flow-run-service.ts +++ b/packages/server/api/src/app/flows/flow-run/flow-run-service.ts @@ -12,6 +12,8 @@ import { FlowRetryStrategy, FlowRun, FlowRunId, + FlowRunSortBy, + FlowRunSortDirection, FlowRunStatus, FlowRunTriggerSource, FlowVersionId, @@ -177,16 +179,32 @@ export const flowRunService = { tags, createdAfter, createdBefore, + sortBy, + sortDirection, }: ListParams): Promise> { + const sortingConfig = resolveFlowRunSorting({ + sortBy, + sortDirection, + }); const decodedCursor = paginationHelper.decodeCursor(cursor); const paginator = buildPaginator({ entity: FlowRunEntity, query: { limit, - order: Order.DESC, + order: sortingConfig.order, afterCursor: decodedCursor.nextCursor, beforeCursor: decodedCursor.previousCursor, }, + customPaginationColumn: { + columnPath: sortingConfig.columnPath, + columnName: sortingConfig.columnName, + columnType: sortingConfig.columnType, + }, + customPaginationSecondaryColumn: { + columnPath: 'id', + columnName: 'flow_run.id', + columnType: 'string', + }, }); let query = flowRunRepo().createQueryBuilder('flow_run').where({ @@ -657,6 +675,8 @@ type ListParams = { limit: number; createdAfter?: string; createdBefore?: string; + sortBy?: FlowRunSortBy; + sortDirection?: FlowRunSortDirection; }; type GetOneParams = { @@ -692,3 +712,53 @@ type RetryParams = { flowRunId: FlowRunId; strategy: FlowRetryStrategy; }; + +function resolveFlowRunSorting({ + sortBy, + sortDirection, +}: { + sortBy?: FlowRunSortBy; + sortDirection?: FlowRunSortDirection; +}): { + columnPath: string; + columnName: string; + columnType: string; + order: Order; +} { + const resolvedSortBy = sortBy ?? FlowRunSortBy.CREATED; + const resolvedSortDirection = sortDirection ?? FlowRunSortDirection.DESC; + + const sortByToColumnMap: Record< + FlowRunSortBy, + { columnPath: string; columnName: string; columnType: string } + > = { + [FlowRunSortBy.FLOW_NAME]: { + columnPath: 'flowDisplayName', + columnName: 'flow_run.flowDisplayName', + columnType: 'string', + }, + [FlowRunSortBy.STATUS]: { + columnPath: 'status', + columnName: 'flow_run.status', + columnType: 'string', + }, + [FlowRunSortBy.TRIGGER_SOURCE]: { + columnPath: 'triggerSource', + columnName: 'flow_run.triggerSource', + columnType: 'string', + }, + [FlowRunSortBy.CREATED]: { + columnPath: 'created', + columnName: 'flow_run.created', + columnType: 'timestamp with time zone', + }, + }; + + return { + ...sortByToColumnMap[resolvedSortBy], + order: + resolvedSortDirection === FlowRunSortDirection.ASC + ? Order.ASC + : Order.DESC, + }; +} diff --git a/packages/server/api/src/app/flows/flow/flow.controller.ts b/packages/server/api/src/app/flows/flow/flow.controller.ts index 99837b33d5..4c1efd8944 100644 --- a/packages/server/api/src/app/flows/flow/flow.controller.ts +++ b/packages/server/api/src/app/flows/flow/flow.controller.ts @@ -104,6 +104,8 @@ export const flowController: FastifyPluginAsyncTypebox = async (app) => { status: request.query.status, name: request.query.name, versionState: request.query.versionState ?? null, + sortBy: request.query.sortBy, + sortDirection: request.query.sortDirection, }); }); diff --git a/packages/server/api/src/app/flows/flow/flow.service.ts b/packages/server/api/src/app/flows/flow/flow.service.ts index 88c373034d..bf4e81fa40 100644 --- a/packages/server/api/src/app/flows/flow/flow.service.ts +++ b/packages/server/api/src/app/flows/flow/flow.service.ts @@ -16,6 +16,8 @@ import { FlowId, FlowOperationRequest, FlowOperationType, + FlowSortBy, + FlowSortDirection, FlowStatus, FlowTemplateWithoutProjectInformation, FlowVersion, @@ -61,6 +63,9 @@ const TRIGGER_FAILURES_THRESHOLD = system.getNumberOrThrow( AppSystemProp.TRIGGER_FAILURES_THRESHOLD, ); +const DEFAULT_FLOW_SORT_BY = FlowSortBy.UPDATED; +const DEFAULT_FLOW_SORT_DIRECTION = FlowSortDirection.DESC; + export const flowService = { async create(params: CreateParams): Promise { const result = await create(params); @@ -129,21 +134,27 @@ export const flowService = { status, name, versionState, + sortBy, + sortDirection, }: ListParams): Promise> { + const sortingConfig = resolveFlowSorting({ + sortBy, + sortDirection, + }); const decodedCursor = paginationHelper.decodeCursor(cursorRequest); const paginator = buildPaginator({ entity: FlowEntity, query: { limit, - order: 'DESC', + order: sortingConfig.order, afterCursor: decodedCursor.nextCursor, beforeCursor: decodedCursor.previousCursor, }, customPaginationColumn: { - columnPath: 'versions[0].updated', - columnName: 'fv.updated', - columnType: 'timestamp with time zone', + columnPath: sortingConfig.columnPath, + columnName: sortingConfig.columnName, + columnType: sortingConfig.columnType, }, customPaginationSecondaryColumn: { columnPath: 'id', @@ -789,6 +800,8 @@ type ListParams = { status: FlowStatus[] | undefined; name: string | undefined; versionState: FlowVersionState[] | null; + sortBy: FlowSortBy | undefined; + sortDirection: FlowSortDirection | undefined; }; type GetOneParams = { @@ -863,3 +876,45 @@ type ExistsByProjectAndStatusParams = { status: FlowStatus; entityManager: EntityManager; }; + +function resolveFlowSorting({ + sortBy, + sortDirection, +}: { + sortBy: FlowSortBy | undefined; + sortDirection: FlowSortDirection | undefined; +}): { + columnPath: string; + columnName: string; + columnType: string; + order: 'ASC' | 'DESC'; +} { + const resolvedSortBy = sortBy ?? DEFAULT_FLOW_SORT_BY; + const resolvedSortDirection = sortDirection ?? DEFAULT_FLOW_SORT_DIRECTION; + + const sortByToColumnMap: Record< + FlowSortBy, + { columnPath: string; columnName: string; columnType: string } + > = { + [FlowSortBy.NAME]: { + columnPath: 'versions[0].displayName', + columnName: 'fv.displayName', + columnType: 'string', + }, + [FlowSortBy.CREATED]: { + columnPath: 'created', + columnName: 'flow.created', + columnType: 'timestamp with time zone', + }, + [FlowSortBy.UPDATED]: { + columnPath: 'versions[0].updated', + columnName: 'fv.updated', + columnType: 'timestamp with time zone', + }, + }; + + return { + ...sortByToColumnMap[resolvedSortBy], + order: resolvedSortDirection === FlowSortDirection.ASC ? 'ASC' : 'DESC', + }; +} diff --git a/packages/server/api/test/integration/ce/flows/flow-run/list-flow-runs.test.ts b/packages/server/api/test/integration/ce/flows/flow-run/list-flow-runs.test.ts index 596a96f6c7..7d19211765 100644 --- a/packages/server/api/test/integration/ce/flows/flow-run/list-flow-runs.test.ts +++ b/packages/server/api/test/integration/ce/flows/flow-run/list-flow-runs.test.ts @@ -1,4 +1,8 @@ -import { PrincipalType } from '@openops/shared'; +import { + FlowRunSortBy, + FlowRunSortDirection, + PrincipalType, +} from '@openops/shared'; import { FastifyInstance } from 'fastify'; import { databaseConnection } from '../../../../../src/app/database/database-connection'; import { setupServer } from '../../../../../src/app/server'; @@ -89,4 +93,92 @@ describe('List flow runs endpoint', () => { expect(ids).toContain(externalRun.id); expect(ids).not.toContain(internalRun.id); }); + + it('should sort runs by flow name', async () => { + const { mockProject } = await mockBasicSetup(); + + const mockFlow = createMockFlow({ + projectId: mockProject.id, + isInternal: false, + }); + await databaseConnection().getRepository('flow').save([mockFlow]); + + const betaRun = createMockFlowRun({ + projectId: mockProject.id, + flowId: mockFlow.id, + flowDisplayName: 'Beta run', + }); + const alphaRun = createMockFlowRun({ + projectId: mockProject.id, + flowId: mockFlow.id, + flowDisplayName: 'Alpha run', + }); + await databaseConnection() + .getRepository('flow_run') + .save([betaRun, alphaRun]); + + const testToken = await generateMockToken({ + type: PrincipalType.USER, + projectId: mockProject.id, + }); + + const response = await app?.inject({ + method: 'GET', + url: '/v1/flow-runs', + query: { + sortBy: FlowRunSortBy.FLOW_NAME, + sortDirection: FlowRunSortDirection.ASC, + }, + headers: { + authorization: `Bearer ${testToken}`, + }, + }); + + expect(response?.statusCode).toBe(200); + const body = response?.json(); + expect(body?.data?.[0]?.flowDisplayName).toBe('Alpha run'); + expect(body?.data?.[1]?.flowDisplayName).toBe('Beta run'); + }); + + it('should use default sorting when sorting is not provided', async () => { + const { mockProject } = await mockBasicSetup(); + + const mockFlow = createMockFlow({ + projectId: mockProject.id, + isInternal: false, + }); + await databaseConnection().getRepository('flow').save([mockFlow]); + + const olderRun = createMockFlowRun({ + projectId: mockProject.id, + flowId: mockFlow.id, + created: '2024-01-01T00:00:00.000Z', + }); + const newerRun = createMockFlowRun({ + projectId: mockProject.id, + flowId: mockFlow.id, + created: '2024-01-02T00:00:00.000Z', + }); + await databaseConnection() + .getRepository('flow_run') + .save([olderRun, newerRun]); + + const testToken = await generateMockToken({ + type: PrincipalType.USER, + projectId: mockProject.id, + }); + + const response = await app?.inject({ + method: 'GET', + url: '/v1/flow-runs', + headers: { + authorization: `Bearer ${testToken}`, + }, + }); + + expect(response?.statusCode).toBe(200); + const body = response?.json(); + expect(body?.data?.[0]?.id).toBe(newerRun.id); + expect(body?.data?.[1]?.id).toBe(olderRun.id); + }); }); diff --git a/packages/server/api/test/integration/ce/flows/flow.test.ts b/packages/server/api/test/integration/ce/flows/flow.test.ts index 7a0a411fa3..c01fd8d6d9 100644 --- a/packages/server/api/test/integration/ce/flows/flow.test.ts +++ b/packages/server/api/test/integration/ce/flows/flow.test.ts @@ -6,6 +6,8 @@ import { import { BlockType, FlowOperationType, + FlowSortBy, + FlowSortDirection, FlowStatus, FlowTemplateDto, FlowVersionState, @@ -538,6 +540,122 @@ describe('Flow API', () => { }); describe('List Flows endpoint', () => { + it('Sorts Flows by name', async () => { + const mockUser = createMockUser(); + await databaseConnection().getRepository('user').save([mockUser]); + + const mockOrganization = createMockOrganization({ ownerId: mockUser.id }); + await databaseConnection() + .getRepository('organization') + .save(mockOrganization); + + const mockProject = createMockProject({ + ownerId: mockUser.id, + organizationId: mockOrganization.id, + }); + await databaseConnection().getRepository('project').save([mockProject]); + + const flowA = createMockFlow({ projectId: mockProject.id }); + const flowB = createMockFlow({ projectId: mockProject.id }); + await databaseConnection().getRepository('flow').save([flowA, flowB]); + + const versionA = createMockFlowVersion({ + flowId: flowA.id, + displayName: 'Beta flow', + }); + const versionB = createMockFlowVersion({ + flowId: flowB.id, + displayName: 'Alpha flow', + }); + await databaseConnection() + .getRepository('flow_version') + .save([versionA, versionB]); + + const mockToken = await generateMockToken({ + type: PrincipalType.USER, + projectId: mockProject.id, + }); + + const response = await app?.inject({ + method: 'GET', + url: '/v1/flows', + query: { + sortBy: FlowSortBy.NAME, + sortDirection: FlowSortDirection.ASC, + }, + headers: { + authorization: `Bearer ${mockToken}`, + }, + }); + + expect(response?.statusCode).toBe(StatusCodes.OK); + const responseBody = response?.json(); + + expect(responseBody.data).toHaveLength(2); + expect(responseBody.data[0].version.displayName).toBe('Alpha flow'); + expect(responseBody.data[1].version.displayName).toBe('Beta flow'); + }); + + it('Uses default sorting when sorting is not provided', async () => { + const mockUser = createMockUser(); + await databaseConnection().getRepository('user').save([mockUser]); + + const mockOrganization = createMockOrganization({ ownerId: mockUser.id }); + await databaseConnection() + .getRepository('organization') + .save(mockOrganization); + + const mockProject = createMockProject({ + ownerId: mockUser.id, + organizationId: mockOrganization.id, + }); + await databaseConnection().getRepository('project').save([mockProject]); + + const olderFlow = createMockFlow({ projectId: mockProject.id }); + const newerFlow = createMockFlow({ projectId: mockProject.id }); + await databaseConnection() + .getRepository('flow') + .save([olderFlow, newerFlow]); + + const olderVersion = createMockFlowVersion({ + flowId: olderFlow.id, + displayName: 'Older updated flow', + updated: '2024-01-01T00:00:00.000Z', + }); + const newerVersion = createMockFlowVersion({ + flowId: newerFlow.id, + displayName: 'Newer updated flow', + updated: '2024-01-02T00:00:00.000Z', + }); + await databaseConnection() + .getRepository('flow_version') + .save([olderVersion, newerVersion]); + + const mockToken = await generateMockToken({ + type: PrincipalType.USER, + projectId: mockProject.id, + }); + + const response = await app?.inject({ + method: 'GET', + url: '/v1/flows', + headers: { + authorization: `Bearer ${mockToken}`, + }, + }); + + expect(response?.statusCode).toBe(StatusCodes.OK); + const responseBody = response?.json(); + + expect(responseBody.data).toHaveLength(2); + expect(responseBody.data[0].version.displayName).toBe( + 'Newer updated flow', + ); + expect(responseBody.data[1].version.displayName).toBe( + 'Older updated flow', + ); + }); + it('Filters Flows by status', async () => { const mockUser = createMockUser(); await databaseConnection().getRepository('user').save([mockUser]); diff --git a/packages/server/api/test/unit/app-connection/app-connection-service.test.ts b/packages/server/api/test/unit/app-connection/app-connection-service.test.ts index 2c394deaf9..ae85fb12b1 100644 --- a/packages/server/api/test/unit/app-connection/app-connection-service.test.ts +++ b/packages/server/api/test/unit/app-connection/app-connection-service.test.ts @@ -56,6 +56,8 @@ jest.mock('../../../src/app/helper/pagination/pagination-utils', () => ({ import { BlockMetadataModel } from '@openops/blocks-framework'; import { encryptUtils } from '@openops/server-shared'; import { + AppConnectionSortBy, + AppConnectionSortDirection, AppConnectionStatus, AppConnectionType, ApplicationError, @@ -222,4 +224,32 @@ describe('appConnectionService.list', () => { { authProviders: ['github', 'slack'] }, ); }); + + test('should apply requested sorting for connections list', async () => { + await appConnectionService.list({ + projectId, + cursorRequest: null, + name: undefined, + status: undefined, + limit: 10, + connectionsIds: undefined, + authProviders: undefined, + sortBy: AppConnectionSortBy.NAME, + sortDirection: AppConnectionSortDirection.ASC, + }); + + expect(buildPaginator).toHaveBeenCalledWith({ + entity: AppConnectionEntity, + query: { + limit: 10, + order: 'ASC', + afterCursor: null, + beforeCursor: null, + }, + customPaginationColumn: { + columnPath: 'name', + columnName: 'app_connection.name', + }, + }); + }); }); diff --git a/packages/shared/src/lib/app-connection/dto/read-app-connection-request.ts b/packages/shared/src/lib/app-connection/dto/read-app-connection-request.ts index 1ac291ae59..69c3cdfc61 100644 --- a/packages/shared/src/lib/app-connection/dto/read-app-connection-request.ts +++ b/packages/shared/src/lib/app-connection/dto/read-app-connection-request.ts @@ -1,12 +1,25 @@ import { Static, Type } from '@sinclair/typebox'; import { AppConnectionStatus } from '../app-connection'; +export enum AppConnectionSortBy { + NAME = 'name', + CREATED = 'created', + UPDATED = 'updated', +} + +export enum AppConnectionSortDirection { + ASC = 'asc', + DESC = 'desc', +} + export const ListAppConnectionsRequestQuery = Type.Object({ cursor: Type.Optional(Type.String({})), name: Type.Optional(Type.String({})), status: Type.Optional(Type.Array(Type.Enum(AppConnectionStatus))), limit: Type.Optional(Type.Number({})), authProviders: Type.Optional(Type.Array(Type.String({}))), + sortBy: Type.Optional(Type.Enum(AppConnectionSortBy)), + sortDirection: Type.Optional(Type.Enum(AppConnectionSortDirection)), }); export type ListAppConnectionsRequestQuery = Static< typeof ListAppConnectionsRequestQuery diff --git a/packages/shared/src/lib/flow-run/dto/list-flow-runs-request.ts b/packages/shared/src/lib/flow-run/dto/list-flow-runs-request.ts index ab8b5c05cb..79483a78cb 100644 --- a/packages/shared/src/lib/flow-run/dto/list-flow-runs-request.ts +++ b/packages/shared/src/lib/flow-run/dto/list-flow-runs-request.ts @@ -3,6 +3,18 @@ import { OpenOpsId } from '../../common/id-generator'; import { FlowRunStatus } from '../execution/flow-execution'; import { FlowRunTriggerSource } from '../flow-run'; +export enum FlowRunSortBy { + FLOW_NAME = 'flowName', + STATUS = 'status', + TRIGGER_SOURCE = 'triggerSource', + CREATED = 'created', +} + +export enum FlowRunSortDirection { + ASC = 'asc', + DESC = 'desc', +} + export const ListFlowRunsRequestQuery = Type.Object({ flowId: Type.Optional(Type.Array(OpenOpsId)), tags: Type.Optional(Type.Array(Type.String({}))), @@ -12,6 +24,8 @@ export const ListFlowRunsRequestQuery = Type.Object({ cursor: Type.Optional(Type.String({})), createdAfter: Type.Optional(Type.String({})), createdBefore: Type.Optional(Type.String({})), + sortBy: Type.Optional(Type.Enum(FlowRunSortBy)), + sortDirection: Type.Optional(Type.Enum(FlowRunSortDirection)), }); export type ListFlowRunsRequestQuery = Static; diff --git a/packages/shared/src/lib/flows/dto/list-flows-request.ts b/packages/shared/src/lib/flows/dto/list-flows-request.ts index 3855f16147..0fc234c029 100644 --- a/packages/shared/src/lib/flows/dto/list-flows-request.ts +++ b/packages/shared/src/lib/flows/dto/list-flows-request.ts @@ -3,6 +3,17 @@ import { Cursor } from '../../common/seek-page'; import { FlowStatus } from '../flow'; import { FlowVersionState } from '../flow-version'; +export enum FlowSortBy { + NAME = 'name', + CREATED = 'created', + UPDATED = 'updated', +} + +export enum FlowSortDirection { + ASC = 'asc', + DESC = 'desc', +} + export const ListFlowsRequest = Type.Object({ folderId: Type.Optional(Type.String()), limit: Type.Optional(Type.Number({})), @@ -10,6 +21,8 @@ export const ListFlowsRequest = Type.Object({ status: Type.Optional(Type.Array(Type.Enum(FlowStatus))), versionState: Type.Optional(Type.Array(Type.Enum(FlowVersionState))), name: Type.Optional(Type.String({})), + sortBy: Type.Optional(Type.Enum(FlowSortBy)), + sortDirection: Type.Optional(Type.Enum(FlowSortDirection)), }); export type ListFlowsRequest = Omit< diff --git a/packages/ui-components/src/ui/data-table-column-header.tsx b/packages/ui-components/src/ui/data-table-column-header.tsx index 2fe302798b..83ac74a9a0 100644 --- a/packages/ui-components/src/ui/data-table-column-header.tsx +++ b/packages/ui-components/src/ui/data-table-column-header.tsx @@ -1,4 +1,7 @@ import { Column } from '@tanstack/react-table'; +import { ArrowDown, ArrowUp, ArrowUpDown } from 'lucide-react'; + +import { Button } from './button'; interface DataTableColumnHeaderProps extends React.HTMLAttributes { @@ -7,16 +10,45 @@ interface DataTableColumnHeaderProps } export function DataTableColumnHeader({ + column, title, className, }: DataTableColumnHeaderProps) { + const sortingState = column.getIsSorted(); + const isSortable = column.getCanSort(); + + const renderSortIcon = () => { + if (!isSortable) { + return null; + } + + if (sortingState === 'asc') { + return ; + } + + if (sortingState === 'desc') { + return ; + } + + return ; + }; + return ( -
-
- {title} -
+
+ {isSortable ? ( + + ) : ( +
+ {title} +
+ )}
); } diff --git a/packages/ui-components/src/ui/data-table.tsx b/packages/ui-components/src/ui/data-table.tsx index 6c86ef7534..e35dd2c986 100644 --- a/packages/ui-components/src/ui/data-table.tsx +++ b/packages/ui-components/src/ui/data-table.tsx @@ -4,6 +4,8 @@ import { ColumnDef, flexRender, getCoreRowModel, + getSortedRowModel, + SortingState, useReactTable, VisibilityState, } from '@tanstack/react-table'; @@ -72,6 +74,8 @@ export type PaginationParams = { limit?: number; createdAfter?: string; createdBefore?: string; + sortBy?: string; + sortDirection?: 'asc' | 'desc'; }; interface DataTableProps< @@ -103,6 +107,8 @@ interface DataTableProps< emptyStateComponent?: React.ReactNode; getRowHref?: (row: RowDataWithActions) => string | undefined; navigationExcludedColumns?: string[]; + enableSorting?: boolean; + syncWithSearchParams?: boolean; } export function DataTable< @@ -127,10 +133,13 @@ export function DataTable< emptyStateComponent, getRowHref, navigationExcludedColumns, + enableSorting = false, + syncWithSearchParams = true, }: DataTableProps) { const columns = columnsInitial.concat([ { accessorKey: '__actions', + enableSorting: false, header: ({ column }) => ( ), @@ -151,8 +160,30 @@ export function DataTable< ]); const [searchParams, setSearchParams] = useSearchParams(); - const startingCursor = searchParams.get('cursor') || undefined; - const startingLimit = searchParams.get('limit') || '10'; + const startingCursor = syncWithSearchParams + ? searchParams.get('cursor') || undefined + : undefined; + const startingLimit = + syncWithSearchParams && searchParams.get('limit') + ? searchParams.get('limit') || '10' + : '10'; + const startingSortBy = syncWithSearchParams + ? searchParams.get('sortBy') || undefined + : undefined; + const startingSortDirection = syncWithSearchParams + ? searchParams.get('sortDirection') + : null; + const hasValidStartingSortDirection = + startingSortDirection === 'asc' || startingSortDirection === 'desc'; + const initialSorting: SortingState = + enableSorting && startingSortBy && hasValidStartingSortDirection + ? [ + { + id: startingSortBy, + desc: startingSortDirection === 'desc', + }, + ] + : []; const [currentCursor, setCurrentCursor] = useState( startingCursor, ); @@ -184,6 +215,7 @@ export function DataTable< const [tableData, setTableData] = useState[]>( data ? mapDataWithActions(data) : [], ); + const [sorting, setSorting] = useState(initialSorting); const [deletedRows = [], setDeletedRows] = useState([]); const [internalLoading, setLoading] = useState(true); @@ -210,6 +242,12 @@ export function DataTable< limit: limit ? parseInt(limit) : undefined, createdAfter: params.get('createdAfter') ?? undefined, createdBefore: params.get('createdBefore') ?? undefined, + sortBy: params.get('sortBy') ?? undefined, + sortDirection: + params.get('sortDirection') === 'asc' || + params.get('sortDirection') === 'desc' + ? (params.get('sortDirection') as 'asc' | 'desc') + : undefined, }); const newData = mapDataWithActions(response.data); @@ -230,9 +268,13 @@ export function DataTable< data: tableData, columns, manualPagination: true, + enableSorting, getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + onSortingChange: setSorting, state: { columnVisibility, + sorting, }, initialState: { pagination: { @@ -264,6 +306,9 @@ export function DataTable< }, [table.getSelectedRowModel().rows]); useEffect(() => { + if (!syncWithSearchParams) { + return; + } setSearchParams( (prev) => { const newParams = new URLSearchParams(prev); @@ -273,11 +318,30 @@ export function DataTable< newParams.delete('cursor'); } newParams.set('limit', `${table.getState().pagination.pageSize}`); + if (enableSorting && sorting.length > 0) { + newParams.set('sortBy', sorting[0].id); + newParams.set('sortDirection', sorting[0].desc ? 'desc' : 'asc'); + } else { + newParams.delete('sortBy'); + newParams.delete('sortDirection'); + } return newParams; }, { replace: true }, ); - }, [currentCursor, table.getState().pagination.pageSize]); + }, [ + currentCursor, + enableSorting, + sorting, + syncWithSearchParams, + table.getState().pagination.pageSize, + ]); + + useEffect(() => { + if (enableSorting) { + setCurrentCursor(undefined); + } + }, [enableSorting, sorting]); useEffect(() => { if (fetchData) { From 2da9d8f04118e15a365fb56ccd018bb43af35bc1 Mon Sep 17 00:00:00 2001 From: Roman Date: Wed, 15 Apr 2026 17:22:45 +0300 Subject: [PATCH 2/9] Update sorting logic in DataTableColumnHeader to check for row presence before displaying sort icon --- packages/ui-components/src/ui/data-table-column-header.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/ui-components/src/ui/data-table-column-header.tsx b/packages/ui-components/src/ui/data-table-column-header.tsx index 83ac74a9a0..f7922db4a3 100644 --- a/packages/ui-components/src/ui/data-table-column-header.tsx +++ b/packages/ui-components/src/ui/data-table-column-header.tsx @@ -16,9 +16,11 @@ export function DataTableColumnHeader({ }: DataTableColumnHeaderProps) { const sortingState = column.getIsSorted(); const isSortable = column.getCanSort(); + const hasRows = column.getFacetedRowModel().rows.length > 0; + const canShowSorting = isSortable && hasRows; const renderSortIcon = () => { - if (!isSortable) { + if (!canShowSorting) { return null; } @@ -35,7 +37,7 @@ export function DataTableColumnHeader({ return (
- {isSortable ? ( + {canShowSorting ? ( + + ), + }, + { + render: (_selectedRows, _resetSelection) => ( + + ), + }, + { + render: (_selectedRows, resetSelection) => ( + + {t('Delete workflows')} + + } + className="max-w-[700px]" + message={ + + {t('Are you sure you want to delete {count} workflows?', { + count: selectedRows.length, + })} + + } + mutationFn={async () => { + await deleteFlows(selectedRows.map((flow) => flow.id)); + await refetchFolderTree(); + resetSelection(); + setSelectedRows([]); + onTableRefresh(); + }} + entityName={t('workflows')} + content={ + + } + > + + + ), + }, + ], + [ + deleteFlows, + isDeleteFlowsPending, + isExportFlowsPending, + onTableRefresh, + exportFlows, + refetchFolderTree, + selectedRows, + ], ); if (!hasAccess) { @@ -93,6 +231,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), diff --git a/packages/server/api/src/app/flows/flow/flow.controller.ts b/packages/server/api/src/app/flows/flow/flow.controller.ts index 4c1efd8944..923725d4c0 100644 --- a/packages/server/api/src/app/flows/flow/flow.controller.ts +++ b/packages/server/api/src/app/flows/flow/flow.controller.ts @@ -7,6 +7,7 @@ import { CountFlowsRequest, CreateEmptyFlowRequest, CreateFlowFromTemplateRequest, + DeleteFlowsRequest, ErrorCode, ExecutionType, FlowOperationRequest, @@ -17,6 +18,7 @@ import { GetFlowTemplateRequestQuery, ListFlowsRequest, ListFlowVersionRequest, + MoveFlowsRequest, OpenOpsId, openOpsId, Permission, @@ -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({ @@ -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, @@ -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({ @@ -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({ diff --git a/packages/server/api/src/app/flows/flow/flow.service.ts b/packages/server/api/src/app/flows/flow/flow.service.ts index c471138961..cedaa1c286 100644 --- a/packages/server/api/src/app/flows/flow/flow.service.ts +++ b/packages/server/api/src/app/flows/flow/flow.service.ts @@ -453,6 +453,60 @@ export const flowService = { } }, + async deleteMany({ + flowIds, + projectId, + userId, + }: DeleteManyParams): Promise { + await Promise.all( + flowIds.map((id) => + this.delete({ + id, + projectId, + userId, + }), + ), + ); + }, + + async moveMany({ + flowIds, + folderId, + projectId, + }: MoveManyParams): Promise { + await ensureFolderContentTypeMatches({ + projectId, + folderId, + contentType: ContentType.WORKFLOW, + }); + + const flows = await flowRepo().findBy({ + id: In(flowIds), + projectId, + }); + + if (flows.length !== flowIds.length) { + throw new ApplicationError({ + code: ErrorCode.ENTITY_NOT_FOUND, + params: {}, + }); + } + + for (const flow of flows) { + await assertThatFlowIsNotInternal(flow); + } + + await flowRepo().update( + { + id: In(flowIds), + projectId, + }, + { + folderId, + }, + ); + }, + async getAllEnabled(): Promise { return flowRepo().findBy({ status: FlowStatus.ENABLED, @@ -862,6 +916,18 @@ type DeleteParams = { projectId: ProjectId; }; +type DeleteManyParams = { + flowIds: FlowId[]; + userId: UserId; + projectId: ProjectId; +}; + +type MoveManyParams = { + flowIds: FlowId[]; + folderId: string | null; + projectId: ProjectId; +}; + type NewFlow = Omit; type LockFlowVersionIfNotLockedParams = { diff --git a/packages/server/api/test/integration/ce/flows/flow.test.ts b/packages/server/api/test/integration/ce/flows/flow.test.ts index 260ff902e2..543921b874 100644 --- a/packages/server/api/test/integration/ce/flows/flow.test.ts +++ b/packages/server/api/test/integration/ce/flows/flow.test.ts @@ -1516,6 +1516,149 @@ describe('Flow API', () => { .findOneBy({ id: mockInternalFlow.id }); expect(flowStillExists).toBeTruthy(); }); + + it('Successfully deletes multiple non-internal flows', async () => { + const mockUser = createMockUser(); + await databaseConnection().getRepository('user').save([mockUser]); + + const mockOrganization = createMockOrganization({ ownerId: mockUser.id }); + await databaseConnection() + .getRepository('organization') + .save(mockOrganization); + + const mockProject = createMockProject({ + ownerId: mockUser.id, + organizationId: mockOrganization.id, + }); + await databaseConnection().getRepository('project').save([mockProject]); + + const mockFlow1 = createMockFlow({ + projectId: mockProject.id, + isInternal: false, + }); + const mockFlow2 = createMockFlow({ + projectId: mockProject.id, + isInternal: false, + }); + await databaseConnection() + .getRepository('flow') + .save([mockFlow1, mockFlow2]); + + const mockFlowVersion1 = createMockFlowVersion({ + flowId: mockFlow1.id, + }); + const mockFlowVersion2 = createMockFlowVersion({ + flowId: mockFlow2.id, + }); + await databaseConnection() + .getRepository('flow_version') + .save([mockFlowVersion1, mockFlowVersion2]); + + const mockToken = await generateMockToken({ + type: PrincipalType.USER, + projectId: mockProject.id, + }); + + const response = await app?.inject({ + method: 'DELETE', + url: '/v1/flows', + headers: { + authorization: `Bearer ${mockToken}`, + }, + query: { + flowIds: [mockFlow1.id, mockFlow2.id], + }, + }); + + expect(response?.statusCode).toBe(StatusCodes.NO_CONTENT); + + const deletedFlow1 = await databaseConnection() + .getRepository('flow') + .findOneBy({ id: mockFlow1.id }); + const deletedFlow2 = await databaseConnection() + .getRepository('flow') + .findOneBy({ id: mockFlow2.id }); + + expect(deletedFlow1).toBeNull(); + expect(deletedFlow2).toBeNull(); + }); + }); + + describe('Move Flow endpoint', () => { + it('Successfully moves multiple non-internal flows', async () => { + const mockUser = createMockUser(); + await databaseConnection().getRepository('user').save([mockUser]); + + const mockOrganization = createMockOrganization({ ownerId: mockUser.id }); + await databaseConnection() + .getRepository('organization') + .save(mockOrganization); + + const mockProject = createMockProject({ + ownerId: mockUser.id, + organizationId: mockOrganization.id, + }); + await databaseConnection().getRepository('project').save([mockProject]); + + const sourceFolder = createMockFolder(mockProject.id); + const targetFolder = createMockFolder(mockProject.id); + await databaseConnection() + .getRepository('folder') + .save([sourceFolder, targetFolder]); + + const mockFlow1 = createMockFlow({ + projectId: mockProject.id, + isInternal: false, + folderId: sourceFolder.id, + }); + const mockFlow2 = createMockFlow({ + projectId: mockProject.id, + isInternal: false, + folderId: sourceFolder.id, + }); + await databaseConnection() + .getRepository('flow') + .save([mockFlow1, mockFlow2]); + + const mockFlowVersion1 = createMockFlowVersion({ + flowId: mockFlow1.id, + }); + const mockFlowVersion2 = createMockFlowVersion({ + flowId: mockFlow2.id, + }); + await databaseConnection() + .getRepository('flow_version') + .save([mockFlowVersion1, mockFlowVersion2]); + + const mockToken = await generateMockToken({ + type: PrincipalType.USER, + projectId: mockProject.id, + }); + + const response = await app?.inject({ + method: 'POST', + url: '/v1/flows/move', + headers: { + authorization: `Bearer ${mockToken}`, + }, + body: { + flowIds: [mockFlow1.id, mockFlow2.id], + folderId: targetFolder.id, + }, + }); + + expect(response?.statusCode).toBe(StatusCodes.NO_CONTENT); + + const movedFlow1 = await databaseConnection() + .getRepository('flow') + .findOneBy({ id: mockFlow1.id }); + const movedFlow2 = await databaseConnection() + .getRepository('flow') + .findOneBy({ id: mockFlow2.id }); + + expect(movedFlow1?.folderId).toBe(targetFolder.id); + expect(movedFlow2?.folderId).toBe(targetFolder.id); + }); }); }); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 3e62985ef3..32451d96bd 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -38,8 +38,10 @@ export * from './lib/flows/actions/action'; export * from './lib/flows/actions/frontend-blocks'; export * from './lib/flows/dto/count-flows-request'; export * from './lib/flows/dto/create-flow-request'; +export * from './lib/flows/dto/delete-flows-request'; export * from './lib/flows/dto/flow-template-request'; export * from './lib/flows/dto/list-flows-request'; +export * from './lib/flows/dto/move-flows-request'; export * from './lib/flows/flow'; export * from './lib/flows/flow-helper'; export * from './lib/flows/flow-operations'; diff --git a/packages/shared/src/lib/flows/dto/delete-flows-request.ts b/packages/shared/src/lib/flows/dto/delete-flows-request.ts new file mode 100644 index 0000000000..55f21400e1 --- /dev/null +++ b/packages/shared/src/lib/flows/dto/delete-flows-request.ts @@ -0,0 +1,10 @@ +import { Static, Type } from '@sinclair/typebox'; +import { OpenOpsId } from '../../common/id-generator'; + +export const DeleteFlowsRequest = Type.Object({ + flowIds: Type.Array(OpenOpsId, { + minItems: 1, + }), +}); + +export type DeleteFlowsRequest = Static; diff --git a/packages/shared/src/lib/flows/dto/move-flows-request.ts b/packages/shared/src/lib/flows/dto/move-flows-request.ts new file mode 100644 index 0000000000..c1747b4137 --- /dev/null +++ b/packages/shared/src/lib/flows/dto/move-flows-request.ts @@ -0,0 +1,12 @@ +import { Static, Type } from '@sinclair/typebox'; +import { Nullable } from '../../common'; +import { OpenOpsId } from '../../common/id-generator'; + +export const MoveFlowsRequest = Type.Object({ + flowIds: Type.Array(OpenOpsId, { + minItems: 1, + }), + folderId: Nullable(OpenOpsId), +}); + +export type MoveFlowsRequest = Static; diff --git a/packages/ui-components/src/index.ts b/packages/ui-components/src/index.ts index 03bafd94c5..fa751cb546 100644 --- a/packages/ui-components/src/index.ts +++ b/packages/ui-components/src/index.ts @@ -21,6 +21,7 @@ export * from './ui/data-table'; export * from './ui/data-table-column-header'; export * from './ui/data-table-options-filter'; export * from './ui/data-table-select-popover'; +export * from './ui/data-table-selection-bar'; export * from './ui/data-table-skeleton'; export * from './ui/data-table-toolbar'; export * from './ui/date-picker-range'; diff --git a/packages/ui-components/src/ui/data-table-selection-bar.tsx b/packages/ui-components/src/ui/data-table-selection-bar.tsx new file mode 100644 index 0000000000..5f7a58ce75 --- /dev/null +++ b/packages/ui-components/src/ui/data-table-selection-bar.tsx @@ -0,0 +1,43 @@ +'use client'; + +import { t } from 'i18next'; +import { X } from 'lucide-react'; +import React from 'react'; + +import { Button } from './button'; + +type DataTableSelectionBarProps = { + selectedCount: number; + onClearSelection: () => void; + children: React.ReactNode; +}; + +export function DataTableSelectionBar({ + selectedCount, + onClearSelection, + children, +}: DataTableSelectionBarProps) { + if (selectedCount <= 0) { + return null; + } + + return ( +
+
+ {children} +
+ + {t('{count} selected', { count: selectedCount })} + + +
+
+ ); +} diff --git a/packages/ui-components/src/ui/data-table.tsx b/packages/ui-components/src/ui/data-table.tsx index 089051e76e..6ceec1c756 100644 --- a/packages/ui-components/src/ui/data-table.tsx +++ b/packages/ui-components/src/ui/data-table.tsx @@ -18,8 +18,10 @@ import { SeekPage, SortDirection } from '@openops/shared'; import { cn } from '../lib/cn'; import { Button } from './button'; +import { Checkbox } from './checkbox'; import { DataTableColumnHeader } from './data-table-column-header'; import { DataTableFacetedFilter } from './data-table-options-filter'; +import { DataTableSelectionBar } from './data-table-selection-bar'; import { DataTableSkeleton } from './data-table-skeleton'; import { DataTableToolbar } from './data-table-toolbar'; import { @@ -89,6 +91,13 @@ type DataTableAction = ( row: RowDataWithActions, ) => JSX.Element; +export type DataTableBulkAction = { + render: ( + selectedRows: RowDataWithActions[], + resetSelection: () => void, + ) => React.ReactNode; +}; + export type PaginationParams = { cursor?: string; limit?: number; @@ -129,6 +138,8 @@ interface DataTableProps< navigationExcludedColumns?: string[]; enableSorting?: boolean; syncWithSearchParams?: boolean; + enableSelection?: boolean; + bulkActions?: DataTableBulkAction[]; } export function DataTable< @@ -155,15 +166,52 @@ export function DataTable< navigationExcludedColumns, enableSorting = false, syncWithSearchParams = true, + enableSelection = false, + bulkActions = [], }: DataTableProps) { - const columns = columnsInitial.concat([ + const selectionColumn = { + id: '__select', + accessorKey: '__select', + enableSorting: false, + enableHiding: false, + meta: { className: 'w-10' }, + header: ({ + table, + }: { + table: { + getIsAllPageRowsSelected: () => boolean; + toggleAllPageRowsSelected: (value: boolean) => void; + }; + }) => ( + table.toggleAllPageRowsSelected(!!value)} + /> + ), + cell: ({ + row, + }: { + row: { + getIsSelected: () => boolean; + toggleSelected: (value: boolean) => void; + }; + }) => ( + row.toggleSelected(!!value)} + /> + ), + }; + const columns = [ + ...(enableSelection ? [selectionColumn] : []), + ...columnsInitial, { accessorKey: '__actions', enableSorting: false, - header: ({ column }) => ( + header: ({ column }: { column: any }) => ( ), - cell: ({ row }) => { + cell: ({ row }: { row: any }) => { return (
{actions.map((action, index) => { @@ -177,7 +225,7 @@ export function DataTable< ); }, }, - ]); + ]; const [searchParams, setSearchParams] = useSearchParams(); const startingCursor = syncWithSearchParams @@ -294,6 +342,7 @@ export function DataTable< data: tableData, columns, manualPagination: true, + enableRowSelection: enableSelection, enableSorting, manualSorting, getCoreRowModel: getCoreRowModel(), @@ -303,6 +352,7 @@ export function DataTable< columnVisibility, sorting, }, + getRowId: (row, index) => row.id ?? `${index}`, initialState: { pagination: { pageSize: parseInt(startingLimit), @@ -330,7 +380,15 @@ export function DataTable< onSelectedRowsChange?.( table.getSelectedRowModel().rows.map((row) => row.original), ); - }, [table.getSelectedRowModel().rows]); + }, [table.getState().rowSelection]); + + const selectedRows = table + .getSelectedRowModel() + .rows.map((row) => row.original); + + const resetSelection = () => { + table.toggleAllRowsSelected(false); + }; useEffect(() => { if (!syncWithSearchParams) { @@ -475,8 +533,14 @@ export function DataTable< { + if (cell.column.id === '__select') { + e.stopPropagation(); + } + }} > {rowHref && + cell.column.id !== '__select' && !navigationExcludedColumns?.includes( cell.column.id, ) ? ( @@ -550,6 +614,18 @@ export function DataTable<
)} + {bulkActions.length > 0 && selectedRows.length > 0 && ( + + {bulkActions.map((action, index) => ( + + {action.render(selectedRows, resetSelection)} + + ))} + + )}
); } From 35ec9c34f2e767ea4fca2f2c464392f674bdf975 Mon Sep 17 00:00:00 2001 From: Roman Date: Tue, 5 May 2026 20:59:24 +0300 Subject: [PATCH 8/9] Implement bulk action handlers for flow management, including moving, deleting, and exporting selected flows. Refactor related functions for improved readability and maintainability. --- .../react-ui/src/app/routes/flows/index.tsx | 61 ++++++++++++++----- 1 file changed, 45 insertions(+), 16 deletions(-) diff --git a/packages/react-ui/src/app/routes/flows/index.tsx b/packages/react-ui/src/app/routes/flows/index.tsx index 3dd88a0025..7a34fc5de5 100644 --- a/packages/react-ui/src/app/routes/flows/index.tsx +++ b/packages/react-ui/src/app/routes/flows/index.tsx @@ -44,6 +44,10 @@ 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); @@ -114,6 +118,38 @@ const FlowsPage = () => { [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[]>( () => [ { @@ -123,17 +159,10 @@ const FlowsPage = () => { count: selectedRows.length, })} apiMutateFn={async (data: MoveToFolderFormSchema) => { - await flowsApi.moveMany({ - flowIds: selectedRows.map((flow) => flow.id), - folderId: data.folder, - }); + await moveSelectedFlows(data.folder); return { success: true }; }} - onMoveTo={() => { - resetSelection(); - setSelectedRows([]); - onTableRefresh(); - }} + onMoveTo={() => completeBulkAction(resetSelection)} >