mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-12 02:19:43 +00:00
fix: Load only the publish history that the editor shows (#40183)
Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
7397073d86
commit
b28adf9789
@@ -527,6 +527,10 @@ export {
|
||||
workflowHistoryListItemSchema,
|
||||
} from './workflow-history/workflow-history-list-item.dto';
|
||||
export { ListWorkflowHistoryQueryDto } from './workflow-history/list-workflow-history-query.dto';
|
||||
export {
|
||||
WorkflowPublishTimelineQueryDto,
|
||||
WORKFLOW_HISTORY_DEFAULT_TAKE,
|
||||
} from './workflow-history/workflow-publish-timeline-query.dto';
|
||||
export {
|
||||
WorkflowVersionHistoryListPublicDto,
|
||||
workflowVersionListItemPublicSchema,
|
||||
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import {
|
||||
PaginationDto,
|
||||
MAX_ITEMS_PER_PAGE,
|
||||
createTakeValidator,
|
||||
} from '../pagination/pagination.dto';
|
||||
|
||||
export const WORKFLOW_HISTORY_DEFAULT_TAKE = 20;
|
||||
|
||||
export class WorkflowPublishTimelineQueryDto extends PaginationDto.extend({
|
||||
take: createTakeValidator(MAX_ITEMS_PER_PAGE, false, WORKFLOW_HISTORY_DEFAULT_TAKE),
|
||||
}) {}
|
||||
@@ -300,11 +300,44 @@ describe('SharedWorkflowRepository', () => {
|
||||
expect(workflowPublishHistoryRepository.findByVersion).toHaveBeenCalledWith(
|
||||
'workflow-1',
|
||||
'version-1',
|
||||
'all',
|
||||
entityManager,
|
||||
);
|
||||
expect(result?.workflow.activeVersion?.workflowPublishHistory).toBe(events);
|
||||
});
|
||||
|
||||
it('passes the requested publish history scope to the query', async () => {
|
||||
entityManager.findOne.mockResolvedValueOnce(
|
||||
sharedWorkflowWith(mock<WorkflowHistory>({ versionId: 'version-1' })),
|
||||
);
|
||||
workflowPublishHistoryRepository.findByVersion.mockResolvedValueOnce([]);
|
||||
|
||||
await sharedWorkflowRepository.findWorkflowWithOptions('workflow-1', {
|
||||
includeActiveVersion: true,
|
||||
publishHistory: 'latestActivation',
|
||||
});
|
||||
|
||||
expect(workflowPublishHistoryRepository.findByVersion).toHaveBeenCalledWith(
|
||||
'workflow-1',
|
||||
'version-1',
|
||||
'latestActivation',
|
||||
entityManager,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not load publish history when the scope is none', async () => {
|
||||
entityManager.findOne.mockResolvedValueOnce(
|
||||
sharedWorkflowWith(mock<WorkflowHistory>({ versionId: 'version-1' })),
|
||||
);
|
||||
|
||||
await sharedWorkflowRepository.findWorkflowWithOptions('workflow-1', {
|
||||
includeActiveVersion: true,
|
||||
publishHistory: 'none',
|
||||
});
|
||||
|
||||
expect(workflowPublishHistoryRepository.findByVersion).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('loads the publish history with the entity manager of the caller', async () => {
|
||||
const trx = mock<EntityManager>();
|
||||
trx.findOne.mockResolvedValueOnce(
|
||||
@@ -320,6 +353,7 @@ describe('SharedWorkflowRepository', () => {
|
||||
expect(workflowPublishHistoryRepository.findByVersion).toHaveBeenCalledWith(
|
||||
'workflow-1',
|
||||
'version-1',
|
||||
'all',
|
||||
trx,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -115,7 +115,10 @@ export {
|
||||
WorkflowPublishedVersionRepository,
|
||||
type PublishedWorkflowDataForExecution,
|
||||
} from './workflow-published-version.repository';
|
||||
export { WorkflowPublishHistoryRepository } from './workflow-publish-history.repository';
|
||||
export {
|
||||
WorkflowPublishHistoryRepository,
|
||||
type PublishHistoryScope,
|
||||
} from './workflow-publish-history.repository';
|
||||
export {
|
||||
WorkflowReviewRequestRepository,
|
||||
type WorkflowReviewRequestForWorkflowRow,
|
||||
|
||||
@@ -14,7 +14,10 @@ import type {
|
||||
} from '@n8n/typeorm';
|
||||
|
||||
import { BaseRepository } from './base-repository';
|
||||
import { WorkflowPublishHistoryRepository } from './workflow-publish-history.repository';
|
||||
import {
|
||||
type PublishHistoryScope,
|
||||
WorkflowPublishHistoryRepository,
|
||||
} from './workflow-publish-history.repository';
|
||||
import type { User } from '../entities';
|
||||
import { Project, ProjectRelation, SharedWorkflow } from '../entities';
|
||||
import { type OperationContext, TransactionRunner } from '../services/transaction';
|
||||
@@ -349,6 +352,7 @@ export class SharedWorkflowRepository extends BaseRepository<SharedWorkflow> {
|
||||
includeTags?: boolean;
|
||||
includeParentFolder?: boolean;
|
||||
includeActiveVersion?: boolean;
|
||||
publishHistory?: PublishHistoryScope;
|
||||
} & ({ em?: EntityManager; ctx?: never } | { ctx?: OperationContext; em?: never }) = {},
|
||||
) {
|
||||
const {
|
||||
@@ -356,6 +360,7 @@ export class SharedWorkflowRepository extends BaseRepository<SharedWorkflow> {
|
||||
includeTags = false,
|
||||
includeParentFolder = false,
|
||||
includeActiveVersion = false,
|
||||
publishHistory = 'all',
|
||||
ctx = {},
|
||||
em = this.managerFor(ctx),
|
||||
} = options;
|
||||
@@ -376,11 +381,12 @@ export class SharedWorkflowRepository extends BaseRepository<SharedWorkflow> {
|
||||
});
|
||||
|
||||
const activeVersion = sharedWorkflow?.workflow.activeVersion;
|
||||
if (activeVersion) {
|
||||
if (activeVersion && publishHistory !== 'none') {
|
||||
activeVersion.workflowPublishHistory =
|
||||
await this.workflowPublishHistoryRepository.findByVersion(
|
||||
workflowId,
|
||||
activeVersion.versionId,
|
||||
publishHistory,
|
||||
em,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,9 @@ import type { EntityManager } from '@n8n/typeorm';
|
||||
import { BaseRepository } from './base-repository';
|
||||
import { WorkflowPublishHistory } from '../entities';
|
||||
import { type OperationContext, TransactionRunner } from '../services/transaction';
|
||||
import { chunkIds } from '../utils/chunk-ids';
|
||||
|
||||
export type PublishHistoryScope = 'all' | 'latestActivation' | 'none';
|
||||
|
||||
@Service()
|
||||
export class WorkflowPublishHistoryRepository extends BaseRepository<WorkflowPublishHistory> {
|
||||
@@ -39,15 +42,64 @@ export class WorkflowPublishHistoryRepository extends BaseRepository<WorkflowPub
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the events of one version, oldest first. Use this method, not a
|
||||
* join on the `workflowPublishHistory` relation. A join repeats the nodes
|
||||
* JSON of the version for each event, and a version can have many events.
|
||||
* Use this method, not a join on the `workflowPublishHistory` relation.
|
||||
* A join repeats the nodes JSON of the version for each event.
|
||||
*/
|
||||
async findByVersion(workflowId: string, versionId: string, trx?: EntityManager) {
|
||||
async findByVersion(
|
||||
workflowId: string,
|
||||
versionId: string,
|
||||
scope: Exclude<PublishHistoryScope, 'none'> = 'all',
|
||||
trx?: EntityManager,
|
||||
) {
|
||||
const repository = trx ? trx.getRepository(WorkflowPublishHistory) : this;
|
||||
if (scope === 'latestActivation') {
|
||||
return await repository.find({
|
||||
where: { workflowId, versionId, event: 'activated' },
|
||||
order: { id: 'DESC' },
|
||||
take: 1,
|
||||
});
|
||||
}
|
||||
return await repository.find({ where: { workflowId, versionId }, order: { id: 'ASC' } });
|
||||
}
|
||||
|
||||
async findLatestActivations(workflowId: string, versionIds: string[]) {
|
||||
if (versionIds.length === 0) return [];
|
||||
|
||||
const activations: WorkflowPublishHistory[] = [];
|
||||
for (const batch of chunkIds([...new Set(versionIds)])) {
|
||||
const latestIds = this.createQueryBuilder('latest')
|
||||
.select('MAX(latest.id)')
|
||||
.where('latest.workflowId = :workflowId', { workflowId })
|
||||
.andWhere('latest.versionId IN (:...versionIds)', { versionIds: batch })
|
||||
.andWhere('latest.event = :event', { event: 'activated' })
|
||||
.groupBy('latest.versionId');
|
||||
|
||||
const batchActivations = await this.createQueryBuilder('wph')
|
||||
.where(`wph.id IN (${latestIds.getQuery()})`)
|
||||
.setParameters(latestIds.getParameters())
|
||||
.getMany();
|
||||
activations.push(...batchActivations);
|
||||
}
|
||||
return activations;
|
||||
}
|
||||
|
||||
async findTimelinePage(workflowId: string, { offset, limit }: { offset: number; limit: number }) {
|
||||
// TypeORM omits the SQL limit when it is zero.
|
||||
if (limit <= 0) return [];
|
||||
|
||||
// The joins are many-to-one, so `offset` and `limit` page the events.
|
||||
return await this.createQueryBuilder('wph')
|
||||
.leftJoinAndSelect('wph.user', 'user')
|
||||
.leftJoin('wph.workflowHistory', 'wh')
|
||||
.addSelect('wh.name')
|
||||
.where('wph.workflowId = :workflowId', { workflowId })
|
||||
.orderBy('wph.createdAt', 'DESC')
|
||||
.addOrderBy('wph.id', 'DESC')
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
.getMany();
|
||||
}
|
||||
|
||||
async findActivatedByUserId(workflowId: string): Promise<string | undefined> {
|
||||
const record = await this.findOne({
|
||||
select: ['userId'],
|
||||
|
||||
@@ -163,6 +163,12 @@ describe('execute-workflow MCP tool', () => {
|
||||
|
||||
expect(result.status).toBe('started');
|
||||
expect(result.executionId).toBe('execution-id');
|
||||
expect(workflowFinderService.findWorkflowForUser).toHaveBeenCalledWith(
|
||||
'unpublished-workflow',
|
||||
user,
|
||||
['workflow:execute'],
|
||||
{ includeActiveVersion: true, publishHistory: 'none' },
|
||||
);
|
||||
});
|
||||
|
||||
test('allows manual mode for workflows with only manual trigger', async () => {
|
||||
|
||||
@@ -220,7 +220,11 @@ describe('get-workflow-details MCP tool', () => {
|
||||
'wf-1',
|
||||
user,
|
||||
['workflow:read'],
|
||||
expect.objectContaining({ includeActiveVersion: true, includeParentFolder: true }),
|
||||
expect.objectContaining({
|
||||
includeActiveVersion: true,
|
||||
includeParentFolder: true,
|
||||
publishHistory: 'none',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(payload.workflow.nodes).toBeUndefined();
|
||||
|
||||
@@ -138,6 +138,7 @@ describe('publish-workflow MCP tool', () => {
|
||||
expect(workflowService.activateWorkflow).toHaveBeenCalledWith(user, 'wf-1', {
|
||||
versionId: undefined,
|
||||
source: 'n8n-mcp',
|
||||
publishHistory: 'none',
|
||||
});
|
||||
|
||||
expect(collaborationService.broadcastWorkflowUpdate).toHaveBeenCalledWith('wf-1', user.id);
|
||||
@@ -173,6 +174,7 @@ describe('publish-workflow MCP tool', () => {
|
||||
expect(workflowService.activateWorkflow).toHaveBeenCalledWith(user, 'wf-1', {
|
||||
versionId,
|
||||
source: 'n8n-mcp',
|
||||
publishHistory: 'none',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -198,7 +198,7 @@ export const executeWorkflow = async (
|
||||
user,
|
||||
['workflow:execute'],
|
||||
workflowFinderService,
|
||||
{ includeActiveVersion: true },
|
||||
{ includeActiveVersion: true, publishHistory: 'none' },
|
||||
);
|
||||
const runData = await buildRunData(
|
||||
workflow,
|
||||
|
||||
@@ -182,7 +182,12 @@ export async function getWorkflowDetails(
|
||||
user,
|
||||
['workflow:read'],
|
||||
workflowFinderService,
|
||||
{ includeActiveVersion: true, includeTags: true, includeParentFolder: true },
|
||||
{
|
||||
includeActiveVersion: true,
|
||||
includeTags: true,
|
||||
includeParentFolder: true,
|
||||
publishHistory: 'none',
|
||||
},
|
||||
);
|
||||
|
||||
// Compute user scopes for this workflow
|
||||
|
||||
@@ -94,6 +94,7 @@ export const createPublishWorkflowTool = (
|
||||
const activatedWorkflow = await workflowService.activateWorkflow(user, workflowId, {
|
||||
versionId,
|
||||
source: 'n8n-mcp',
|
||||
publishHistory: 'none',
|
||||
});
|
||||
|
||||
void collaborationService.broadcastWorkflowUpdate(workflowId, user.id).catch(() => {});
|
||||
|
||||
@@ -18,7 +18,7 @@ export async function getMcpWorkflowVersion(
|
||||
): Promise<WorkflowHistory> {
|
||||
try {
|
||||
return await workflowHistoryService.getVersion(user, workflowId, versionId, {
|
||||
includePublishHistory: false,
|
||||
publishHistory: 'none',
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof WorkflowHistoryVersionNotFoundError) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { UrlService } from '@n8n/backend-services';
|
||||
import type { User } from '@n8n/db';
|
||||
import type { PublishHistoryScope, User } from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
import type { Scope } from '@n8n/permissions';
|
||||
|
||||
@@ -43,6 +43,7 @@ export type GetMcpWorkflowOptions = {
|
||||
includeActiveVersion?: boolean;
|
||||
includeTags?: boolean;
|
||||
includeParentFolder?: boolean;
|
||||
publishHistory?: PublishHistoryScope;
|
||||
};
|
||||
|
||||
/** The editor opens the workflow settings modal when the `settings` query parameter is set. */
|
||||
|
||||
@@ -762,7 +762,7 @@ export class WorkflowsPublicController {
|
||||
workflowId,
|
||||
workflowVersionId,
|
||||
{
|
||||
includePublishHistory: false,
|
||||
publishHistory: 'none',
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
@@ -860,7 +860,7 @@ export class WorkflowsPublicController {
|
||||
req.user,
|
||||
workflowId,
|
||||
workflowVersionId,
|
||||
{ includePublishHistory: false },
|
||||
{ publishHistory: 'none' },
|
||||
);
|
||||
|
||||
this.eventService.emit('user-retrieved-workflow-version', {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/* eslint-disable @typescript-eslint/unbound-method -- vi mocks */
|
||||
import type {
|
||||
FolderRepository,
|
||||
OperationContext,
|
||||
RoleRepository,
|
||||
SharedWorkflow,
|
||||
SharedWorkflowRepository,
|
||||
@@ -43,23 +44,35 @@ function makeService(rows?: FolderRow[]) {
|
||||
}
|
||||
|
||||
describe('WorkflowFinderService', () => {
|
||||
it('loads roles through the supplied transaction manager', async () => {
|
||||
const { service, roleService, roleRepository } = makeService();
|
||||
const em = mock<EntityManager>();
|
||||
roleService.rolesWithScope.mockResolvedValue([]);
|
||||
roleRepository.findAll.mockResolvedValue([]);
|
||||
it.each(['manager', 'context'])(
|
||||
'keeps the history scope with the transaction %s',
|
||||
async (kind) => {
|
||||
const { service, roleService, roleRepository, sharedWorkflowRepository } = makeService();
|
||||
const transaction =
|
||||
kind === 'manager' ? { em: mock<EntityManager>() } : { ctx: mock<OperationContext>() };
|
||||
roleService.rolesWithScope.mockResolvedValue([]);
|
||||
roleRepository.findAll.mockResolvedValue([]);
|
||||
|
||||
await service.findWorkflowForUser(
|
||||
'workflow-1',
|
||||
{ id: 'user-1', role: { slug: 'global:member', scopes: [] } } as never,
|
||||
['workflow:read'],
|
||||
{ em },
|
||||
);
|
||||
await service.findWorkflowForUser(
|
||||
'workflow-1',
|
||||
{ id: 'user-1', role: { slug: 'global:member', scopes: [] } } as never,
|
||||
['workflow:read'],
|
||||
{ ...transaction, includeActiveVersion: true, publishHistory: 'latestActivation' },
|
||||
);
|
||||
|
||||
const loadRoles = roleService.rolesWithScope.mock.calls[0][2];
|
||||
await loadRoles?.();
|
||||
expect(roleRepository.findAll).toHaveBeenCalledWith(em);
|
||||
});
|
||||
const loadRoles = roleService.rolesWithScope.mock.calls[0][2];
|
||||
await loadRoles?.();
|
||||
expect(roleRepository.findAll).toHaveBeenCalledWith(transaction.em ?? transaction.ctx);
|
||||
expect(sharedWorkflowRepository.findWorkflowWithOptions).toHaveBeenCalledWith(
|
||||
'workflow-1',
|
||||
expect.objectContaining({
|
||||
...transaction,
|
||||
includeActiveVersion: true,
|
||||
publishHistory: 'latestActivation',
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
describe('findWorkflowIdsByFolder', () => {
|
||||
it('returns an empty map without querying when no folder ids are given', async () => {
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { RoleService } from '@n8n/backend-services';
|
||||
import type { SharedWorkflow, User, WorkflowEntity, ListQuery, OperationContext } from '@n8n/db';
|
||||
import type {
|
||||
SharedWorkflow,
|
||||
User,
|
||||
WorkflowEntity,
|
||||
ListQuery,
|
||||
PublishHistoryScope,
|
||||
OperationContext,
|
||||
} from '@n8n/db';
|
||||
import {
|
||||
SharedWorkflowRepository,
|
||||
FolderRepository,
|
||||
@@ -48,6 +55,7 @@ export class WorkflowFinderService {
|
||||
includeTags?: boolean;
|
||||
includeParentFolder?: boolean;
|
||||
includeActiveVersion?: boolean;
|
||||
publishHistory?: PublishHistoryScope;
|
||||
} & ({ em?: EntityManager; ctx?: never } | { ctx?: OperationContext; em?: never }) = {},
|
||||
) {
|
||||
const where = await this.buildSingleWorkflowReadWhere(user, scopes, options.em ?? options.ctx);
|
||||
@@ -57,6 +65,7 @@ export class WorkflowFinderService {
|
||||
includeTags: options.includeTags,
|
||||
includeParentFolder: options.includeParentFolder,
|
||||
includeActiveVersion: options.includeActiveVersion,
|
||||
publishHistory: options.publishHistory,
|
||||
...(options.em ? { em: options.em } : { ctx: options.ctx }),
|
||||
});
|
||||
|
||||
|
||||
+14
-17
@@ -352,7 +352,7 @@ describe('WorkflowHistoryService', () => {
|
||||
});
|
||||
|
||||
describe('getVersion', () => {
|
||||
it('should not load publish history when includePublishHistory is false', async () => {
|
||||
it('should not load publish history when the scope is none', async () => {
|
||||
// Arrange
|
||||
const workflow = getWorkflow({ addNodeWithoutCreds: true });
|
||||
workflow.id = '123';
|
||||
@@ -362,7 +362,7 @@ describe('WorkflowHistoryService', () => {
|
||||
|
||||
// Act
|
||||
const result = await workflowHistoryService.getVersion(testUser, workflow.id, 'version1', {
|
||||
includePublishHistory: false,
|
||||
publishHistory: 'none',
|
||||
});
|
||||
|
||||
// Assert
|
||||
@@ -558,9 +558,9 @@ describe('WorkflowHistoryService', () => {
|
||||
const workflowId = '123';
|
||||
workflowFinderService.findWorkflowForUser.mockResolvedValueOnce(null);
|
||||
|
||||
await expect(workflowHistoryService.getPublishTimeline(testUser, workflowId)).rejects.toThrow(
|
||||
SharedWorkflowNotFoundError,
|
||||
);
|
||||
await expect(
|
||||
workflowHistoryService.getPublishTimeline(testUser, workflowId, { offset: 0, limit: 10 }),
|
||||
).rejects.toThrow(SharedWorkflowNotFoundError);
|
||||
});
|
||||
|
||||
it('should return publish timeline events with version names', async () => {
|
||||
@@ -593,17 +593,12 @@ describe('WorkflowHistoryService', () => {
|
||||
},
|
||||
];
|
||||
|
||||
const qb = {
|
||||
leftJoinAndSelect: vi.fn().mockReturnThis(),
|
||||
leftJoin: vi.fn().mockReturnThis(),
|
||||
addSelect: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
orderBy: vi.fn().mockReturnThis(),
|
||||
getMany: vi.fn().mockResolvedValueOnce(mockEvents),
|
||||
};
|
||||
workflowPublishHistoryRepository.createQueryBuilder.mockReturnValueOnce(qb as never);
|
||||
workflowPublishHistoryRepository.findTimelinePage.mockResolvedValueOnce(mockEvents as never);
|
||||
|
||||
const result = await workflowHistoryService.getPublishTimeline(testUser, workflowId);
|
||||
const result = await workflowHistoryService.getPublishTimeline(testUser, workflowId, {
|
||||
offset: 20,
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
@@ -625,8 +620,10 @@ describe('WorkflowHistoryService', () => {
|
||||
versionName: null,
|
||||
},
|
||||
]);
|
||||
expect(qb.leftJoin).toHaveBeenCalledWith('wph.workflowHistory', 'wh');
|
||||
expect(qb.addSelect).toHaveBeenCalledWith('wh.name');
|
||||
expect(workflowPublishHistoryRepository.findTimelinePage).toHaveBeenCalledWith(workflowId, {
|
||||
offset: 20,
|
||||
limit: 10,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ import {
|
||||
PaginationDto,
|
||||
WorkflowHistoryVersionsByIdsDto,
|
||||
UpdateWorkflowHistoryVersionDto,
|
||||
WorkflowPublishTimelineQueryDto,
|
||||
WORKFLOW_HISTORY_DEFAULT_TAKE,
|
||||
} from '@n8n/api-types';
|
||||
import { AuthenticatedRequest } from '@n8n/db';
|
||||
import { RestController, Get, Post, Query, Body, Patch, Param, Licensed } from '@n8n/decorators';
|
||||
@@ -13,8 +15,6 @@ import { WorkflowHistoryRequest } from '@/requests';
|
||||
|
||||
import { WorkflowHistoryService } from './workflow-history.service';
|
||||
|
||||
const DEFAULT_TAKE = 20;
|
||||
|
||||
@RestController('/workflow-history')
|
||||
export class WorkflowHistoryController {
|
||||
constructor(private readonly historyService: WorkflowHistoryService) {}
|
||||
@@ -25,7 +25,7 @@ export class WorkflowHistoryController {
|
||||
return await this.historyService.getList(
|
||||
req.user,
|
||||
req.params.workflowId,
|
||||
query.take ?? DEFAULT_TAKE,
|
||||
query.take ?? WORKFLOW_HISTORY_DEFAULT_TAKE,
|
||||
query.skip ?? 0,
|
||||
);
|
||||
} catch (e) {
|
||||
@@ -43,6 +43,7 @@ export class WorkflowHistoryController {
|
||||
req.user,
|
||||
req.params.workflowId,
|
||||
req.params.versionId,
|
||||
{ publishHistory: 'latestActivation' },
|
||||
);
|
||||
} catch (e) {
|
||||
if (e instanceof SharedWorkflowNotFoundError) {
|
||||
@@ -76,9 +77,16 @@ export class WorkflowHistoryController {
|
||||
}
|
||||
|
||||
@Get('/workflow/:workflowId/publish-timeline')
|
||||
async getPublishTimeline(req: WorkflowHistoryRequest.GetList) {
|
||||
async getPublishTimeline(
|
||||
req: WorkflowHistoryRequest.GetList,
|
||||
_res: Response,
|
||||
@Query query: WorkflowPublishTimelineQueryDto,
|
||||
) {
|
||||
try {
|
||||
return await this.historyService.getPublishTimeline(req.user, req.params.workflowId);
|
||||
return await this.historyService.getPublishTimeline(req.user, req.params.workflowId, {
|
||||
offset: query.skip,
|
||||
limit: query.take,
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof SharedWorkflowNotFoundError) {
|
||||
throw new NotFoundError('Could not find workflow');
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { UpdateWorkflowHistoryVersionDto } from '@n8n/api-types';
|
||||
import { Logger } from '@n8n/backend-common';
|
||||
import { EventService } from '@n8n/backend-services';
|
||||
import type { OperationContext, User } from '@n8n/db';
|
||||
import type { OperationContext, PublishHistoryScope, User } from '@n8n/db';
|
||||
import {
|
||||
WorkflowHistory,
|
||||
WorkflowHistoryRepository,
|
||||
@@ -60,7 +60,7 @@ export class WorkflowHistoryService {
|
||||
throw new SharedWorkflowNotFoundError('');
|
||||
}
|
||||
|
||||
return await this.workflowHistoryRepository.find({
|
||||
const versions = await this.workflowHistoryRepository.find({
|
||||
where: {
|
||||
workflowId: workflow.id,
|
||||
},
|
||||
@@ -76,16 +76,30 @@ export class WorkflowHistoryService {
|
||||
'description',
|
||||
'autosaved',
|
||||
],
|
||||
relations: ['workflowPublishHistory'],
|
||||
order: { createdAt: 'DESC' },
|
||||
order: { createdAt: 'DESC', versionId: 'ASC' },
|
||||
});
|
||||
|
||||
// Callers only show the latest publish of each version.
|
||||
const activations = await this.workflowPublishHistoryRepository.findLatestActivations(
|
||||
workflow.id,
|
||||
versions.map(({ versionId }) => versionId),
|
||||
);
|
||||
const activationsByVersion = new Map(
|
||||
activations.map((activation) => [activation.versionId, activation]),
|
||||
);
|
||||
for (const version of versions) {
|
||||
const activation = activationsByVersion.get(version.versionId);
|
||||
version.workflowPublishHistory = activation ? [activation] : [];
|
||||
}
|
||||
|
||||
return versions;
|
||||
}
|
||||
|
||||
async getVersion(
|
||||
user: User,
|
||||
workflowId: string,
|
||||
versionId: string,
|
||||
settings?: { includePublishHistory?: boolean },
|
||||
settings?: { publishHistory?: PublishHistoryScope },
|
||||
): Promise<WorkflowHistory> {
|
||||
const workflow = await this.workflowFinderService.findWorkflowForUser(workflowId, user, [
|
||||
'workflow:read',
|
||||
@@ -105,10 +119,12 @@ export class WorkflowHistoryService {
|
||||
throw new WorkflowHistoryVersionNotFoundError('');
|
||||
}
|
||||
|
||||
if (settings?.includePublishHistory ?? true) {
|
||||
const publishHistory = settings?.publishHistory ?? 'all';
|
||||
if (publishHistory !== 'none') {
|
||||
hist.workflowPublishHistory = await this.workflowPublishHistoryRepository.findByVersion(
|
||||
workflow.id,
|
||||
versionId,
|
||||
publishHistory,
|
||||
);
|
||||
}
|
||||
return hist;
|
||||
@@ -346,7 +362,11 @@ export class WorkflowHistoryService {
|
||||
return versions.map((v) => ({ versionId: v.versionId, createdAt: v.createdAt }));
|
||||
}
|
||||
|
||||
async getPublishTimeline(user: User, workflowId: string) {
|
||||
async getPublishTimeline(
|
||||
user: User,
|
||||
workflowId: string,
|
||||
page: { offset: number; limit: number },
|
||||
) {
|
||||
const workflow = await this.workflowFinderService.findWorkflowForUser(workflowId, user, [
|
||||
'workflow:read',
|
||||
]);
|
||||
@@ -355,14 +375,7 @@ export class WorkflowHistoryService {
|
||||
throw new SharedWorkflowNotFoundError('');
|
||||
}
|
||||
|
||||
const events = await this.workflowPublishHistoryRepository
|
||||
.createQueryBuilder('wph')
|
||||
.leftJoinAndSelect('wph.user', 'user')
|
||||
.leftJoin('wph.workflowHistory', 'wh')
|
||||
.addSelect('wh.name')
|
||||
.where('wph.workflowId = :workflowId', { workflowId: workflow.id })
|
||||
.orderBy('wph.createdAt', 'ASC')
|
||||
.getMany();
|
||||
const events = await this.workflowPublishHistoryRepository.findTimelinePage(workflow.id, page);
|
||||
|
||||
return events.map((e) => ({
|
||||
id: e.id,
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
User,
|
||||
ListQueryDb,
|
||||
Project,
|
||||
PublishHistoryScope,
|
||||
WorkflowFolderUnionFull,
|
||||
WorkflowHistory,
|
||||
OperationContext,
|
||||
@@ -918,6 +919,7 @@ export class WorkflowService {
|
||||
await this.activateWorkflow(user, workflowId, {
|
||||
versionId: workflow.activeVersionId,
|
||||
source,
|
||||
publishHistory: 'none',
|
||||
});
|
||||
}
|
||||
return updatedWorkflow;
|
||||
@@ -1094,6 +1096,7 @@ export class WorkflowService {
|
||||
description?: string;
|
||||
expectedChecksum?: string;
|
||||
source?: WorkflowActionSource;
|
||||
publishHistory?: PublishHistoryScope;
|
||||
},
|
||||
): Promise<WorkflowEntity> {
|
||||
const source = options?.source ?? 'ui';
|
||||
@@ -1151,7 +1154,7 @@ export class WorkflowService {
|
||||
workflow.id,
|
||||
versionIdToActivate,
|
||||
{
|
||||
includePublishHistory: false,
|
||||
publishHistory: 'none',
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
@@ -1341,11 +1344,13 @@ export class WorkflowService {
|
||||
throw new NotFoundError(`Workflow with ID "${workflowId}" could not be found.`);
|
||||
}
|
||||
|
||||
if (updatedWorkflow.activeVersion) {
|
||||
const publishHistory = options?.publishHistory ?? 'all';
|
||||
if (updatedWorkflow.activeVersion && publishHistory !== 'none') {
|
||||
updatedWorkflow.activeVersion.workflowPublishHistory =
|
||||
await this.workflowPublishHistoryRepository.findByVersion(
|
||||
workflowId,
|
||||
updatedWorkflow.activeVersion.versionId,
|
||||
publishHistory,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1373,7 +1378,7 @@ export class WorkflowService {
|
||||
workflowId,
|
||||
user,
|
||||
['workflow:unpublish'],
|
||||
{ includeActiveVersion: true },
|
||||
{ includeActiveVersion: true, publishHistory: 'none' },
|
||||
);
|
||||
|
||||
if (!workflow) {
|
||||
|
||||
@@ -230,6 +230,7 @@ export class WorkflowsController {
|
||||
includeTags: !this.globalConfig.tags.disabled,
|
||||
includeParentFolder: true,
|
||||
includeActiveVersion: true,
|
||||
publishHistory: 'latestActivation',
|
||||
},
|
||||
);
|
||||
|
||||
@@ -263,6 +264,7 @@ export class WorkflowsController {
|
||||
includeTags: !this.globalConfig.tags.disabled,
|
||||
includeParentFolder: true,
|
||||
includeActiveVersion: true,
|
||||
publishHistory: 'latestActivation',
|
||||
},
|
||||
);
|
||||
|
||||
@@ -463,6 +465,7 @@ export class WorkflowsController {
|
||||
name,
|
||||
description,
|
||||
expectedChecksum,
|
||||
publishHistory: 'latestActivation',
|
||||
});
|
||||
|
||||
const scopes = await this.workflowService.getWorkflowScopes(req.user, workflowId);
|
||||
|
||||
+69
@@ -15,6 +15,7 @@ import { sleep } from '@n8n/utils/sleep';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { createUser } from '../../shared/db/users';
|
||||
import { createWorkflowPublishHistoryItem } from '../../shared/db/workflow-publish-history';
|
||||
|
||||
describe('WorkflowPublishHistoryRepository', () => {
|
||||
beforeAll(async () => {
|
||||
@@ -29,6 +30,74 @@ describe('WorkflowPublishHistoryRepository', () => {
|
||||
await testDb.terminate();
|
||||
});
|
||||
|
||||
describe('findLatestActivations', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should not query events when no version IDs are provided', async () => {
|
||||
const repository = Container.get(WorkflowPublishHistoryRepository);
|
||||
const createQueryBuilder = vi.spyOn(repository, 'createQueryBuilder');
|
||||
|
||||
await expect(repository.findLatestActivations('workflow-1', [])).resolves.toEqual([]);
|
||||
|
||||
expect(createQueryBuilder).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should batch version IDs and return each latest activation once', async () => {
|
||||
const repository = Container.get(WorkflowPublishHistoryRepository);
|
||||
const workflow = await createWorkflow();
|
||||
const versionIds = Array.from(
|
||||
{ length: 32_768 },
|
||||
(_, i) => `00000000-0000-4000-8000-${i.toString().padStart(12, '0')}`,
|
||||
);
|
||||
const storedVersionIds = [versionIds[0], versionIds[versionIds.length - 1]];
|
||||
const latestActivations = [];
|
||||
for (const versionId of storedVersionIds) {
|
||||
await createWorkflowHistory({ ...workflow, versionId });
|
||||
const version = { workflowId: workflow.id, versionId };
|
||||
await createWorkflowPublishHistoryItem(version, {
|
||||
createdAt: new Date('2026-01-03T00:00:00Z'),
|
||||
});
|
||||
latestActivations.push(
|
||||
await createWorkflowPublishHistoryItem(version, {
|
||||
createdAt: new Date('2026-01-01T00:00:00Z'),
|
||||
}),
|
||||
);
|
||||
await createWorkflowPublishHistoryItem(version, { event: 'deactivated' });
|
||||
}
|
||||
const logQuery = vi.spyOn(repository.manager.connection.logger, 'logQuery');
|
||||
|
||||
const activations = await repository.findLatestActivations(workflow.id, [
|
||||
...versionIds,
|
||||
...storedVersionIds,
|
||||
]);
|
||||
|
||||
expect(activations).toHaveLength(2);
|
||||
expect(activations).toEqual(expect.arrayContaining(latestActivations));
|
||||
expect(logQuery.mock.calls.map(([, parameters]) => parameters?.length)).toEqual([
|
||||
10_002, 10_002, 10_002, 2_770,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findTimelinePage', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it.each([0, -1])('should not query events when the limit is %i', async (limit) => {
|
||||
const repository = Container.get(WorkflowPublishHistoryRepository);
|
||||
const createQueryBuilder = vi.spyOn(repository, 'createQueryBuilder');
|
||||
|
||||
await expect(
|
||||
repository.findTimelinePage('workflow-1', { offset: 0, limit }),
|
||||
).resolves.toEqual([]);
|
||||
|
||||
expect(createQueryBuilder).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('addRecord', () => {
|
||||
it('should create a publish history record with all fields', async () => {
|
||||
const id1 = uuid();
|
||||
|
||||
@@ -1056,6 +1056,22 @@ describe('GET /workflows/:id', () => {
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test('should return every publish event of the active version in order', async () => {
|
||||
const workflow = await createWorkflowWithTriggerAndHistory({}, member);
|
||||
|
||||
await authMemberAgent.post(`/workflows/${workflow.id}/activate`);
|
||||
await authMemberAgent.post(`/workflows/${workflow.id}/deactivate`);
|
||||
await authMemberAgent.post(`/workflows/${workflow.id}/activate`);
|
||||
|
||||
const response = await authMemberAgent.get(`/workflows/${workflow.id}`);
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const events = (
|
||||
response.body.activeVersion.workflowPublishHistory as Array<{ event: string }>
|
||||
).map(({ event }) => event);
|
||||
expect(events).toEqual(['activated', 'deactivated', 'activated']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /workflows/:id/:versionId', () => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { WORKFLOW_HISTORY_DEFAULT_TAKE } from '@n8n/api-types';
|
||||
import { LicenseState } from '@n8n/backend-common';
|
||||
import { createWorkflow, mockInstance, testDb } from '@n8n/backend-test-utils';
|
||||
import type { User, WorkflowHistory } from '@n8n/db';
|
||||
@@ -7,7 +8,10 @@ import type { IConnections, INode } from 'n8n-workflow';
|
||||
import { ProjectService } from '@/services/project.service.ee';
|
||||
import { createOwner, createUser } from '@test-integration/db/users';
|
||||
import { createWorkflowHistoryItem } from '@test-integration/db/workflow-history';
|
||||
import { createWorkflowPublishHistoryItem } from '@test-integration/db/workflow-publish-history';
|
||||
import {
|
||||
createManyWorkflowPublishHistoryItems,
|
||||
createWorkflowPublishHistoryItem,
|
||||
} from '@test-integration/db/workflow-publish-history';
|
||||
|
||||
import type { SuperAgentTest } from './shared/types';
|
||||
import * as utils from './shared/utils/';
|
||||
@@ -155,6 +159,59 @@ describe('GET /workflow-history/:workflowId', () => {
|
||||
expect(resp.body.data[0]).toEqual(expected);
|
||||
});
|
||||
|
||||
test('should return all versions when take is zero', async () => {
|
||||
const workflow = await createWorkflow(undefined, owner);
|
||||
const versions = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
versions.push(await createWorkflowHistoryItem(workflow.id));
|
||||
}
|
||||
const latest = await createWorkflowPublishHistoryItem(versions[0]);
|
||||
|
||||
const response = await authOwnerAgent
|
||||
.get(`/workflow-history/workflow/${workflow.id}`)
|
||||
.query({ take: 0 })
|
||||
.expect(200);
|
||||
|
||||
const page = response.body.data as WorkflowHistory[];
|
||||
expect(page).toHaveLength(versions.length);
|
||||
expect(page.map(({ versionId }) => versionId).sort()).toEqual(
|
||||
versions.map(({ versionId }) => versionId).sort(),
|
||||
);
|
||||
for (const version of page) {
|
||||
expect(version.workflowPublishHistory).toEqual(
|
||||
version.versionId === latest.versionId
|
||||
? [{ ...latest, createdAt: latest.createdAt.toISOString() }]
|
||||
: [],
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('should page versions with equal timestamps in ascending version ID order', async () => {
|
||||
const workflow = await createWorkflow(undefined, owner);
|
||||
const createdAt = new Date('2026-01-01T00:00:00Z');
|
||||
const versionIds = Array.from(
|
||||
{ length: 5 },
|
||||
(_, i) => `00000000-0000-4000-8000-${i.toString().padStart(12, '0')}`,
|
||||
);
|
||||
for (const versionId of versionIds.toReversed()) {
|
||||
await createWorkflowHistoryItem(workflow.id, { versionId, createdAt });
|
||||
}
|
||||
|
||||
const returnedIds: string[] = [];
|
||||
for (const skip of [0, 2, 4]) {
|
||||
const response = await authOwnerAgent
|
||||
.get(`/workflow-history/workflow/${workflow.id}`)
|
||||
.query({ skip, take: 2 })
|
||||
.expect(200);
|
||||
const page = response.body.data as WorkflowHistory[];
|
||||
expect(page.map(({ versionId }) => versionId)).toEqual(versionIds.slice(skip, skip + 2));
|
||||
returnedIds.push(...page.map(({ versionId }) => versionId));
|
||||
}
|
||||
|
||||
expect(returnedIds).toEqual(versionIds);
|
||||
expect(new Set(returnedIds).size).toBe(versionIds.length);
|
||||
});
|
||||
|
||||
test('should work with skip parameter', async () => {
|
||||
const workflow = await createWorkflow(undefined, owner);
|
||||
const versions = await Promise.all(
|
||||
@@ -188,51 +245,29 @@ describe('GET /workflow-history/:workflowId', () => {
|
||||
expect(resp.body.data[0]).toEqual(expected);
|
||||
});
|
||||
|
||||
test('should include workflowPublishHistory records related to each history item', async () => {
|
||||
test('should include only the latest activation of each history item', async () => {
|
||||
const workflow = await createWorkflow(undefined, owner);
|
||||
const v1 = await createWorkflowHistoryItem(workflow.id);
|
||||
const v2 = await createWorkflowHistoryItem(workflow.id);
|
||||
|
||||
const wph1 = await createWorkflowPublishHistoryItem(v1);
|
||||
const wph2 = await createWorkflowPublishHistoryItem(v1, { event: 'deactivated' });
|
||||
const wph3 = await createWorkflowPublishHistoryItem(v2);
|
||||
await createWorkflowPublishHistoryItem(v1);
|
||||
const latestOfV1 = await createWorkflowPublishHistoryItem(v1);
|
||||
await createWorkflowPublishHistoryItem(v1, { event: 'deactivated' });
|
||||
const latestOfV2 = await createWorkflowPublishHistoryItem(v2);
|
||||
|
||||
const response = await authOwnerAgent.get(`/workflow-history/workflow/${workflow.id}`);
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const body = response.body as { data: WorkflowHistory[] };
|
||||
const publishHistoryOf = (versionId: string) =>
|
||||
body.data.find((history) => history.versionId === versionId)?.workflowPublishHistory;
|
||||
|
||||
expect(body.data).toHaveLength(2);
|
||||
|
||||
const publishHistories = body.data.map((history) => history.workflowPublishHistory);
|
||||
expect(publishHistories).toEqual(
|
||||
expect.arrayContaining([expect.any(Array), expect.any(Array)]),
|
||||
);
|
||||
|
||||
const publishHistory1 = publishHistories.find((ph) => ph.length === 1)!;
|
||||
const publishHistory2 = publishHistories.find((ph) => ph.length === 2)!;
|
||||
|
||||
expect(publishHistory1).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
...wph3,
|
||||
createdAt: wph3.createdAt.toISOString(),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
expect(publishHistory2).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
...wph1,
|
||||
createdAt: wph1.createdAt.toISOString(),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
...wph2,
|
||||
createdAt: wph2.createdAt.toISOString(),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(publishHistoryOf(v1.versionId)).toEqual([
|
||||
{ ...latestOfV1, createdAt: latestOfV1.createdAt.toISOString() },
|
||||
]);
|
||||
expect(publishHistoryOf(v2.versionId)).toEqual([
|
||||
{ ...latestOfV2, createdAt: latestOfV2.createdAt.toISOString() },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -287,12 +322,13 @@ describe('GET /workflow-history/workflow/:workflowId/version/:versionId', () =>
|
||||
);
|
||||
expect(resp.status).toBe(404);
|
||||
});
|
||||
test('should include workflowPublishHistory records related to history item', async () => {
|
||||
test('should include only the latest activation of the history item', async () => {
|
||||
const workflow = await createWorkflow(undefined, owner);
|
||||
const v1 = await createWorkflowHistoryItem(workflow.id);
|
||||
const v2 = await createWorkflowHistoryItem(workflow.id);
|
||||
const wph1 = await createWorkflowPublishHistoryItem(v1);
|
||||
const wph2 = await createWorkflowPublishHistoryItem(v1, { event: 'deactivated' });
|
||||
await createWorkflowPublishHistoryItem(v1);
|
||||
const latest = await createWorkflowPublishHistoryItem(v1);
|
||||
await createWorkflowPublishHistoryItem(v1, { event: 'deactivated' });
|
||||
await createWorkflowPublishHistoryItem(v2);
|
||||
|
||||
const resp = await authOwnerAgent.get(
|
||||
@@ -303,10 +339,7 @@ describe('GET /workflow-history/workflow/:workflowId/version/:versionId', () =>
|
||||
...v1,
|
||||
createdAt: v1.createdAt.toISOString(),
|
||||
updatedAt: v1.updatedAt.toISOString(),
|
||||
workflowPublishHistory: [
|
||||
{ ...wph1, createdAt: wph1.createdAt.toISOString() },
|
||||
{ ...wph2, createdAt: wph2.createdAt.toISOString() },
|
||||
],
|
||||
workflowPublishHistory: [{ ...latest, createdAt: latest.createdAt.toISOString() }],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -502,3 +535,86 @@ describe('PATCH /workflow-history/workflow/:workflowId/versions/:versionId', ()
|
||||
expect(getResponse.body.data.connections).toEqual(originalConnections);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /workflow-history/workflow/:workflowId/publish-timeline', () => {
|
||||
test('should return an empty page when take is zero', async () => {
|
||||
const workflow = await createWorkflow(undefined, owner);
|
||||
const version = await createWorkflowHistoryItem(workflow.id);
|
||||
await createManyWorkflowPublishHistoryItems(version, 5);
|
||||
|
||||
const response = await authOwnerAgent
|
||||
.get(`/workflow-history/workflow/${workflow.id}/publish-timeline`)
|
||||
.query({ take: 0 })
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.data).toEqual([]);
|
||||
});
|
||||
|
||||
test('should use the default page size when take is omitted', async () => {
|
||||
const workflow = await createWorkflow(undefined, owner);
|
||||
const version = await createWorkflowHistoryItem(workflow.id);
|
||||
const events = await createManyWorkflowPublishHistoryItems(
|
||||
version,
|
||||
WORKFLOW_HISTORY_DEFAULT_TAKE + 5,
|
||||
);
|
||||
|
||||
const response = await authOwnerAgent
|
||||
.get(`/workflow-history/workflow/${workflow.id}/publish-timeline`)
|
||||
.expect(200);
|
||||
|
||||
const page = response.body.data as Array<{ id: number }>;
|
||||
expect(page).toHaveLength(WORKFLOW_HISTORY_DEFAULT_TAKE);
|
||||
expect(page.map(({ id }) => id)).toEqual(
|
||||
events
|
||||
.slice(-WORKFLOW_HISTORY_DEFAULT_TAKE)
|
||||
.toReversed()
|
||||
.map(({ id }) => id),
|
||||
);
|
||||
});
|
||||
|
||||
test('should page events with equal timestamps in descending ID order', async () => {
|
||||
const workflow = await createWorkflow(undefined, owner);
|
||||
const version = await createWorkflowHistoryItem(workflow.id);
|
||||
const createdAt = new Date('2026-01-01T00:00:00Z');
|
||||
const events = [];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
events.push(await createWorkflowPublishHistoryItem(version, { createdAt }));
|
||||
}
|
||||
|
||||
const returnedIds: number[] = [];
|
||||
for (const skip of [0, 2, 4]) {
|
||||
const response = await authOwnerAgent
|
||||
.get(`/workflow-history/workflow/${workflow.id}/publish-timeline`)
|
||||
.query({ skip, take: 2 })
|
||||
.expect(200);
|
||||
const page = response.body.data as Array<{ id: number }>;
|
||||
returnedIds.push(...page.map(({ id }) => id));
|
||||
}
|
||||
|
||||
expect(returnedIds).toEqual(events.toReversed().map(({ id }) => id));
|
||||
});
|
||||
|
||||
test('should return one page of events, newest first, with the version name', async () => {
|
||||
const workflow = await createWorkflow(undefined, owner);
|
||||
const version = await createWorkflowHistoryItem(workflow.id, { name: 'Release 1' });
|
||||
const start = new Date('2026-01-01T00:00:00Z').getTime();
|
||||
const events = [];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
events.push(
|
||||
await createWorkflowPublishHistoryItem(version, {
|
||||
event: i % 2 === 0 ? 'activated' : 'deactivated',
|
||||
createdAt: new Date(start + i * 60_000),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const response = await authOwnerAgent
|
||||
.get(`/workflow-history/workflow/${workflow.id}/publish-timeline`)
|
||||
.query({ skip: 1, take: 2 });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const page = response.body.data as Array<{ id: number; event: string; versionName: string }>;
|
||||
expect(page.map(({ id }) => id)).toEqual([events[3].id, events[2].id]);
|
||||
expect(page[0]).toMatchObject({ event: 'deactivated', versionName: 'Release 1' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -371,6 +371,7 @@ describe('update()', () => {
|
||||
expect(activateSpy).toHaveBeenCalledWith(owner, workflow.id, {
|
||||
versionId: workflow.activeVersionId,
|
||||
source: 'ui',
|
||||
publishHistory: 'none',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1087,19 +1087,17 @@ describe('GET /workflows/:workflowId', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('should return every publish event of the active version in order', async () => {
|
||||
test('should return only the latest activation of the active version', async () => {
|
||||
const workflow = await createActiveWorkflow({}, owner);
|
||||
const activeVersion = { workflowId: workflow.id, versionId: workflow.activeVersionId! };
|
||||
await createWorkflowPublishHistoryItem(activeVersion, { event: 'deactivated' });
|
||||
await createWorkflowPublishHistoryItem(activeVersion);
|
||||
const latest = await createWorkflowPublishHistoryItem(activeVersion);
|
||||
|
||||
const response = await authOwnerAgent.get(`/workflows/${workflow.id}`).expect(200);
|
||||
|
||||
const { data } = response.body as { data: { activeVersion: WorkflowHistory } };
|
||||
expect(data.activeVersion.workflowPublishHistory.map(({ event }) => event)).toEqual([
|
||||
'activated',
|
||||
'deactivated',
|
||||
'activated',
|
||||
expect(data.activeVersion.workflowPublishHistory).toEqual([
|
||||
expect.objectContaining({ id: latest.id, event: 'activated' }),
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -92,10 +92,12 @@ export type { PublishTimelineEvent } from '@n8n/api-types';
|
||||
export const getPublishTimeline = async (
|
||||
context: IRestApiContext,
|
||||
workflowId: string,
|
||||
queryParams: WorkflowHistoryRequestParams,
|
||||
): Promise<PublishTimelineEvent[]> => {
|
||||
const { data } = await get(
|
||||
context.baseUrl,
|
||||
`/workflow-history/workflow/${workflowId}/publish-timeline`,
|
||||
queryParams,
|
||||
);
|
||||
return data;
|
||||
};
|
||||
|
||||
+348
-1
@@ -1,7 +1,12 @@
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { createDeferredPromise } from '@n8n/utils/promise/deferred-promise';
|
||||
import { flushPromises } from '@vue/test-utils';
|
||||
import { fireEvent, waitFor } from '@testing-library/vue';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { defineComponent, h } from 'vue';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { mockedStore } from '@/__tests__/utils';
|
||||
import { registerToastNotifier } from '@/app/init/toastNotifier';
|
||||
import WorkflowPublishTimelineContent from './WorkflowPublishTimelineContent.vue';
|
||||
import { useWorkflowHistoryStore } from '../workflowHistory.store';
|
||||
import type { PublishTimelineEvent } from '@n8n/rest-api-client/api/workflowHistory';
|
||||
@@ -57,7 +62,10 @@ describe('WorkflowPublishTimelineContent', () => {
|
||||
const { findByText, workflowHistoryStore } = renderWithEvents([]);
|
||||
await findByText('This workflow has no publish history yet.');
|
||||
|
||||
expect(workflowHistoryStore.getPublishTimeline).toHaveBeenCalledWith(workflowId);
|
||||
expect(workflowHistoryStore.getPublishTimeline).toHaveBeenCalledWith(workflowId, {
|
||||
take: 100,
|
||||
skip: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('should render a published entry with its version name', async () => {
|
||||
@@ -228,4 +236,343 @@ describe('WorkflowPublishTimelineContent', () => {
|
||||
|
||||
expect(await findByText('Published')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('paging', () => {
|
||||
const renderKeyedTimeline = createComponentRenderer(
|
||||
defineComponent({
|
||||
props: { workflowId: { type: String, required: true } },
|
||||
setup(props) {
|
||||
return () =>
|
||||
h(WorkflowPublishTimelineContent, {
|
||||
key: props.workflowId,
|
||||
workflowId: props.workflowId,
|
||||
});
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
let scrollToEnd = () => {};
|
||||
let scrollAwayFromEnd = () => {};
|
||||
|
||||
beforeEach(() => {
|
||||
scrollToEnd = () => {};
|
||||
scrollAwayFromEnd = () => {};
|
||||
vi.stubGlobal(
|
||||
'IntersectionObserver',
|
||||
class {
|
||||
isObserving = false;
|
||||
isIntersecting = false;
|
||||
|
||||
constructor(callback: IntersectionObserverCallback) {
|
||||
const intersect = (isIntersecting: boolean) => {
|
||||
if (!this.isObserving || this.isIntersecting === isIntersecting) return;
|
||||
this.isIntersecting = isIntersecting;
|
||||
callback(
|
||||
[{ isIntersecting } as IntersectionObserverEntry],
|
||||
this as unknown as IntersectionObserver,
|
||||
);
|
||||
};
|
||||
scrollToEnd = () => intersect(true);
|
||||
scrollAwayFromEnd = () => intersect(false);
|
||||
}
|
||||
|
||||
observe = vi.fn(() => {
|
||||
this.isObserving = true;
|
||||
this.isIntersecting = false;
|
||||
});
|
||||
|
||||
disconnect = vi.fn(() => {
|
||||
this.isObserving = false;
|
||||
});
|
||||
|
||||
unobserve = vi.fn();
|
||||
|
||||
takeRecords = vi.fn();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
const buildPage = (count: number, firstId: number, newest: Date) =>
|
||||
Array.from({ length: count }, (_, i) =>
|
||||
buildEvent({
|
||||
id: firstId + i,
|
||||
createdAt: new Date(newest.getTime() - i * 60_000).toISOString(),
|
||||
}),
|
||||
);
|
||||
|
||||
const renderWithPages = (...pages: PublishTimelineEvent[][]) => {
|
||||
const pinia = createTestingPinia({ stubActions: false });
|
||||
const workflowHistoryStore = mockedStore(useWorkflowHistoryStore);
|
||||
for (const page of pages) {
|
||||
workflowHistoryStore.getPublishTimeline.mockResolvedValueOnce(page);
|
||||
}
|
||||
workflowHistoryStore.getVersionFirstAdoptionDate.mockResolvedValue(null);
|
||||
|
||||
return { ...renderKeyedTimeline({ pinia, props: { workflowId } }), workflowHistoryStore };
|
||||
};
|
||||
|
||||
it('should request the next page when the end of the list becomes visible', async () => {
|
||||
const newest = new Date('2026-03-01T10:00:00Z');
|
||||
const { findAllByText, getAllByText, workflowHistoryStore } = renderWithPages(
|
||||
buildPage(100, 1, newest),
|
||||
buildPage(1, 101, new Date(newest.getTime() - 100 * 60_000)),
|
||||
);
|
||||
expect(await findAllByText('Published')).toHaveLength(100);
|
||||
|
||||
scrollToEnd();
|
||||
|
||||
await waitFor(() => expect(workflowHistoryStore.getPublishTimeline).toHaveBeenCalledTimes(2));
|
||||
expect(workflowHistoryStore.getPublishTimeline).toHaveBeenLastCalledWith(workflowId, {
|
||||
take: 100,
|
||||
skip: 100,
|
||||
});
|
||||
await waitFor(() => expect(getAllByText('Published')).toHaveLength(101));
|
||||
});
|
||||
|
||||
it('should remove overlapping events without shifting the offset or timestamp tie order', async () => {
|
||||
const events = Array.from({ length: 205 }, (_, i) =>
|
||||
buildEvent({
|
||||
id: 205 - i,
|
||||
versionName: `Version ${205 - i}`,
|
||||
createdAt: '2026-03-01T10:00:00Z',
|
||||
}),
|
||||
);
|
||||
const { findAllByText, getAllByText, workflowHistoryStore } = renderWithPages(
|
||||
events.slice(0, 100),
|
||||
events.slice(99, 199),
|
||||
events.slice(199),
|
||||
);
|
||||
expect(await findAllByText(/^Published Version /)).toHaveLength(100);
|
||||
|
||||
scrollToEnd();
|
||||
await waitFor(() => expect(getAllByText(/^Published Version /)).toHaveLength(199));
|
||||
expect(getAllByText('Published Version 106')).toHaveLength(1);
|
||||
|
||||
scrollToEnd();
|
||||
await waitFor(() => expect(getAllByText(/^Published Version /)).toHaveLength(205));
|
||||
|
||||
expect(workflowHistoryStore.getPublishTimeline.mock.calls).toEqual([
|
||||
[workflowId, { take: 100, skip: 0 }],
|
||||
[workflowId, { take: 100, skip: 100 }],
|
||||
[workflowId, { take: 100, skip: 200 }],
|
||||
]);
|
||||
expect(getAllByText(/^Published Version /).map((element) => element.textContent)).toEqual(
|
||||
events.map(({ versionName }) => `Published ${versionName}`),
|
||||
);
|
||||
});
|
||||
|
||||
it('should retry a failed page without scrolling or advancing the offset', async () => {
|
||||
registerToastNotifier();
|
||||
const newest = new Date('2026-03-01T10:00:00Z');
|
||||
const {
|
||||
findByRole,
|
||||
findByText,
|
||||
findAllByText,
|
||||
getAllByText,
|
||||
queryByRole,
|
||||
workflowHistoryStore,
|
||||
} = renderWithPages(buildPage(100, 1, newest));
|
||||
const error = new Error('Failed to load page');
|
||||
workflowHistoryStore.getPublishTimeline
|
||||
.mockRejectedValueOnce(error)
|
||||
.mockResolvedValueOnce(buildPage(1, 101, new Date(newest.getTime() - 100 * 60_000)));
|
||||
expect(await findAllByText('Published')).toHaveLength(100);
|
||||
|
||||
scrollToEnd();
|
||||
expect(await findByText(error.message)).toBeInTheDocument();
|
||||
|
||||
expect(workflowHistoryStore.getPublishTimeline).toHaveBeenCalledTimes(2);
|
||||
expect(getAllByText('Published')).toHaveLength(100);
|
||||
|
||||
scrollToEnd();
|
||||
expect(workflowHistoryStore.getPublishTimeline).toHaveBeenCalledTimes(2);
|
||||
await userEvent.click(await findByRole('button', { name: 'Retry' }));
|
||||
await waitFor(() => expect(getAllByText('Published')).toHaveLength(101));
|
||||
|
||||
expect(workflowHistoryStore.getPublishTimeline).toHaveBeenCalledTimes(3);
|
||||
expect(workflowHistoryStore.getPublishTimeline.mock.calls.slice(1)).toEqual([
|
||||
[workflowId, { take: 100, skip: 100 }],
|
||||
[workflowId, { take: 100, skip: 100 }],
|
||||
]);
|
||||
expect(queryByRole('button', { name: 'Retry' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should retain scroll-based retry after leaving and re-entering the sentinel', async () => {
|
||||
registerToastNotifier();
|
||||
const newest = new Date('2026-03-01T10:00:00Z');
|
||||
const { findAllByText, findByText, getAllByText, workflowHistoryStore } = renderWithPages(
|
||||
buildPage(100, 1, newest),
|
||||
);
|
||||
workflowHistoryStore.getPublishTimeline
|
||||
.mockRejectedValueOnce(new Error('Page request failed'))
|
||||
.mockResolvedValueOnce(buildPage(1, 101, new Date(newest.getTime() - 100 * 60_000)));
|
||||
await findAllByText('Published');
|
||||
scrollToEnd();
|
||||
await findByText('Page request failed');
|
||||
scrollAwayFromEnd();
|
||||
scrollToEnd();
|
||||
await waitFor(() => expect(getAllByText('Published')).toHaveLength(101));
|
||||
});
|
||||
|
||||
it('should reload from the first page when the workflow changes', async () => {
|
||||
const { findAllByText, findByText, queryAllByText, rerender, workflowHistoryStore } =
|
||||
renderWithPages(buildPage(100, 1, new Date('2026-03-01T10:00:00Z')), [
|
||||
buildEvent({ id: 1001, workflowId: 'wf-2', versionName: 'Workflow B' }),
|
||||
]);
|
||||
await findAllByText('Published');
|
||||
await rerender({ workflowId: 'wf-2' });
|
||||
expect(await findByText('Published Workflow B')).toBeInTheDocument();
|
||||
expect(queryAllByText('Published')).toHaveLength(0);
|
||||
expect(workflowHistoryStore.getPublishTimeline.mock.calls).toEqual([
|
||||
[workflowId, { take: 100, skip: 0 }],
|
||||
['wf-2', { take: 100, skip: 0 }],
|
||||
]);
|
||||
});
|
||||
|
||||
it('should ignore an old page that resolves after the workflow changes', async () => {
|
||||
const pending = createDeferredPromise<PublishTimelineEvent[]>();
|
||||
const { findAllByText, findByText, queryByText, rerender, workflowHistoryStore } =
|
||||
renderWithPages(buildPage(100, 1, new Date('2026-03-01T10:00:00Z')));
|
||||
workflowHistoryStore.getPublishTimeline
|
||||
.mockReturnValueOnce(pending.promise)
|
||||
.mockResolvedValueOnce([
|
||||
buildEvent({ id: 1001, workflowId: 'wf-2', versionName: 'Workflow B' }),
|
||||
]);
|
||||
await findAllByText('Published');
|
||||
scrollToEnd();
|
||||
await waitFor(() => expect(workflowHistoryStore.getPublishTimeline).toHaveBeenCalledTimes(2));
|
||||
await rerender({ workflowId: 'wf-2' });
|
||||
await findByText('Published Workflow B');
|
||||
pending.resolve([buildEvent({ id: 101, versionName: 'Late workflow A' })]);
|
||||
await flushPromises();
|
||||
expect(queryByText('Published Late workflow A')).not.toBeInTheDocument();
|
||||
expect(queryByText('Published')).not.toBeInTheDocument();
|
||||
expect(workflowHistoryStore.getPublishTimeline).toHaveBeenLastCalledWith('wf-2', {
|
||||
take: 100,
|
||||
skip: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it.each(['workflow change', 'unmount'])(
|
||||
'should ignore a page failure after %s',
|
||||
async (action) => {
|
||||
registerToastNotifier();
|
||||
const pending = createDeferredPromise<PublishTimelineEvent[]>();
|
||||
const { findAllByText, queryByText, rerender, unmount, workflowHistoryStore } =
|
||||
renderWithPages(buildPage(100, 1, new Date('2026-03-01T10:00:00Z')));
|
||||
workflowHistoryStore.getPublishTimeline
|
||||
.mockReturnValueOnce(pending.promise)
|
||||
.mockResolvedValueOnce([buildEvent({ id: 1001, workflowId: 'wf-2' })]);
|
||||
await findAllByText('Published');
|
||||
scrollToEnd();
|
||||
await waitFor(() =>
|
||||
expect(workflowHistoryStore.getPublishTimeline).toHaveBeenCalledTimes(2),
|
||||
);
|
||||
if (action === 'workflow change') await rerender({ workflowId: 'wf-2' });
|
||||
else unmount();
|
||||
pending.reject(new Error('Stale page failure'));
|
||||
await flushPromises();
|
||||
expect(queryByText('Stale page failure')).not.toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
|
||||
it('should retry the first page without starting duplicate requests', async () => {
|
||||
registerToastNotifier();
|
||||
const pinia = createTestingPinia({ stubActions: false });
|
||||
const workflowHistoryStore = mockedStore(useWorkflowHistoryStore);
|
||||
const pending = createDeferredPromise<PublishTimelineEvent[]>();
|
||||
workflowHistoryStore.getPublishTimeline
|
||||
.mockRejectedValueOnce(new Error('Initial page failed'))
|
||||
.mockReturnValueOnce(pending.promise);
|
||||
workflowHistoryStore.getVersionFirstAdoptionDate.mockResolvedValue(null);
|
||||
const { findByRole, findByText, queryByText } = renderComponent({
|
||||
pinia,
|
||||
props: { workflowId },
|
||||
});
|
||||
const retryButton = await findByRole('button', { name: 'Retry' });
|
||||
expect(queryByText('This workflow has no publish history yet.')).not.toBeInTheDocument();
|
||||
fireEvent.click(retryButton);
|
||||
fireEvent.click(retryButton);
|
||||
expect(workflowHistoryStore.getPublishTimeline).toHaveBeenCalledTimes(2);
|
||||
pending.resolve([buildEvent()]);
|
||||
expect(await findByText('Published')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should ignore initial results and adoption data from the previous workflow', async () => {
|
||||
const pinia = createTestingPinia({ stubActions: false });
|
||||
const workflowHistoryStore = mockedStore(useWorkflowHistoryStore);
|
||||
const oldPage = createDeferredPromise<PublishTimelineEvent[]>();
|
||||
const newPage = createDeferredPromise<PublishTimelineEvent[]>();
|
||||
const oldAdoption = createDeferredPromise<string | null>();
|
||||
workflowHistoryStore.getPublishTimeline
|
||||
.mockReturnValueOnce(oldPage.promise)
|
||||
.mockReturnValueOnce(newPage.promise);
|
||||
workflowHistoryStore.getVersionFirstAdoptionDate
|
||||
.mockReturnValueOnce(oldAdoption.promise)
|
||||
.mockResolvedValueOnce('2024-01-01T00:00:00Z');
|
||||
const { findByText, queryByText, rerender } = renderKeyedTimeline({
|
||||
pinia,
|
||||
props: { workflowId },
|
||||
});
|
||||
await rerender({ workflowId: 'wf-2' });
|
||||
oldPage.resolve([buildEvent({ versionName: 'Old workflow A' })]);
|
||||
oldAdoption.resolve('2026-01-01T00:00:00Z');
|
||||
await flushPromises();
|
||||
expect(queryByText('Published Old workflow A')).not.toBeInTheDocument();
|
||||
expect(queryByText('This workflow has no publish history yet.')).not.toBeInTheDocument();
|
||||
newPage.resolve([
|
||||
buildEvent({
|
||||
id: 1001,
|
||||
workflowId: 'wf-2',
|
||||
versionName: 'Workflow B',
|
||||
createdAt: '2025-01-01T00:00:00Z',
|
||||
}),
|
||||
]);
|
||||
expect(await findByText('Published Workflow B')).toBeInTheDocument();
|
||||
expect(queryByText(/History before .* may be incomplete/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not let an old completion clear the new workflow loading guard', async () => {
|
||||
const oldPage = createDeferredPromise<PublishTimelineEvent[]>();
|
||||
const newPage = createDeferredPromise<PublishTimelineEvent[]>();
|
||||
const newest = new Date('2026-03-01T10:00:00Z');
|
||||
const { findAllByText, getAllByText, rerender, workflowHistoryStore } = renderWithPages(
|
||||
buildPage(100, 1, newest),
|
||||
);
|
||||
workflowHistoryStore.getPublishTimeline
|
||||
.mockReturnValueOnce(oldPage.promise)
|
||||
.mockResolvedValueOnce(
|
||||
buildPage(100, 1001, newest).map((event) => ({ ...event, workflowId: 'wf-2' })),
|
||||
)
|
||||
.mockReturnValueOnce(newPage.promise);
|
||||
await findAllByText('Published');
|
||||
scrollToEnd();
|
||||
await rerender({ workflowId: 'wf-2' });
|
||||
await waitFor(() => expect(getAllByText('Published')).toHaveLength(100));
|
||||
scrollToEnd();
|
||||
expect(workflowHistoryStore.getPublishTimeline).toHaveBeenCalledTimes(4);
|
||||
oldPage.resolve([buildEvent({ id: 101 })]);
|
||||
await flushPromises();
|
||||
scrollAwayFromEnd();
|
||||
scrollToEnd();
|
||||
expect(workflowHistoryStore.getPublishTimeline).toHaveBeenCalledTimes(4);
|
||||
newPage.resolve([buildEvent({ id: 1101, workflowId: 'wf-2' })]);
|
||||
await waitFor(() => expect(getAllByText('Published')).toHaveLength(101));
|
||||
});
|
||||
|
||||
it('should not request another page after a page that is not full', async () => {
|
||||
const { findAllByText, workflowHistoryStore } = renderWithPages(
|
||||
buildPage(3, 1, new Date('2026-03-01T10:00:00Z')),
|
||||
);
|
||||
expect(await findAllByText('Published')).toHaveLength(3);
|
||||
|
||||
scrollToEnd();
|
||||
|
||||
expect(workflowHistoryStore.getPublishTimeline).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+99
-10
@@ -1,9 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue';
|
||||
import dateformat from 'dateformat';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { N8nText, N8nLoading, N8nIcon, N8nTooltip } from '@n8n/design-system';
|
||||
import { useToast } from '@n8n/composables/useToast';
|
||||
import { N8nText, N8nLoading, N8nIcon, N8nTooltip, N8nButton } from '@n8n/design-system';
|
||||
import type { PublishTimelineEvent } from '@n8n/rest-api-client/api/workflowHistory';
|
||||
import { useIntersectionObserver } from '@/app/composables/useIntersectionObserver';
|
||||
import { useWorkflowHistoryStore } from '../workflowHistory.store';
|
||||
import { formatTimestamp, generateVersionLabelFromId } from '../utils';
|
||||
import WorkflowHistoryPublishedTooltip from './WorkflowHistoryPublishedTooltip.vue';
|
||||
@@ -13,6 +15,7 @@ import type { WorkflowHistoryVersionStatus } from '../types';
|
||||
const TRANSIENT_DEACTIVATION_MS = 2000;
|
||||
/** Adoption point for publish-timeline tracking — older events may be incomplete */
|
||||
const ADOPTION_VERSION = { major: 2, minor: 17, patch: 0 } as const;
|
||||
const PAGE_SIZE = 100;
|
||||
|
||||
const props = defineProps<{
|
||||
workflowId: string;
|
||||
@@ -24,11 +27,19 @@ const emit = defineEmits<{
|
||||
}>();
|
||||
|
||||
const i18n = useI18n();
|
||||
const toast = useToast();
|
||||
const workflowHistoryStore = useWorkflowHistoryStore();
|
||||
|
||||
const isLoading = ref(true);
|
||||
const isLoadingMore = ref(false);
|
||||
const hasLoadError = ref(false);
|
||||
const hasMore = ref(false);
|
||||
const events = ref<PublishTimelineEvent[]>([]);
|
||||
const fetchedCount = ref(0);
|
||||
const adoptionDate = ref<Date | null>(null);
|
||||
const contentElement = ref<HTMLElement | null>(null);
|
||||
const loadMoreSentinel = ref<HTMLElement | null>(null);
|
||||
let disposed = false;
|
||||
|
||||
type EntryStatus = 'published' | 'unpublished';
|
||||
|
||||
@@ -181,7 +192,7 @@ const entries = computed<TimelineEntry[]>(() =>
|
||||
);
|
||||
|
||||
const showDeletedVersionsDisclaimer = computed(() => {
|
||||
if (adoptionDate.value === null) return false;
|
||||
if (adoptionDate.value === null || hasMore.value) return false;
|
||||
const adoptionTime = adoptionDate.value.getTime();
|
||||
return events.value.some((e) => new Date(e.createdAt).getTime() < adoptionTime);
|
||||
});
|
||||
@@ -196,27 +207,90 @@ const handleSelect = (entry: TimelineEntry) => {
|
||||
if (entry.versionId) emit('selectVersion', entry.versionId);
|
||||
};
|
||||
|
||||
const oldestFirst = (a: PublishTimelineEvent, b: PublishTimelineEvent) =>
|
||||
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() || a.id - b.id;
|
||||
|
||||
const fetchNextPage = async () => {
|
||||
const page = await workflowHistoryStore.getPublishTimeline(props.workflowId, {
|
||||
take: PAGE_SIZE,
|
||||
skip: fetchedCount.value,
|
||||
});
|
||||
if (disposed) return;
|
||||
fetchedCount.value += page.length;
|
||||
hasMore.value = page.length === PAGE_SIZE;
|
||||
// A publish while scrolling shifts the pages, so they can overlap.
|
||||
const loadedIds = new Set(events.value.map(({ id }) => id));
|
||||
events.value = [...events.value, ...page.filter(({ id }) => !loadedIds.has(id))].sort(
|
||||
oldestFirst,
|
||||
);
|
||||
};
|
||||
|
||||
const loadTimeline = async () => {
|
||||
if (disposed) return;
|
||||
isLoading.value = true;
|
||||
hasLoadError.value = false;
|
||||
try {
|
||||
const [timelineEvents, firstAdoptionDate] = await Promise.all([
|
||||
workflowHistoryStore.getPublishTimeline(props.workflowId),
|
||||
const [, firstAdoptionDate] = await Promise.all([
|
||||
fetchNextPage(),
|
||||
workflowHistoryStore.getVersionFirstAdoptionDate(ADOPTION_VERSION).catch(() => null),
|
||||
]);
|
||||
adoptionDate.value = firstAdoptionDate ? new Date(firstAdoptionDate) : null;
|
||||
events.value = timelineEvents;
|
||||
if (!disposed) {
|
||||
adoptionDate.value = firstAdoptionDate ? new Date(firstAdoptionDate) : null;
|
||||
}
|
||||
} catch (error) {
|
||||
if (!disposed) {
|
||||
hasLoadError.value = true;
|
||||
toast.showError(error, i18n.baseText('workflowHistory.title'));
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
if (!disposed) isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadMore = async () => {
|
||||
if (disposed || isLoadingMore.value || !hasMore.value) return;
|
||||
isLoadingMore.value = true;
|
||||
hasLoadError.value = false;
|
||||
try {
|
||||
await fetchNextPage();
|
||||
} catch (error) {
|
||||
if (!disposed) {
|
||||
hasLoadError.value = true;
|
||||
toast.showError(error, i18n.baseText('workflowHistory.title'));
|
||||
}
|
||||
} finally {
|
||||
if (!disposed) isLoadingMore.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const retry = async () => {
|
||||
if (disposed || isLoading.value || isLoadingMore.value) return;
|
||||
if (fetchedCount.value === 0) await loadTimeline();
|
||||
else await loadMore();
|
||||
};
|
||||
|
||||
const { observe: observeForLoadMore } = useIntersectionObserver({
|
||||
root: contentElement,
|
||||
onIntersect: async () => await loadMore(),
|
||||
// Keep observing so a later scroll can retry a failed page.
|
||||
once: false,
|
||||
});
|
||||
|
||||
watch([loadMoreSentinel, hasMore, fetchedCount], ([sentinel, canLoadMore]) => {
|
||||
if (sentinel && canLoadMore) observeForLoadMore(sentinel);
|
||||
});
|
||||
|
||||
onMounted(loadTimeline);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
disposed = true;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.content">
|
||||
<div ref="contentElement" :class="$style.content">
|
||||
<N8nLoading v-if="isLoading" :rows="4" />
|
||||
<div v-else-if="entries.length === 0" :class="$style.empty">
|
||||
<div v-else-if="entries.length === 0 && !hasMore && !hasLoadError" :class="$style.empty">
|
||||
<N8nText size="small" color="text-light">
|
||||
{{ i18n.baseText('workflowHistory.publishTimeline.empty') }}
|
||||
</N8nText>
|
||||
@@ -297,6 +371,17 @@ onMounted(loadTimeline);
|
||||
</div>
|
||||
</WorkflowHistoryPublishedTooltip>
|
||||
</div>
|
||||
<div v-if="hasMore" ref="loadMoreSentinel" :class="$style.sentinel" aria-hidden="true" />
|
||||
<N8nLoading v-if="isLoadingMore" :rows="1" />
|
||||
<N8nButton
|
||||
v-if="hasLoadError"
|
||||
variant="subtle"
|
||||
size="small"
|
||||
:disabled="isLoading || isLoadingMore"
|
||||
@click="retry"
|
||||
>
|
||||
{{ i18n.baseText('generic.retry') }}
|
||||
</N8nButton>
|
||||
<N8nTooltip
|
||||
v-if="showDeletedVersionsDisclaimer"
|
||||
placement="top"
|
||||
@@ -328,6 +413,10 @@ onMounted(loadTimeline);
|
||||
padding: var(--spacing--2xs);
|
||||
}
|
||||
|
||||
.sentinel {
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
+17
-2
@@ -20,8 +20,9 @@ import type { IWorkflowDb } from '@/Interface';
|
||||
import { telemetry } from '@/app/plugins/telemetry';
|
||||
import { registerToastNotifier } from '@/app/init/toastNotifier';
|
||||
|
||||
vi.mock('vue-router', () => {
|
||||
const params = {};
|
||||
vi.mock('vue-router', async () => {
|
||||
const { reactive } = await import('vue');
|
||||
const params = reactive({});
|
||||
const query = {};
|
||||
const push = vi.fn();
|
||||
const replace = vi.fn();
|
||||
@@ -279,6 +280,20 @@ describe('WorkflowHistory', () => {
|
||||
beforeEach(cleanRouteState);
|
||||
afterEach(cleanRouteState);
|
||||
|
||||
it('should remount the timeline only when the workflow route changes', async () => {
|
||||
route.params.workflowId = workflowId;
|
||||
route.query.tab = 'publishTimeline';
|
||||
const { findByTestId, getByTestId } = renderComponent({ pinia });
|
||||
const oldTimeline = await findByTestId('stub-publish-timeline-select');
|
||||
route.params.versionId = 'another-version';
|
||||
await flushPromises();
|
||||
expect(getByTestId('stub-publish-timeline-select')).toBe(oldTimeline);
|
||||
route.params.workflowId = 'another-workflow';
|
||||
await flushPromises();
|
||||
expect(oldTimeline.isConnected).toBe(false);
|
||||
expect(getByTestId('stub-publish-timeline-select')).not.toBe(oldTimeline);
|
||||
});
|
||||
|
||||
it('should track telemetry when landing on the publish timeline tab via query param', async () => {
|
||||
route.params.workflowId = workflowId;
|
||||
route.query.tab = 'publishTimeline';
|
||||
|
||||
+2
@@ -664,7 +664,9 @@ watchEffect(async () => {
|
||||
@load-more="loadMore"
|
||||
@upgrade="onUpgrade"
|
||||
/>
|
||||
<!-- Keep pagination state scoped to one workflow. -->
|
||||
<WorkflowPublishTimelineContent
|
||||
:key="workflowId"
|
||||
v-if="canRender && activeTab === 'publishTimeline'"
|
||||
:workflow-id="workflowId"
|
||||
:selected-version-id="versionId"
|
||||
|
||||
+12
-6
@@ -144,7 +144,7 @@ describe('Workflow history store', () => {
|
||||
});
|
||||
|
||||
describe('getPublishTimeline', () => {
|
||||
it('should call the API with the rest context and workflow id and return its result', async () => {
|
||||
it('should call the API with the rest context, workflow id and page, and return its result', async () => {
|
||||
const workflowHistoryStore = useWorkflowHistoryStore();
|
||||
const rootStore = useRootStore();
|
||||
const workflowId = 'workflow-123';
|
||||
@@ -163,9 +163,15 @@ describe('Workflow history store', () => {
|
||||
|
||||
vi.mocked(whApi.getPublishTimeline).mockResolvedValue(events);
|
||||
|
||||
const result = await workflowHistoryStore.getPublishTimeline(workflowId);
|
||||
const result = await workflowHistoryStore.getPublishTimeline(workflowId, {
|
||||
take: 100,
|
||||
skip: 0,
|
||||
});
|
||||
|
||||
expect(whApi.getPublishTimeline).toHaveBeenCalledWith(rootStore.restApiContext, workflowId);
|
||||
expect(whApi.getPublishTimeline).toHaveBeenCalledWith(rootStore.restApiContext, workflowId, {
|
||||
take: 100,
|
||||
skip: 0,
|
||||
});
|
||||
expect(result).toBe(events);
|
||||
});
|
||||
|
||||
@@ -173,9 +179,9 @@ describe('Workflow history store', () => {
|
||||
const workflowHistoryStore = useWorkflowHistoryStore();
|
||||
vi.mocked(whApi.getPublishTimeline).mockRejectedValue(new Error('API Error'));
|
||||
|
||||
await expect(workflowHistoryStore.getPublishTimeline('workflow-123')).rejects.toThrow(
|
||||
'API Error',
|
||||
);
|
||||
await expect(
|
||||
workflowHistoryStore.getPublishTimeline('workflow-123', { take: 100, skip: 0 }),
|
||||
).rejects.toThrow('API Error');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+5
-2
@@ -136,8 +136,11 @@ export const useWorkflowHistoryStore = defineStore('workflowHistory', () => {
|
||||
await whApi.updateWorkflowHistoryVersion(rootStore.restApiContext, workflowId, versionId, data);
|
||||
};
|
||||
|
||||
const getPublishTimeline = async (workflowId: string): Promise<PublishTimelineEvent[]> =>
|
||||
await whApi.getPublishTimeline(rootStore.restApiContext, workflowId);
|
||||
const getPublishTimeline = async (
|
||||
workflowId: string,
|
||||
queryParams: WorkflowHistoryRequestParams,
|
||||
): Promise<PublishTimelineEvent[]> =>
|
||||
await whApi.getPublishTimeline(rootStore.restApiContext, workflowId, queryParams);
|
||||
|
||||
const getVersionFirstAdoptionDate = async (version: {
|
||||
major: number;
|
||||
|
||||
Reference in New Issue
Block a user