diff --git a/brain/knowledge/flows-execution/flows.md b/brain/knowledge/flows-execution/flows.md index 69d93dcbd4ef..c65d9efb974e 100644 --- a/brain/knowledge/flows-execution/flows.md +++ b/brain/knowledge/flows-execution/flows.md @@ -35,6 +35,8 @@ Flows are the core automation primitive: a versioned directed graph of trigger + - **`transaction()` (`core/db/transaction.ts`) is a bare `dataSource.transaction()`** — it acquires a *new* connection, not a savepoint. Nesting it deadlocks, so check every caller before wrapping a service method that others may already call inside a transaction. - Step settings split a piece's props into an always-visible **essential** set and a collapsed **Advanced** section: a prop is Advanced only when it sets `advanced: true` (everything else — incl. `MARKDOWN`, tab/section group members, and checkbox reveal targets — stays essential). `propertyGroups` render as tabs, sectioned cards, or the "Add filter" builder. - **Flows stuck in `DELETING` keep eating the active-flow limit.** Deletion is a durable BullMQ system job (`delete-flow-`), not synchronous: `delete()` sets `operationStatus=DELETING` and enqueues, and the row plus `status=ENABLED` only go away when the job finishes. That job runs `sampleDataService.deleteForFlow`, whose `DELETE FROM file … metadata->>'flowId'=?` had no index — on the large prod `file` table it seq-scans, blows `statement_timeout`, exhausts its 2 attempts and lands **permanently** in the failed set. The flow is then hidden from the UI list (which filters `!=DELETING`) but still counted by the active-flows quota (`getUsage` counts `status=ENABLED`), so Publish silently shows the "Purchase Extra Active Flows" dialog instead of publishing — this is what breaks the `webhook-should-return-response` e2e monitor. Stuck flows are functionally dead (`preDelete` disables the trigger before the failing delete), so forcing their rows away is safe. Fixes on `fix/flow-delete-sample-data-timeout`: a partial expression index `idx_file_sample_data_flow_id` on `file (type, (metadata->>'flowId'))`, plus `operationStatus != DELETING` in the active-flow counts so the quota stops depending on delete-job success. +- **The flows list page (`automations/index.tsx`) fetches a capped window and sorts it client-side, so any new "sort by X" has to be pushed into SQL to be correct.** `use-automations-data.ts` calls `flowsApi.list`/`tablesApi.list` with `limit: 1000` (1500 for folder contents, one budget shared across *all* folders) and passes `cursor: undefined` at every call site; all merging, sorting and page-slicing then happens client-side in `automations/lib/utils.ts` (`mergeAndSortItems`, `buildTreeItems`, `buildFilteredTreeItems`, plus a second folder-children sort inside `buildFilteredTreeItems` that is easy to miss). Because the window is capped, re-sorting it in the browser only reorders a *recency-biased slice* — a rarely-touched flow at row 1001 by `updated` never reaches the client, so it can never appear under A–Z. Ordering therefore has to happen before the `LIMIT`, in Postgres. +- **The cursor `Paginator` can be made to order by a joined column without denormalizing anything — `addOrderBy` registered on the query builder *before* `paginate()` leads.** `appendPagingQuery` does `new SelectQueryBuilder(builder)` and then only ever calls `addOrderBy`, so a caller's own criteria comes first and the paginator's `status/updated/id` become the tiebreak chain. That means `queryBuilder.addSelect('LOWER(COALESCE(latest_version."displayName", ''))', alias).addOrderBy(alias, order)` sorts flows by name with **no new column, no migration, no index, no paginator change** — despite `OrderByConfig.field` itself being unable to target a join (`buildOrder` and `buildCursorQuery` both hard-prefix `${this.alias}.`). Three things to respect: (1) the keyset `WHERE` is still built from `status/updated/id`, so this is only sound while no cursor is passed — reject `sortBy` + `cursor` with a 400 and return `createPage(data, null)` so no meaningless `next` is minted; (2) the sort alias must be **all-lowercase**, because TypeORM's DISTINCT path re-emits the order criteria *unquoted* in its second `id IN (...)` query, so a mixed-case alias fails only on that second query; (3) that same DISTINCT path (`SelectQueryBuilder` clears the inner ORDER BY and nulls the inner limit) means **no index on `flow` can serve this ORDER BY** — which is exactly why denormalizing a sort column onto `FlowEntity` buys nothing here. - **Any query that reads `flow_version.trigger` across a whole platform is TOAST-bound, not index-bound.** Trigger blobs are jsonb and TOAST'd on rows past ~2 KB, so a report that walks piece steps across all published flows on a platform pays ~2–5 ms of TOAST fetch + JSON parse per flow no matter how tight the WHERE clause is — a 10k-published-flow platform lands at 30 s–1 min. Endpoints of this shape (`platform/pieces-report/pieces-report.controller.ts` is the current example) must page + stream (`Readable.from(async iterable)`) so memory is bounded even when wall clock isn't; the escape hatch for the biggest platforms is a background job with async delivery, unlocked when a real sync request times out. Postgres jsonpath in-DB is not a shortcut — it still reads the whole TOASTed blob and the branch schema (`nextAction`/`children.*`/`onSuccessAction`/`firstLoopAction`/router children) drifts every time the flow shape changes, which is what `flowStructureUtil.getAllSteps` is authoritative over. - **`transferFlow` already deep-clones the whole flow — a callback that clones `step` again is quadratic.** `flowStructureUtil.transferFlow` opens with `JSON.parse(JSON.stringify(flowVersion))`, so the callback is handed a private copy and can mutate in place. Cloning per step instead is O(N²), because a `step` carries `nextAction` (the entire rest of the chain) plus loop/router children: cloning step *i* copies the remaining `N-i` steps. Measured on prod app containers (CDP CPU profile, 2026-08-21): the callback at `flow-version.service.ts` was **42% of wall-clock / ~84% of non-idle CPU**, at `transferStep` recursion depth 255 ≈ 32k step serializations per call, plus the GC churn behind ~2.5 GB RSS. It ran on **every** `getFlowVersionOrThrow` — including the default `removeConnectionsName=false, removeSampleData=false`, where the callback does nothing but the cloning still happens. Symptom was containers pegged at their `cpus: 1` cap and the 5s healthcheck `curl` timing out, which reads as "app unhealthy" with nothing crashed (the leftover zombie `curl`s are those killed healthchecks). Same pattern at `ee/…/project-state/diff/flow-diff.service.ts` (colder path, untouched here). When you write a `transferFlow` callback, mutate and return `step` — don't re-clone it. diff --git a/bun.lock b/bun.lock index 6a1614f5cf0f..88f4b48df05a 100644 --- a/bun.lock +++ b/bun.lock @@ -163,7 +163,7 @@ }, "packages/core/shared": { "name": "@activepieces/shared", - "version": "0.157.0", + "version": "0.158.0", "dependencies": { "@activepieces/core-execution": "workspace:*", "@activepieces/core-formula": "workspace:*", @@ -528,7 +528,7 @@ }, "packages/pieces/community/amazon-bedrock": { "name": "@activepieces/piece-amazon-bedrock", - "version": "0.2.3", + "version": "0.3.0", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -543,11 +543,12 @@ }, "devDependencies": { "tslib": "^2.3.0", + "vitest": "3.2.6", }, }, "packages/pieces/community/amazon-s3": { "name": "@activepieces/piece-amazon-s3", - "version": "0.6.8", + "version": "0.7.0", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -569,7 +570,7 @@ }, "packages/pieces/community/amazon-secrets-manager": { "name": "@activepieces/piece-amazon-secrets-manager", - "version": "0.1.3", + "version": "0.2.0", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -902,7 +903,7 @@ }, "packages/pieces/community/aws-bedrock": { "name": "@activepieces/piece-aws-bedrock", - "version": "0.2.4", + "version": "0.3.0", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -8808,7 +8809,7 @@ }, "packages/pieces/community/tally": { "name": "@activepieces/piece-tally", - "version": "0.4.6", + "version": "0.5.0", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -10665,7 +10666,7 @@ }, "packages/pieces/framework": { "name": "@activepieces/pieces-framework", - "version": "0.38.0", + "version": "0.39.0", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", diff --git a/packages/core/execution/src/lib/flows/dto/list-flows-request.ts b/packages/core/execution/src/lib/flows/dto/list-flows-request.ts index 78af61b2123f..3f45e488b536 100755 --- a/packages/core/execution/src/lib/flows/dto/list-flows-request.ts +++ b/packages/core/execution/src/lib/flows/dto/list-flows-request.ts @@ -16,6 +16,8 @@ export const ListFlowsRequest = z.object({ versionState: z.nativeEnum(FlowVersionState).optional(), connectionExternalIds: OptionalArrayFromQuery(z.string()), externalIds: OptionalArrayFromQuery(z.string()), + sortBy: z.enum(['NAME']).optional(), + order: z.enum(['ASC', 'DESC']).optional(), }) export type ListFlowsRequest = Omit, 'cursor'> & { cursor: Cursor | undefined } diff --git a/packages/core/shared/package.json b/packages/core/shared/package.json index 23bcac62cb82..bf90c1e009ad 100644 --- a/packages/core/shared/package.json +++ b/packages/core/shared/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/shared", - "version": "0.157.0", + "version": "0.158.0", "type": "commonjs", "sideEffects": false, "main": "./dist/src/index.js", diff --git a/packages/core/shared/src/lib/automation/tables/dto/tables.dto.ts b/packages/core/shared/src/lib/automation/tables/dto/tables.dto.ts index 9d19e2ceb2bb..23376e7913ce 100644 --- a/packages/core/shared/src/lib/automation/tables/dto/tables.dto.ts +++ b/packages/core/shared/src/lib/automation/tables/dto/tables.dto.ts @@ -50,6 +50,8 @@ export const ListTablesRequest = z.object({ externalIds: OptionalArrayFromQuery(z.string()), folderId: z.string().optional(), folderIds: OptionalArrayFromQuery(z.string()), + sortBy: z.enum(['NAME']).optional(), + order: z.enum(['ASC', 'DESC']).optional(), }) export type ListTablesRequest = z.infer diff --git a/packages/pieces/community/bettermode/package.json b/packages/pieces/community/bettermode/package.json index 75978da1f07e..ecb0ab7c7291 100644 --- a/packages/pieces/community/bettermode/package.json +++ b/packages/pieces/community/bettermode/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/piece-bettermode", - "version": "0.1.8", + "version": "0.1.9", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", "scripts": { 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 a704103373a4..9edc131f37d1 100644 --- a/packages/server/api/src/app/flows/flow/flow.controller.ts +++ b/packages/server/api/src/app/flows/flow/flow.controller.ts @@ -111,6 +111,8 @@ export const flowController: FastifyPluginAsyncZod = async (app) => { externalIds: request.query.externalIds, connectionExternalIds: request.query.connectionExternalIds, agentExternalIds: request.query.agentExternalIds, + sortBy: request.query.sortBy, + order: request.query.order, }) }) 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 a11093228de2..ef1689df66db 100644 --- a/packages/server/api/src/app/flows/flow/flow.service.ts +++ b/packages/server/api/src/app/flows/flow/flow.service.ts @@ -8,7 +8,7 @@ import { transaction } from '../../core/db/transaction' import { distributedLock } from '../../database/redis-connections' import { buildPaginator } from '../../helper/pagination/build-paginator' import { paginationHelper } from '../../helper/pagination/pagination-utils' -import Paginator, { Order } from '../../helper/pagination/paginator' +import Paginator, { CURSOR_SELECT_PREFIX, Order } from '../../helper/pagination/paginator' import { rejectedPromiseHandler } from '../../helper/promise-handler' import { system } from '../../helper/system/system' import { AppSystemProp } from '../../helper/system/system-props' @@ -99,7 +99,10 @@ export const flowService = (log: FastifyBaseLogger) => ({ externalIds, versionState = FlowVersionState.DRAFT, includeTriggerSource = true, + sortBy, + order, }: ListParams): Promise> { + assertSortIsNotCombinedWithCursor({ sortBy, cursor: cursorRequest }) const decodedCursor = paginationHelper.decodeCursor(cursorRequest) const paginator = buildPaginator({ entity: FlowEntity, @@ -192,6 +195,13 @@ export const flowService = (log: FastifyBaseLogger) => ({ queryBuilder.andWhere('latest_version."agentIds" && :agentExternalIds', { agentExternalIds }) } + if (!isNil(sortBy)) { + const nameSortAlias = `${CURSOR_SELECT_PREFIX}name` + const nameSortColumn = versionState === FlowVersionState.DRAFT ? 'latest_version."displayName"' : 'published_version."displayName"' + queryBuilder.addSelect(`LOWER(COALESCE(${nameSortColumn}, ''))`, nameSortAlias) + queryBuilder.addOrderBy(nameSortAlias, order ?? 'ASC') + } + const paginationResult = await paginator.paginate(queryBuilder) const populatedFlows = await Promise.all(paginationResult.data.map(async (flow) => { @@ -215,7 +225,7 @@ export const flowService = (log: FastifyBaseLogger) => ({ : undefined, } })) - return paginationHelper.createPage(populatedFlows, paginationResult.cursor) + return paginationHelper.createPage(populatedFlows, isNil(sortBy) ? paginationResult.cursor : null) }, async exists(id: FlowId): Promise { return flowRepo().existsBy({ @@ -809,6 +819,16 @@ const assertFlowIsNotNull: ( } } +function assertSortIsNotCombinedWithCursor({ sortBy, cursor }: { sortBy: 'NAME' | undefined, cursor: Cursor | undefined | null }): void { + if (isNil(sortBy) || isNil(cursor)) { + return + } + throw new ActivepiecesError({ + code: ErrorCode.GENERIC_ERROR, + params: { message: 'sortBy cannot be combined with cursor' }, + }) +} + async function assertExternalIdIsUnique({ projectId, externalId }: { projectId: ProjectId, externalId: string | undefined }): Promise { if (isNil(externalId)) { return @@ -843,6 +863,8 @@ type ListParamsBase = { connectionExternalIds?: string[] agentExternalIds?: string[] includeTriggerSource?: boolean + sortBy?: 'NAME' + order?: 'ASC' | 'DESC' } type ListParams = ListParamsBase & ( diff --git a/packages/server/api/src/app/helper/pagination/paginator.ts b/packages/server/api/src/app/helper/pagination/paginator.ts index da187d282767..201cf65c6ef0 100644 --- a/packages/server/api/src/app/helper/pagination/paginator.ts +++ b/packages/server/api/src/app/helper/pagination/paginator.ts @@ -298,7 +298,7 @@ function withIdTiebreaker(orderByConfig: OrderByConfig[]): OrderByConfig[] { ] } -const CURSOR_SELECT_PREFIX = 'ap_cursor_' +export const CURSOR_SELECT_PREFIX = 'ap_cursor_' const TIMESTAMP_TEXT_PATTERN = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(\.\d+)?([+-]\d{2}(:?\d{2})?|Z)?$/ diff --git a/packages/server/api/src/app/tables/table/table.controller.ts b/packages/server/api/src/app/tables/table/table.controller.ts index af5bd5fdc79f..474423eb8667 100644 --- a/packages/server/api/src/app/tables/table/table.controller.ts +++ b/packages/server/api/src/app/tables/table/table.controller.ts @@ -41,6 +41,8 @@ export const tablesController: FastifyPluginAsyncZod = async (fastify) => { externalIds: request.query.externalIds, folderId: request.query.folderId, folderIds: request.query.folderIds, + sortBy: request.query.sortBy, + order: request.query.order, }) }) diff --git a/packages/server/api/src/app/tables/table/table.service.ts b/packages/server/api/src/app/tables/table/table.service.ts index 671a63de758c..4c8a25ecbce9 100644 --- a/packages/server/api/src/app/tables/table/table.service.ts +++ b/packages/server/api/src/app/tables/table/table.service.ts @@ -10,7 +10,7 @@ import { enforceByteLimit, filesService } from '../../file/files-service' import { getFolderIdFromRequest } from '../../flows/flow/flow.service' import { buildPaginator } from '../../helper/pagination/build-paginator' import { paginationHelper } from '../../helper/pagination/pagination-utils' -import { Order } from '../../helper/pagination/paginator' +import { CURSOR_SELECT_PREFIX, Order } from '../../helper/pagination/paginator' import { system } from '../../helper/system/system' import { AppSystemProp } from '../../helper/system/system-props' import { fieldService } from '../field/field.service' @@ -46,7 +46,8 @@ export const tableService = { } return table }, - async list({ projectId, cursor, limit, name, externalIds, folderId, folderIds, includeRowCount }: ListParams): Promise> { + async list({ projectId, cursor, limit, name, externalIds, folderId, folderIds, includeRowCount, sortBy, order }: ListParams): Promise> { + assertSortIsNotCombinedWithCursor({ sortBy, cursor }) const decodedCursor = paginationHelper.decodeCursor(cursor ?? null) const paginator = buildPaginator({ @@ -85,9 +86,15 @@ export const tableService = { }, 'rowCount') } + if (!isNil(sortBy)) { + const nameSortAlias = `${CURSOR_SELECT_PREFIX}name` + queryBuilder.addSelect('LOWER(COALESCE("table"."name", \'\'))', nameSortAlias) + queryBuilder.addOrderBy(nameSortAlias, order ?? 'ASC') + } + const paginationResult = await paginator.paginate(queryBuilder) - return paginationHelper.createPage(paginationResult.data, paginationResult.cursor) + return paginationHelper.createPage(paginationResult.data, isNil(sortBy) ? paginationResult.cursor : null) }, async getOneOrThrow({ @@ -357,6 +364,16 @@ const EXPORT_BATCH_SIZE = 2000 const CELL_EDGE_CHARS = /^[\s\u0000-\u001F\u007F-\u009F]+|[\s\u0000-\u001F\u007F-\u009F]+$/g +function assertSortIsNotCombinedWithCursor({ sortBy, cursor }: { sortBy: 'NAME' | undefined, cursor: string | undefined }): void { + if (isNil(sortBy) || isNil(cursor)) { + return + } + throw new ActivepiecesError({ + code: ErrorCode.GENERIC_ERROR, + params: { message: 'sortBy cannot be combined with cursor' }, + }) +} + async function fetchRecordPage({ tableId, projectId, afterCursor }: FetchRecordPageParams): Promise { const paginator = buildPaginator({ entity: RecordEntity, @@ -421,6 +438,8 @@ type ListParams = { folderId: string | undefined folderIds?: string[] | undefined includeRowCount?: boolean + sortBy?: 'NAME' + order?: 'ASC' | 'DESC' } type GetByIdParams = { diff --git a/packages/server/api/test/integration/ce/flows/flow/flow.test.ts b/packages/server/api/test/integration/ce/flows/flow/flow.test.ts index 4db83d880b38..cc4a2986c81a 100644 --- a/packages/server/api/test/integration/ce/flows/flow/flow.test.ts +++ b/packages/server/api/test/integration/ce/flows/flow/flow.test.ts @@ -13,6 +13,7 @@ import { TriggerTestStrategy, WebhookHandshakeStrategy, } from '@activepieces/shared' +import dayjs from 'dayjs' import { FastifyInstance } from 'fastify' import { StatusCodes } from 'http-status-codes' import { generateMockToken } from '../../../../helpers/auth' @@ -336,6 +337,170 @@ describe('Flow API', () => { expect(responseBody?.params?.entityType).toBe('FlowVersion') expect(responseBody?.params?.message).toBe(`flowId=${mockFlow.id}`) }) + + it('Sorts Flows by name ascending, ignoring case', async () => { + const ctx = await createTestContext(app!) + await seedFlowsNamed({ projectId: ctx.project.id, displayNames: ['Zeta', 'alpha', 'mid'] }) + + const response = await ctx.get('/v1/flows', { + projectId: ctx.project.id, + sortBy: 'NAME', + order: 'ASC', + }) + + expect(response?.statusCode).toBe(StatusCodes.OK) + const responseBody = response?.json() + + expect(responseBody.data.map((flow: PopulatedFlow) => flow.version.displayName)).toEqual(['alpha', 'mid', 'Zeta']) + }) + + it('Sorts Flows by name descending', async () => { + const ctx = await createTestContext(app!) + await seedFlowsNamed({ projectId: ctx.project.id, displayNames: ['Zeta', 'alpha', 'mid'] }) + + const response = await ctx.get('/v1/flows', { + projectId: ctx.project.id, + sortBy: 'NAME', + order: 'DESC', + }) + + expect(response?.statusCode).toBe(StatusCodes.OK) + const responseBody = response?.json() + + expect(responseBody.data.map((flow: PopulatedFlow) => flow.version.displayName)).toEqual(['Zeta', 'mid', 'alpha']) + }) + + it('Sorts Flows by the published name when versionState is LOCKED', async () => { + const ctx = await createTestContext(app!) + + const flowWithLateName = createMockFlow({ projectId: ctx.project.id }) + const flowWithEarlyName = createMockFlow({ projectId: ctx.project.id }) + await db.save('flow', [flowWithLateName, flowWithEarlyName]) + + const latePublishedVersion = createMockFlowVersion({ + flowId: flowWithLateName.id, + displayName: 'Zpublished', + state: FlowVersionState.LOCKED, + created: dayjs().subtract(1, 'hour').toISOString(), + }) + const earlyPublishedVersion = createMockFlowVersion({ + flowId: flowWithEarlyName.id, + displayName: 'Apublished', + state: FlowVersionState.LOCKED, + created: dayjs().subtract(1, 'hour').toISOString(), + }) + const lateDraftVersion = createMockFlowVersion({ + flowId: flowWithLateName.id, + displayName: 'adraft', + state: FlowVersionState.DRAFT, + created: dayjs().toISOString(), + }) + const earlyDraftVersion = createMockFlowVersion({ + flowId: flowWithEarlyName.id, + displayName: 'zdraft', + state: FlowVersionState.DRAFT, + created: dayjs().toISOString(), + }) + await db.save('flow_version', [latePublishedVersion, earlyPublishedVersion, lateDraftVersion, earlyDraftVersion]) + + await db.update('flow', flowWithLateName.id, { publishedVersionId: latePublishedVersion.id }) + await db.update('flow', flowWithEarlyName.id, { publishedVersionId: earlyPublishedVersion.id }) + + const response = await ctx.get('/v1/flows', { + projectId: ctx.project.id, + versionState: 'LOCKED', + sortBy: 'NAME', + order: 'ASC', + }) + + expect(response?.statusCode).toBe(StatusCodes.OK) + const responseBody = response?.json() + + expect(responseBody.data.map((flow: PopulatedFlow) => flow.version.displayName)).toEqual(['Apublished', 'Zpublished']) + }) + + it('Mints no cursor for a name sorted page', async () => { + const ctx = await createTestContext(app!) + await seedFlowsNamed({ projectId: ctx.project.id, displayNames: ['Zeta', 'alpha', 'mid'] }) + + const response = await ctx.get('/v1/flows', { + projectId: ctx.project.id, + sortBy: 'NAME', + limit: '1', + }) + + expect(response?.statusCode).toBe(StatusCodes.OK) + const responseBody = response?.json() + + expect(responseBody.data).toHaveLength(1) + expect(responseBody.next).toBeNull() + expect(responseBody.previous).toBeNull() + }) + + it('Does not leak the internal name sort column', async () => { + const ctx = await createTestContext(app!) + await seedFlowsNamed({ projectId: ctx.project.id, displayNames: ['alpha'] }) + + const response = await ctx.get('/v1/flows', { + projectId: ctx.project.id, + sortBy: 'NAME', + }) + + expect(response?.statusCode).toBe(StatusCodes.OK) + const responseBody = response?.json() + + expect(responseBody.data[0]).not.toHaveProperty('ap_cursor_name') + }) + + it('Rejects a name sort combined with a cursor', async () => { + const ctx = await createTestContext(app!) + await seedFlowsNamed({ projectId: ctx.project.id, displayNames: ['alpha', 'beta'] }) + + const firstPage = await ctx.get('/v1/flows', { projectId: ctx.project.id, limit: '1' }) + const cursor = firstPage?.json()?.next + expect(cursor).not.toBeNull() + + const response = await ctx.get('/v1/flows', { + projectId: ctx.project.id, + sortBy: 'NAME', + cursor, + }) + + expect(response?.statusCode).toBe(StatusCodes.BAD_REQUEST) + }) + + it('Keeps the default order on status then updated when no sort is requested', async () => { + const ctx = await createTestContext(app!) + + const enabledOlder = createMockFlow({ + projectId: ctx.project.id, + status: FlowStatus.ENABLED, + updated: dayjs().subtract(3, 'hour').toISOString(), + }) + const enabledNewer = createMockFlow({ + projectId: ctx.project.id, + status: FlowStatus.ENABLED, + updated: dayjs().subtract(1, 'hour').toISOString(), + }) + const disabledNewest = createMockFlow({ + projectId: ctx.project.id, + status: FlowStatus.DISABLED, + updated: dayjs().toISOString(), + }) + await db.save('flow', [enabledOlder, enabledNewer, disabledNewest]) + await db.save('flow_version', [enabledOlder, enabledNewer, disabledNewest].map((flow) => createMockFlowVersion({ flowId: flow.id }))) + + const response = await ctx.get('/v1/flows', { projectId: ctx.project.id }) + + expect(response?.statusCode).toBe(StatusCodes.OK) + const responseBody = response?.json() + + expect(responseBody.data.map((flow: PopulatedFlow) => flow.id)).toEqual([ + enabledNewer.id, + enabledOlder.id, + disabledNewest.id, + ]) + }) }) describe('Update Metadata endpoint', () => { @@ -406,3 +571,12 @@ describe('Flow API', () => { }) }) }) + +async function seedFlowsNamed({ projectId, displayNames }: { projectId: string, displayNames: string[] }): Promise { + const flows = displayNames.map(() => createMockFlow({ projectId })) + await db.save('flow', flows) + await db.save('flow_version', flows.map((flow, index) => createMockFlowVersion({ + flowId: flow.id, + displayName: displayNames[index], + }))) +} diff --git a/packages/server/api/test/integration/ce/tables/table.test.ts b/packages/server/api/test/integration/ce/tables/table.test.ts index 6deb77be3d66..4c5be326c9f9 100644 --- a/packages/server/api/test/integration/ce/tables/table.test.ts +++ b/packages/server/api/test/integration/ce/tables/table.test.ts @@ -1,5 +1,5 @@ import { apId } from '@activepieces/core-utils' -import { FieldType } from '@activepieces/shared' +import { FieldType, Table } from '@activepieces/shared' import { FastifyInstance } from 'fastify' import { StatusCodes } from 'http-status-codes' import { db } from '../../../helpers/db' @@ -229,6 +229,46 @@ describe('Table API', () => { const body = response?.json() expect(body.data.length).toBe(2) }) + + it('should sort by name ignoring case', async () => { + const ctx = await setup() + for (const name of ['Zeta', 'alpha', 'mid']) { + await db.save('table', { ...createMockTable({ projectId: ctx.project.id }), name }) + } + + const ascending = await ctx.get('/v1/tables', { + projectId: ctx.project.id, + sortBy: 'NAME', + order: 'ASC', + }) + const descending = await ctx.get('/v1/tables', { + projectId: ctx.project.id, + sortBy: 'NAME', + order: 'DESC', + }) + + expect(ascending?.json().data.map((table: Table) => table.name)).toEqual(['alpha', 'mid', 'Zeta']) + expect(descending?.json().data.map((table: Table) => table.name)).toEqual(['Zeta', 'mid', 'alpha']) + expect(ascending?.json().next).toBeNull() + }) + + it('should reject a name sort combined with a cursor', async () => { + const ctx = await setup() + await createAndSaveTable(ctx) + await createAndSaveTable(ctx) + + const firstPage = await ctx.get('/v1/tables', { projectId: ctx.project.id, limit: '1' }) + const cursor = firstPage?.json()?.next + expect(cursor).not.toBeNull() + + const response = await ctx.get('/v1/tables', { + projectId: ctx.project.id, + sortBy: 'NAME', + cursor, + }) + + expect(response?.statusCode).toBe(StatusCodes.BAD_REQUEST) + }) }) describe('GET /v1/tables/count', () => { diff --git a/packages/web/public/locales/en/translation.json b/packages/web/public/locales/en/translation.json index f46f24b4770a..9971c19174ee 100644 --- a/packages/web/public/locales/en/translation.json +++ b/packages/web/public/locales/en/translation.json @@ -2737,6 +2737,9 @@ "Resets in {days, plural, =1 {# day} other {# days}}": "Resets in {days, plural, =1 {# day} other {# days}}", "Show all projects": "Show all projects", "Sort pinned projects": "Sort pinned projects", + "Sort by name A to Z": "Sort by name A to Z", + "Sort by name Z to A": "Sort by name Z to A", + "Clear name sorting": "Clear name sorting", "Key rejected": "Key rejected", "Unreachable": "Unreachable", "Recheck": "Recheck", diff --git a/packages/web/src/app/routes/automations/index.tsx b/packages/web/src/app/routes/automations/index.tsx index 7c35ba29f299..bc4d27054bc3 100644 --- a/packages/web/src/app/routes/automations/index.tsx +++ b/packages/web/src/app/routes/automations/index.tsx @@ -26,7 +26,8 @@ import { hasMovableOrExportableItems, } from '@/features/automations/hooks/use-automations-selection'; import { usePinnedItems } from '@/features/automations/hooks/use-pinned-items'; -import { TreeItem } from '@/features/automations/lib/types'; +import { AutomationsSort, TreeItem } from '@/features/automations/lib/types'; +import { ROOT_ITEMS_LIMIT } from '@/features/automations/lib/utils'; import { appConnectionsQueries } from '@/features/connections'; import { ImportFlowDialog } from '@/features/flows/components/import-flow-dialog'; import { projectMembersHooks } from '@/features/members'; @@ -72,6 +73,8 @@ const AutomationsPageContent = ({ projectId }: { projectId: string }) => { setOwnerFilter, folderFilter, setFolderFilter, + sort, + setSort, filters, filtersActive, clearAllFilters, @@ -99,7 +102,7 @@ const AutomationsPageContent = ({ projectId }: { projectId: string }) => { invalidateAll, invalidateRoot, invalidateFolder, - } = useAutomationsData(filters, pinnedList); + } = useAutomationsData({ filters, pinnedList, sort }); const expandFolderIfCollapsed = useCallback( (folderId: string) => { @@ -147,6 +150,14 @@ const AutomationsPageContent = ({ projectId }: { projectId: string }) => { resetPagination(); }, [clearSelection, resetPagination]); + const handleSortChange = useCallback( + (next: AutomationsSort) => { + setSort(next); + handleFiltersChange(); + }, + [setSort, handleFiltersChange], + ); + const handleNextPage = useCallback(() => { clearSelection(); nextRootPage(); @@ -269,6 +280,10 @@ const AutomationsPageContent = ({ projectId }: { projectId: string }) => { const hasAnyItems = rootFlows.length > 0 || rootTables.length > 0 || folders.length > 0; + const isSortTruncated = + sort !== 'default' && + (rootFlows.length >= ROOT_ITEMS_LIMIT || + rootTables.length >= ROOT_ITEMS_LIMIT); const isErrorState = isError && !hasAnyItems && !isLoading; const isEmptyState = !hasAnyItems && !isLoading && !filtersActive && !isErrorState; @@ -356,16 +371,27 @@ const AutomationsPageContent = ({ projectId }: { projectId: string }) => { isDuplicating={mutations.isDuplicating} onLoadMoreInFolder={loadMoreInFolder} isItemSelected={isItemSelected} + sort={sort} + onSortChange={handleSortChange} /> - +
+ {isSortTruncated && ( + + {t('Showing the first {count}', { + count: rootFlows.length + rootTables.length, + })} + + )} + +
)} diff --git a/packages/web/src/features/automations/components/automations-table.tsx b/packages/web/src/features/automations/components/automations-table.tsx index 33c234ebb0f2..0d914e0902b9 100644 --- a/packages/web/src/features/automations/components/automations-table.tsx +++ b/packages/web/src/features/automations/components/automations-table.tsx @@ -6,15 +6,25 @@ import { } from '@activepieces/shared'; import * as AccordionPrimitive from '@radix-ui/react-accordion'; import { t } from 'i18next'; -import { Activity, Clock, Info, Type, User } from 'lucide-react'; +import { + Activity, + ArrowDown, + ArrowUp, + ArrowUpDown, + Clock, + Info, + LucideIcon, + Type, + User, +} from 'lucide-react'; import { useEmbedding } from '@/components/providers/embed-provider'; import { Checkbox } from '@/components/ui/checkbox'; import { Skeleton } from '@/components/ui/skeleton'; import { cn } from '@/lib/utils'; -import { SelectedItemsMap, TreeItem } from '../lib/types'; -import { groupTreeItemsByFolder } from '../lib/utils'; +import { AutomationsSort, SelectedItemsMap, TreeItem } from '../lib/types'; +import { groupTreeItemsByFolder, nextSort } from '../lib/utils'; import { AutomationsTableRow } from './automations-table-row'; import { CreateInFolderKind } from './create-new-menu'; @@ -47,6 +57,8 @@ type AutomationsTableProps = { isDuplicating: boolean; onLoadMoreInFolder: (folderId: string) => void; isItemSelected: (item: TreeItem) => boolean; + sort: AutomationsSort; + onSortChange: (sort: AutomationsSort) => void; }; const rowClassName = @@ -116,9 +128,12 @@ export const AutomationsTable = ({ isDuplicating, onLoadMoreInFolder, isItemSelected, + sort, + onSortChange, }: AutomationsTableProps) => { const { embedState } = useEmbedding(); const groups = groupTreeItemsByFolder(items); + const SortIcon = sortIcons[sort]; return (
@@ -134,8 +149,21 @@ export const AutomationsTable = ({
- - {t('Name')} +
@@ -301,3 +329,20 @@ export const AutomationsTable = ({
); }; + +function sortActionLabel(sort: AutomationsSort): string { + switch (sort) { + case 'default': + return t('Sort by name A to Z'); + case 'name-asc': + return t('Sort by name Z to A'); + case 'name-desc': + return t('Clear name sorting'); + } +} + +const sortIcons: Record = { + default: ArrowUpDown, + 'name-asc': ArrowUp, + 'name-desc': ArrowDown, +}; diff --git a/packages/web/src/features/automations/hooks/use-automations-data.ts b/packages/web/src/features/automations/hooks/use-automations-data.ts index 9b96382cf1c7..15750951f762 100644 --- a/packages/web/src/features/automations/hooks/use-automations-data.ts +++ b/packages/web/src/features/automations/hooks/use-automations-data.ts @@ -16,19 +16,29 @@ import { foldersApi } from '@/features/folders/api/folders-api'; import { tablesApi } from '@/features/tables/api/tables-api'; import { authenticationSession } from '@/lib/authentication-session'; -import { AutomationsFilters, FolderContent } from '../lib/types'; +import { + AutomationsFilters, + AutomationsSort, + FolderContent, +} from '../lib/types'; import { buildFilteredTreeItems, buildTreeItems, DEFAULT_PAGE_SIZE, FOLDER_PAGE_SIZE, hasNonFolderFilters, + ROOT_ITEMS_LIMIT, } from '../lib/utils'; -export function useAutomationsData( - filters: AutomationsFilters, - pinnedList?: string[], -) { +export function useAutomationsData({ + filters, + pinnedList, + sort, +}: { + filters: AutomationsFilters; + pinnedList?: string[]; + sort: AutomationsSort; +}) { const { projectId: projectIdFromUrl } = useParams<{ projectId: string }>(); const projectId = projectIdFromUrl ?? authenticationSession.getProjectId()!; const queryClient = useQueryClient(); @@ -100,12 +110,12 @@ export function useAutomationsData( filters.typeFilter.length > 0 && !filters.typeFilter.includes('table'); const rootFlowsQuery = useQuery({ - queryKey: ['root-flows', projectId, filters], + queryKey: ['root-flows', projectId, filters, sort], queryFn: () => flowsApi.list({ projectId, folderId: isFiltered ? undefined : UncategorizedFolderId, - limit: 1000, + limit: ROOT_ITEMS_LIMIT, cursor: undefined, name: filters.searchTerm || undefined, status: @@ -116,6 +126,8 @@ export function useAutomationsData( filters.connectionFilter.length > 0 ? filters.connectionFilter : undefined, + sortBy: sort === 'default' ? undefined : 'NAME', + order: sortOrder(sort), }), enabled: !skipFlows, staleTime: STALE_TIME, @@ -123,14 +135,16 @@ export function useAutomationsData( }); const rootTablesQuery = useQuery({ - queryKey: ['root-tables', projectId, filters], + queryKey: ['root-tables', projectId, filters, sort], queryFn: () => tablesApi.list({ projectId, folderId: isFiltered ? undefined : UncategorizedFolderId, - limit: 1000, + limit: ROOT_ITEMS_LIMIT, cursor: undefined, name: filters.searchTerm || undefined, + sortBy: sort === 'default' ? undefined : 'NAME', + order: sortOrder(sort), }), enabled: !skipTables && !hideTables, staleTime: STALE_TIME, @@ -195,18 +209,19 @@ export function useAutomationsData( ); } - const { items, totalItems } = buildFilteredTreeItems( - rootFlows, - rootTables, + const { items, totalItems } = buildFilteredTreeItems({ + flows: rootFlows, + tables: rootTables, folders, folderVisibleCounts, - rootPage, + page: rootPage, pageSize, pinnedList, - filters.searchTerm, + searchTerm: filters.searchTerm, folderContents, folderCounts, - ); + sort, + }); return { treeItems: items, totalPageItems: totalItems }; } @@ -217,7 +232,7 @@ export function useAutomationsData( rootTables = []; } - const { items, totalRootItems } = buildTreeItems( + const { items, totalRootItems } = buildTreeItems({ folders, rootFlows, rootTables, @@ -227,7 +242,8 @@ export function useAutomationsData( rootPage, pageSize, pinnedList, - ); + sort, + }); return { treeItems: items, totalPageItems: totalRootItems }; }, [ @@ -243,6 +259,7 @@ export function useAutomationsData( filters.searchTerm, filters.folderFilter, pinnedList, + sort, ]); const hasFolderFilter = filters.folderFilter.length > 0; @@ -316,6 +333,17 @@ export function useAutomationsData( }; } +function sortOrder(sort: AutomationsSort): 'ASC' | 'DESC' | undefined { + switch (sort) { + case 'name-asc': + return 'ASC'; + case 'name-desc': + return 'DESC'; + case 'default': + return undefined; + } +} + type FolderContentsMap = Map; function buildFolderContentsMap( diff --git a/packages/web/src/features/automations/hooks/use-automations-filters.ts b/packages/web/src/features/automations/hooks/use-automations-filters.ts index c149f48bfa0c..ec07cbd02c18 100644 --- a/packages/web/src/features/automations/hooks/use-automations-filters.ts +++ b/packages/web/src/features/automations/hooks/use-automations-filters.ts @@ -2,7 +2,7 @@ import { useCallback, useMemo, useState } from 'react'; import { useSearchParams } from 'react-router-dom'; import { useDebouncedCallback } from 'use-debounce'; -import { AutomationsFilters } from '../lib/types'; +import { AutomationsFilters, AutomationsSort } from '../lib/types'; import { hasActiveFilters } from '../lib/utils'; const SEARCH_PARAM = 'search'; @@ -11,6 +11,7 @@ const STATUS_PARAM = 'status'; const CONNECTION_PARAM = 'connection'; const OWNER_PARAM = 'owner'; const FOLDER_PARAM = 'folder'; +const SORT_PARAM = 'sort'; const FILTER_PARAMS = [ SEARCH_PARAM, @@ -47,6 +48,7 @@ export function useAutomationsFilters() { () => searchParams.getAll(FOLDER_PARAM), [folderParamStr], ); + const sort = parseSort(searchParams.get(SORT_PARAM)); const updateParams = useCallback( (updates: Record) => { @@ -122,6 +124,13 @@ export function useAutomationsFilters() { [updateParams], ); + const setSort = useCallback( + (value: AutomationsSort) => { + updateParams({ [SORT_PARAM]: value === 'default' ? null : value }); + }, + [updateParams], + ); + const filters: AutomationsFilters = { searchTerm, typeFilter, @@ -156,8 +165,20 @@ export function useAutomationsFilters() { setOwnerFilter, folderFilter, setFolderFilter, + sort, + setSort, filters, filtersActive, clearAllFilters, }; } + +function parseSort(value: string | null): AutomationsSort { + switch (value) { + case 'name-asc': + case 'name-desc': + return value; + default: + return 'default'; + } +} diff --git a/packages/web/src/features/automations/hooks/use-automations-mutations.ts b/packages/web/src/features/automations/hooks/use-automations-mutations.ts index 6a7d51d520c0..568f4d25a350 100644 --- a/packages/web/src/features/automations/hooks/use-automations-mutations.ts +++ b/packages/web/src/features/automations/hooks/use-automations-mutations.ts @@ -53,6 +53,7 @@ export function useAutomationsMutations(deps: MutationDeps) { }); }, onSuccess: (flow) => { + deps.invalidateRoot(); navigate(`/flows/${flow.id}?${NEW_FLOW_QUERY_PARAM}=true`); }, }); @@ -68,6 +69,7 @@ export function useAutomationsMutations(deps: MutationDeps) { }, onSuccess: (table) => { queryClient.invalidateQueries({ queryKey: ['tables'] }); + deps.invalidateRoot(); navigate( `/projects/${projectId}/tables/${table.id}?${NEW_TABLE_QUERY_PARAM}=true`, ); diff --git a/packages/web/src/features/automations/lib/types.ts b/packages/web/src/features/automations/lib/types.ts index d00f5d29402b..2da47dfb79e2 100644 --- a/packages/web/src/features/automations/lib/types.ts +++ b/packages/web/src/features/automations/lib/types.ts @@ -36,3 +36,5 @@ export type RootPage = { flows: SeekPage; tables: SeekPage
; }; + +export type AutomationsSort = 'default' | 'name-asc' | 'name-desc'; diff --git a/packages/web/src/features/automations/lib/utils.ts b/packages/web/src/features/automations/lib/utils.ts index cdfda2ff7d8b..18ad562cc657 100644 --- a/packages/web/src/features/automations/lib/utils.ts +++ b/packages/web/src/features/automations/lib/utils.ts @@ -1,10 +1,16 @@ import { FolderDto, PopulatedFlow, Table } from '@activepieces/shared'; -import { AutomationsFilters, FolderContent, TreeItem } from './types'; +import { + AutomationsFilters, + AutomationsSort, + FolderContent, + TreeItem, +} from './types'; export const DEFAULT_PAGE_SIZE = 10; export const PAGE_SIZE_OPTIONS = [10, 20, 50]; export const FOLDER_PAGE_SIZE = 50; +export const ROOT_ITEMS_LIMIT = 1000; export function getUpdatedDate( item: PopulatedFlow | Table | FolderDto, @@ -19,10 +25,15 @@ export function getItemName(item: PopulatedFlow | Table): string { return item.name; } -export function mergeAndSortItems( - flows: PopulatedFlow[], - tables: Table[], -): TreeItem[] { +export function mergeAndSortItems({ + flows, + tables, + sort, +}: { + flows: PopulatedFlow[]; + tables: Table[]; + sort: AutomationsSort; +}): TreeItem[] { const items: TreeItem[] = []; flows.forEach((flow) => { @@ -47,16 +58,23 @@ export function mergeAndSortItems( }); }); - items.sort((a, b) => getUpdatedDate(b.data!) - getUpdatedDate(a.data!)); + items.sort(treeItemComparator(sort)); return items; } -export function buildFolderChildren( - content: FolderContent, - folderId: string, - visibleCount: number, - totalCount: number, -): TreeItem[] { +export function buildFolderChildren({ + content, + folderId, + visibleCount, + totalCount, + sort, +}: { + content: FolderContent; + folderId: string; + visibleCount: number; + totalCount: number; + sort: AutomationsSort; +}): TreeItem[] { const children: TreeItem[] = []; content.flows.forEach((flow) => { @@ -81,7 +99,7 @@ export function buildFolderChildren( }); }); - children.sort((a, b) => getUpdatedDate(b.data!) - getUpdatedDate(a.data!)); + children.sort(treeItemComparator(sort)); const visible = children.slice(0, visibleCount); const remaining = totalCount - Math.min(visibleCount, children.length); @@ -101,17 +119,29 @@ export function buildFolderChildren( return visible; } -export function buildTreeItems( - folders: FolderDto[], - rootFlows: PopulatedFlow[], - rootTables: Table[], - folderContents: Map, - folderCounts: Map, - folderVisibleCounts: Map, - rootPage: number, - pageSize: number, - pinnedList?: string[], -): { items: TreeItem[]; totalRootItems: number } { +export function buildTreeItems({ + folders, + rootFlows, + rootTables, + folderContents, + folderCounts, + folderVisibleCounts, + rootPage, + pageSize, + pinnedList, + sort, +}: { + folders: FolderDto[]; + rootFlows: PopulatedFlow[]; + rootTables: Table[]; + folderContents: Map; + folderCounts: Map; + folderVisibleCounts: Map; + rootPage: number; + pageSize: number; + pinnedList?: string[]; + sort: AutomationsSort; +}): { items: TreeItem[]; totalRootItems: number } { const seenIds = new Set(); const folderItems: TreeItem[] = folders.map((folder) => { @@ -134,7 +164,12 @@ export function buildTreeItems( (t) => !t.folderId || !folderIdSet.has(t.folderId), ); - const rootItems = mergeAndSortItems(dedupedFlows, dedupedTables); + const rootItems = mergeAndSortItems({ + flows: dedupedFlows, + tables: dedupedTables, + sort, + }); + const compareItems = treeItemComparator(sort); const allTopLevel = [...folderItems, ...rootItems]; allTopLevel.sort((a, b) => { const aOrder = pinnedList ? pinnedList.indexOf(a.id) : -1; @@ -143,7 +178,7 @@ export function buildTreeItems( const bPinned = bOrder !== -1; if (aPinned && bPinned) return aOrder - bOrder; if (aPinned !== bPinned) return aPinned ? -1 : 1; - return getUpdatedDate(b.data!) - getUpdatedDate(a.data!); + return compareItems(a, b); }); const totalRootItems = allTopLevel.length; @@ -164,12 +199,13 @@ export function buildTreeItems( const visibleCount = folderVisibleCounts.get(item.id) ?? FOLDER_PAGE_SIZE; const totalCount = folderCounts.get(item.id) ?? 0; - const children = buildFolderChildren( + const children = buildFolderChildren({ content, - item.id, + folderId: item.id, visibleCount, totalCount, - ); + sort, + }); children.forEach((child) => { const childKey = `${child.type}-${child.id}`; if (seenIds.has(childKey)) return; @@ -183,18 +219,32 @@ export function buildTreeItems( return { items: result, totalRootItems }; } -export function buildFilteredTreeItems( - flows: PopulatedFlow[], - tables: Table[], - folders: FolderDto[], - folderVisibleCounts: Map, - page: number, - pageSize: number, - pinnedList?: string[], - searchTerm?: string, - folderContents?: Map, - folderCounts?: Map, -): { items: TreeItem[]; totalItems: number } { +export function buildFilteredTreeItems({ + flows, + tables, + folders, + folderVisibleCounts, + page, + pageSize, + pinnedList, + searchTerm, + folderContents, + folderCounts, + sort, +}: { + flows: PopulatedFlow[]; + tables: Table[]; + folders: FolderDto[]; + folderVisibleCounts: Map; + page: number; + pageSize: number; + pinnedList?: string[]; + searchTerm?: string; + folderContents?: Map; + folderCounts?: Map; + sort: AutomationsSort; +}): { items: TreeItem[]; totalItems: number } { + const compareItems = treeItemComparator(sort); const folderMap = new Map(); folders.forEach((f) => folderMap.set(f.id, f)); @@ -246,7 +296,7 @@ export function buildFilteredTreeItems( for (const [folderId, children] of folderChildren) { const folder = folderMap.get(folderId)!; - children.sort((a, b) => getUpdatedDate(b.data!) - getUpdatedDate(a.data!)); + children.sort(compareItems); folderItems.push({ id: folder.id, type: 'folder', @@ -269,12 +319,14 @@ export function buildFilteredTreeItems( const content = folderContents?.get(folder.id); const totalCount = folderCounts?.get(folder.id) ?? 0; if (content) { - const children = buildFolderChildren( + const children = buildFolderChildren({ content, - folder.id, - folderVisibleCounts.get(folder.id) ?? FOLDER_PAGE_SIZE, + folderId: folder.id, + visibleCount: + folderVisibleCounts.get(folder.id) ?? FOLDER_PAGE_SIZE, totalCount, - ); + sort, + }); folderChildren.set(folder.id, children); } folderItems.push({ @@ -299,7 +351,7 @@ export function buildFilteredTreeItems( const bPinned = bOrder !== -1; if (aPinned && bPinned) return aOrder - bOrder; if (aPinned !== bPinned) return aPinned ? -1 : 1; - return getUpdatedDate(b.data!) - getUpdatedDate(a.data!); + return compareItems(a, b); }); const totalItems = allTopLevel.length; @@ -357,7 +409,16 @@ export function getItemKey(item: TreeItem): string { return `${item.type}-${item.id}`; } -export type TreeRow = { item: TreeItem; children: TreeItem[] }; +export function nextSort(sort: AutomationsSort): AutomationsSort { + switch (sort) { + case 'default': + return 'name-asc'; + case 'name-asc': + return 'name-desc'; + case 'name-desc': + return 'default'; + } +} export function groupTreeItemsByFolder(items: TreeItem[]): TreeRow[] { return items.reduce((rows, item) => { @@ -370,3 +431,19 @@ export function groupTreeItemsByFolder(items: TreeItem[]): TreeRow[] { return rows; }, []); } + +function treeItemComparator( + sort: AutomationsSort, +): (a: TreeItem, b: TreeItem) => number { + if (sort === 'default') { + return (a, b) => getUpdatedDate(b.data!) - getUpdatedDate(a.data!); + } + const direction = sort === 'name-asc' ? 1 : -1; + return (a, b) => + direction * + a.name.localeCompare(b.name, NAME_SORT_LOCALE, { sensitivity: 'accent' }); +} + +const NAME_SORT_LOCALE = 'en'; + +export type TreeRow = { item: TreeItem; children: TreeItem[] }; diff --git a/packages/web/test/features/automations/lib/utils.test.ts b/packages/web/test/features/automations/lib/utils.test.ts new file mode 100644 index 000000000000..2cfc94d9bda7 --- /dev/null +++ b/packages/web/test/features/automations/lib/utils.test.ts @@ -0,0 +1,162 @@ +import { FolderDto } from '@activepieces/shared'; +import { describe, expect, it } from 'vitest'; + +import { AutomationsSort } from '@/features/automations/lib/types'; +import { buildTreeItems, nextSort } from '@/features/automations/lib/utils'; + +function folder({ + id, + displayName, + updated, +}: { + id: string; + displayName: string; + updated: string; +}): FolderDto { + return { + id, + displayName, + created: updated, + updated, + projectId: 'project-1', + displayOrder: 0, + externalId: null, + numberOfFlows: 0, + numberOfTables: 0, + }; +} + +function sortedNames({ + folders, + sort, + pinnedList, +}: { + folders: FolderDto[]; + sort: AutomationsSort; + pinnedList?: string[]; +}): string[] { + const { items } = buildTreeItems({ + folders, + rootFlows: [], + rootTables: [], + folderContents: new Map(), + folderCounts: new Map(), + folderVisibleCounts: new Map(), + rootPage: 0, + pageSize: 100, + pinnedList, + sort, + }); + return items.map((item) => item.name); +} + +const zebra = folder({ + id: 'zebra', + displayName: 'Zebra', + updated: '2020-01-03T00:00:00.000Z', +}); +const apple = folder({ + id: 'apple', + displayName: 'apple', + updated: '2020-01-02T00:00:00.000Z', +}); +const mango = folder({ + id: 'mango', + displayName: 'Mango', + updated: '2020-01-01T00:00:00.000Z', +}); + +describe('automations name sort', () => { + it('keeps the most recently updated first when sort is default', () => { + expect( + sortedNames({ folders: [apple, mango, zebra], sort: 'default' }), + ).toEqual(['Zebra', 'apple', 'Mango']); + }); + + it('ignores case when sorting ascending', () => { + expect( + sortedNames({ folders: [zebra, apple, mango], sort: 'name-asc' }), + ).toEqual(['apple', 'Mango', 'Zebra']); + }); + + it('ignores case when sorting descending', () => { + expect( + sortedNames({ folders: [apple, mango, zebra], sort: 'name-desc' }), + ).toEqual(['Zebra', 'Mango', 'apple']); + }); + + it('orders embedded numbers lexically, matching the server', () => { + const folders = [ + folder({ + id: 'two', + displayName: 'Table 2', + updated: '2020-01-01T00:00:00.000Z', + }), + folder({ + id: 'ten', + displayName: 'Table 10', + updated: '2020-01-01T00:00:00.000Z', + }), + ]; + expect(sortedNames({ folders, sort: 'name-asc' })).toEqual([ + 'Table 10', + 'Table 2', + ]); + }); + + it('distinguishes accents, matching LOWER() on the server', () => { + const folders = [ + folder({ + id: 'accented', + displayName: 'éclair', + updated: '2020-01-01T00:00:00.000Z', + }), + folder({ + id: 'plain', + displayName: 'Eclair', + updated: '2020-01-01T00:00:00.000Z', + }), + ]; + expect(sortedNames({ folders, sort: 'name-asc' })).toEqual([ + 'Eclair', + 'éclair', + ]); + }); + + it('pins the collation locale so every browser agrees', () => { + const folders = [ + folder({ + id: 'z', + displayName: 'zebra', + updated: '2020-01-01T00:00:00.000Z', + }), + folder({ + id: 'a-ring', + displayName: 'äpple', + updated: '2020-01-01T00:00:00.000Z', + }), + ]; + expect(sortedNames({ folders, sort: 'name-asc' })).toEqual([ + 'äpple', + 'zebra', + ]); + }); + + it('keeps pinned items first in both directions', () => { + const folders = [zebra, apple, mango]; + expect( + sortedNames({ folders, sort: 'name-asc', pinnedList: ['zebra'] }), + ).toEqual(['Zebra', 'apple', 'Mango']); + expect( + sortedNames({ folders, sort: 'name-desc', pinnedList: ['apple'] }), + ).toEqual(['apple', 'Zebra', 'Mango']); + }); +}); + +describe('nextSort', () => { + it('cycles default to ascending to descending and back', () => { + expect(nextSort('default')).toBe('name-asc'); + expect(nextSort('name-asc')).toBe('name-desc'); + expect(nextSort('name-desc')).toBe('default'); + }); +});