chore: Initial V2 changes (#22553)

Co-authored-by: Iván Ovejero <ivov.src@gmail.com>
Co-authored-by: yehorkardash <yehor.kardash@n8n.io>
Co-authored-by: Daria <daria.staferova@n8n.io>
Co-authored-by: Svetoslav Dekov <svetoslav.dekov@n8n.io>
Co-authored-by: Nikhil Kuriakose <nikhilkuria@gmail.com>
Co-authored-by: Charlie Kolb <charlie@n8n.io>
This commit is contained in:
Tomi Turtiainen
2025-12-01 20:44:59 +02:00
committed by GitHub
parent c82d95aecb
commit a4757cf009
162 changed files with 4078 additions and 7156 deletions

View File

@@ -60,6 +60,7 @@ jobs:
needs: build
runs-on: blacksmith-4vcpu-ubuntu-2204
timeout-minutes: 30
if: false
env:
DB_MYSQLDB_PASSWORD: password
DB_MYSQLDB_POOL_SIZE: 1
@@ -89,6 +90,7 @@ jobs:
needs: build
runs-on: blacksmith-2vcpu-ubuntu-2204
timeout-minutes: 20
if: false
env:
DB_MYSQLDB_PASSWORD: password
DB_MYSQLDB_POOL_SIZE: 1
@@ -140,7 +142,7 @@ jobs:
notify-on-failure:
name: Notify Slack on failure
runs-on: ubuntu-latest
needs: [sqlite-pooled, mariadb, postgres, mysql]
needs: [sqlite-pooled, postgres]
steps:
- name: Notify Slack on failure
uses: act10ns/slack@44541246747a30eb3102d87f7a4cc5471b0ffb7d # v2.1.0

View File

@@ -178,12 +178,6 @@ To start n8n execute:
pnpm start
```
To start n8n with tunnel:
```
./packages/cli/bin/n8n start --tunnel
```
## Development cycle
While iterating on n8n modules code, you can run `pnpm dev`. It will then

View File

@@ -1,7 +1,5 @@
ARG NODE_VERSION=22.21.0
ARG N8N_VERSION=snapshot
ARG LAUNCHER_VERSION=1.4.1
ARG TARGETPLATFORM
# ==============================================================================
# STAGE 1: System Dependencies & Base Setup
@@ -16,29 +14,7 @@ FROM alpine:3.22.2 AS app-artifact-processor
COPY ./compiled /app/
# ==============================================================================
# STAGE 3: Task Runner Launcher
# ==============================================================================
FROM alpine:3.22.2 AS launcher-downloader
ARG TARGETPLATFORM
ARG LAUNCHER_VERSION
RUN set -e; \
case "$TARGETPLATFORM" in \
"linux/amd64") ARCH_NAME="amd64" ;; \
"linux/arm64") ARCH_NAME="arm64" ;; \
*) echo "Unsupported platform: $TARGETPLATFORM" && exit 1 ;; \
esac; \
mkdir /launcher-temp && cd /launcher-temp; \
wget -q "https://github.com/n8n-io/task-runner-launcher/releases/download/${LAUNCHER_VERSION}/task-runner-launcher-${LAUNCHER_VERSION}-linux-${ARCH_NAME}.tar.gz"; \
wget -q "https://github.com/n8n-io/task-runner-launcher/releases/download/${LAUNCHER_VERSION}/task-runner-launcher-${LAUNCHER_VERSION}-linux-${ARCH_NAME}.tar.gz.sha256"; \
echo "$(cat task-runner-launcher-${LAUNCHER_VERSION}-linux-${ARCH_NAME}.tar.gz.sha256) task-runner-launcher-${LAUNCHER_VERSION}-linux-${ARCH_NAME}.tar.gz" > checksum.sha256; \
sha256sum -c checksum.sha256; \
mkdir -p /launcher-bin; \
tar xzf task-runner-launcher-${LAUNCHER_VERSION}-linux-${ARCH_NAME}.tar.gz -C /launcher-bin; \
cd / && rm -rf /launcher-temp
# ==============================================================================
# STAGE 4: Final Runtime Image
# STAGE 3: Final Runtime Image
# ==============================================================================
FROM system-deps AS runtime
@@ -52,9 +28,7 @@ ENV SHELL=/bin/sh
WORKDIR /home/node
COPY --from=app-artifact-processor /app /usr/local/lib/node_modules/n8n
COPY --from=launcher-downloader /launcher-bin/* /usr/local/bin/
COPY docker/images/n8n/docker-entrypoint.sh /
COPY docker/images/n8n/n8n-task-runners.json /etc/n8n-task-runners.json
RUN cd /usr/local/lib/node_modules/n8n && \
npm rebuild sqlite3 && \

View File

@@ -23,7 +23,6 @@ n8n is a workflow automation platform that gives technical teams the flexibility
- [Available integrations](#available-integrations)
- [Documentation](#documentation)
- [Start n8n in Docker](#start-n8n-in-docker)
- [Start n8n with tunnel](#start-n8n-with-tunnel)
- [Use with PostgreSQL](#use-with-postgresql)
- [Passing sensitive data using files](#passing-sensitive-data-using-files)
- [Example server setups](#example-server-setups)
@@ -75,25 +74,6 @@ To save your work between container restarts, it also mounts a docker volume, `n
If this data can't be found at startup n8n automatically creates a new key and any existing credentials can no longer be decrypted.
## Start n8n with tunnel
> **WARNING**: This is only meant for local development and testing and should **NOT** be used in production!
n8n must be reachable from the internet to make use of webhooks - essential for triggering workflows from external web-based services such as GitHub. To make this easier, n8n has a special tunnel service which redirects requests from our servers to your local n8n instance. You can inspect the code running this service here: [https://github.com/n8n-io/localtunnel](https://github.com/n8n-io/localtunnel)
To use it simply start n8n with `--tunnel`
```bash
docker volume create n8n_data
docker run -it --rm \
--name n8n \
-p 5678:5678 \
-v n8n_data:/home/node/.n8n \
docker.n8n.io/n8nio/n8n \
start --tunnel
```
## Use with PostgreSQL
By default, n8n uses SQLite to save credentials, past executions and workflows. However, n8n also supports using PostgreSQL.

View File

@@ -1,35 +0,0 @@
{
"task-runners": [
{
"runner-type": "javascript",
"workdir": "/home/node",
"command": "/usr/local/bin/node",
"args": [
"--disallow-code-generation-from-strings",
"--disable-proto=delete",
"/usr/local/lib/node_modules/n8n/node_modules/@n8n/task-runner/dist/start.js"
],
"allowed-env": [
"PATH",
"GENERIC_TIMEZONE",
"N8N_RUNNERS_GRANT_TOKEN",
"N8N_RUNNERS_TASK_BROKER_URI",
"N8N_RUNNERS_MAX_PAYLOAD",
"N8N_RUNNERS_MAX_CONCURRENCY",
"N8N_RUNNERS_TASK_TIMEOUT",
"N8N_RUNNERS_HEARTBEAT_INTERVAL",
"N8N_RUNNERS_HEALTH_CHECK_SERVER_ENABLED",
"N8N_RUNNERS_HEALTH_CHECK_SERVER_HOST",
"N8N_RUNNERS_HEALTH_CHECK_SERVER_PORT",
"NODE_FUNCTION_ALLOW_BUILTIN",
"NODE_FUNCTION_ALLOW_EXTERNAL",
"NODE_OPTIONS",
"NODE_PATH",
"N8N_SENTRY_DSN",
"N8N_VERSION",
"ENVIRONMENT",
"DEPLOYMENT_NAME"
]
}
]
}

View File

@@ -38,7 +38,6 @@ docker buildx build \
N8N_RUNNERS_ENABLED=true \
N8N_RUNNERS_MODE=external \
N8N_RUNNERS_AUTH_TOKEN=test \
N8N_NATIVE_PYTHON_RUNNER=true \
N8N_LOG_LEVEL=debug \
pnpm start
```

View File

@@ -286,31 +286,3 @@ export async function createActiveWorkflow(
workflow.activeVersionId = workflow.versionId;
return workflow;
}
/**
* Create a workflow with a specific active version.
* This simulates a workflow where the active version differs from the current version.
* @param activeVersionId the version ID to set as active
* @param attributes workflow attributes
* @param user user to assign the workflow to
*/
export async function createWorkflowWithActiveVersion(
activeVersionId: string,
attributes: Partial<IWorkflowDb> = {},
user?: User,
) {
const workflow = await createWorkflowWithTriggerAndHistory({ active: true, ...attributes }, user);
await Container.get(WorkflowHistoryRepository).insert({
workflowId: workflow.id,
versionId: activeVersionId,
nodes: workflow.nodes,
connections: workflow.connections,
authors: user?.email ?? 'test@example.com',
});
await setActiveVersion(workflow.id, activeVersionId);
workflow.activeVersionId = activeVersionId;
return workflow;
}

View File

@@ -35,7 +35,7 @@
"dependencies": {
"@oclif/core": "4.0.7",
"axios": "catalog:",
"dotenv": "8.6.0",
"dotenv": "17.2.3",
"nanoid": "catalog:",
"zx": "^8.1.4"
},

View File

@@ -37,7 +37,6 @@ services:
- N8N_RUNNERS_MODE=external
- N8N_RUNNERS_BROKER_LISTEN_ADDRESS=0.0.0.0
- N8N_RUNNERS_AUTH_TOKEN=test
- N8N_NATIVE_PYTHON_RUNNER=true
- N8N_METRICS=true
ports:
- 5678:5678

View File

@@ -55,7 +55,6 @@ services:
- N8N_RUNNERS_MODE=external
- N8N_RUNNERS_BROKER_LISTEN_ADDRESS=0.0.0.0
- N8N_RUNNERS_AUTH_TOKEN=test
- N8N_NATIVE_PYTHON_RUNNER=true
command: worker
volumes:
- ${RUN_DIR}/n8n-worker1:/n8n
@@ -103,7 +102,6 @@ services:
- N8N_RUNNERS_MODE=external
- N8N_RUNNERS_BROKER_LISTEN_ADDRESS=0.0.0.0
- N8N_RUNNERS_AUTH_TOKEN=test
- N8N_NATIVE_PYTHON_RUNNER=true
command: worker
volumes:
- ${RUN_DIR}/n8n-worker2:/n8n

View File

@@ -53,7 +53,6 @@ services:
- N8N_RUNNERS_MODE=external
- N8N_RUNNERS_BROKER_LISTEN_ADDRESS=0.0.0.0
- N8N_RUNNERS_AUTH_TOKEN=test
- N8N_NATIVE_PYTHON_RUNNER=true
command: worker
volumes:
- ${RUN_DIR}/n8n-worker1:/n8n
@@ -99,7 +98,6 @@ services:
- N8N_RUNNERS_MODE=external
- N8N_RUNNERS_BROKER_LISTEN_ADDRESS=0.0.0.0
- N8N_RUNNERS_AUTH_TOKEN=test
- N8N_NATIVE_PYTHON_RUNNER=true
command: worker
volumes:
- ${RUN_DIR}/n8n-worker2:/n8n

View File

@@ -19,7 +19,6 @@ services:
- N8N_RUNNERS_MODE=external
- N8N_RUNNERS_BROKER_LISTEN_ADDRESS=0.0.0.0
- N8N_RUNNERS_AUTH_TOKEN=test
- N8N_NATIVE_PYTHON_RUNNER=true
- N8N_METRICS=true
ports:
- 5678:5678

View File

@@ -8,32 +8,12 @@ describe('DatabaseConfig', () => {
jest.clearAllMocks();
});
test('`isLegacySqlite` defaults to true', () => {
const databaseConfig = Container.get(DatabaseConfig);
expect(databaseConfig.isLegacySqlite).toBe(true);
});
test.each(['mariadb', 'mysqldb', 'postgresdb'] satisfies Array<DatabaseConfig['type']>)(
test.each(['sqlite', 'mariadb', 'mysqldb', 'postgresdb'] satisfies Array<DatabaseConfig['type']>)(
'`isLegacySqlite` returns false if dbType is `%s`',
(dbType) => {
const databaseConfig = Container.get(DatabaseConfig);
databaseConfig.sqlite.poolSize = 0;
databaseConfig.type = dbType;
expect(databaseConfig.isLegacySqlite).toBe(false);
},
);
test('`isLegacySqlite` returns false if dbType is `sqlite` and `poolSize` > 0', () => {
const databaseConfig = Container.get(DatabaseConfig);
databaseConfig.sqlite.poolSize = 1;
databaseConfig.type = 'sqlite';
expect(databaseConfig.isLegacySqlite).toBe(false);
});
test('`isLegacySqlite` returns true if dbType is `sqlite` and `poolSize` is 0', () => {
const databaseConfig = Container.get(DatabaseConfig);
databaseConfig.sqlite.poolSize = 0;
databaseConfig.type = 'sqlite';
expect(databaseConfig.isLegacySqlite).toBe(true);
});
});

View File

@@ -5,6 +5,17 @@ import { Config, Env, Nested } from '../decorators';
const dbLoggingOptionsSchema = z.enum(['query', 'error', 'schema', 'warn', 'info', 'log', 'all']);
type DbLoggingOptions = z.infer<typeof dbLoggingOptionsSchema>;
class MySqlMariaDbNotSupportedError extends Error {
// Workaround to not get this reported to Sentry
readonly cause: { level: 'warning' } = {
level: 'warning',
};
constructor() {
super('MySQL and MariaDB have been removed. Please migrate to PostgreSQL.');
}
}
@Config
class LoggingConfig {
/** Whether database logging is enabled. */
@@ -119,15 +130,17 @@ class MysqlConfig {
poolSize: number = 10;
}
const sqlitePoolSizeSchema = z.coerce.number().int().gte(1);
@Config
export class SqliteConfig {
/** SQLite database file name */
@Env('DB_SQLITE_DATABASE')
database: string = 'database.sqlite';
/** SQLite database pool size. Set to `0` to disable pooling. */
@Env('DB_SQLITE_POOL_SIZE')
poolSize: number = 0;
/** SQLite database pool size. Must be equal to or higher than `1`. */
@Env('DB_SQLITE_POOL_SIZE', sqlitePoolSizeSchema)
poolSize: number = 3;
/**
* Enable SQLite WAL mode.
@@ -154,12 +167,10 @@ export class DatabaseConfig {
type: DbType = 'sqlite';
/**
* Is true if the default sqlite data source of TypeORM is used, as opposed
* to any other (e.g. postgres)
* This also returns false if n8n's new pooled sqlite data source is used.
* Legacy sqlite is no longer supported. Setting kept until we clean up all uses.
*/
get isLegacySqlite() {
return this.type === 'sqlite' && this.sqlite.poolSize === 0;
return false;
}
/** Prefix for table names */
@@ -183,4 +194,10 @@ export class DatabaseConfig {
@Nested
sqlite: SqliteConfig;
sanitize() {
if (this.type === 'mariadb' || this.type === 'mysqldb') {
throw new MySqlMariaDbNotSupportedError();
}
}
}

View File

@@ -11,7 +11,7 @@ export class InstanceSettingsConfig {
* attempt change them to 0600 (only owner has rw access) if they are too wide.
*/
@Env('N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS')
enforceSettingsFilePermissions: boolean = false;
enforceSettingsFilePermissions: boolean = true;
/**
* Encryption key to use for encrypting and decrypting credentials.

View File

@@ -26,9 +26,14 @@ export class NodesConfig {
@Env('NODES_INCLUDE')
include: JsonStringArray = [];
/** Node types not to load. Excludes none if unspecified. @example '["n8n-nodes-base.hackerNews"]' */
/**
* Node types not to load. Defaults to excluding `ExecuteCommand` and `LocalFileTrigger` for security.
* Set to an empty array to enable all node types.
*
* @example '["n8n-nodes-base.hackerNews"]'
*/
@Env('NODES_EXCLUDE')
exclude: JsonStringArray = [];
exclude: JsonStringArray = ['n8n-nodes-base.executeCommand', 'n8n-nodes-base.localFileTrigger'];
/** Node type to use as error trigger */
@Env('NODES_ERROR_TRIGGER_TYPE')

View File

@@ -90,6 +90,5 @@ export class TaskRunnersConfig {
* adjusted to account for breaking changes:
* https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.code/#python-native-beta
*/
@Env('N8N_NATIVE_PYTHON_RUNNER')
isNativePythonRunnerEnabled: boolean = false;
isNativePythonRunnerEnabled: boolean = true;
}

View File

@@ -71,10 +71,6 @@ class SettingsConfig {
/** How often (in milliseconds) Bull must check for stalled jobs. `0` to disable. */
@Env('QUEUE_WORKER_STALLED_INTERVAL')
stalledInterval: number = 30_000;
/** Max number of times a stalled job will be re-processed. See Bull's [documentation](https://docs.bullmq.io/guide/workers/stalled-jobs). */
@Env('QUEUE_WORKER_MAX_STALLED_COUNT')
maxStalledCount: number = 1;
}
@Config

View File

@@ -3,18 +3,19 @@ import { Config, Env } from '../decorators';
@Config
export class SecurityConfig {
/**
* Which directories to limit n8n's access to. Separate multiple dirs with semicolon `;`.
* Dirs that the `ReadWriteFile` and `ReadBinaryFiles` nodes are allowed to access. Separate multiple dirs with semicolon `;`.
* Set to an empty string to disable restrictions (insecure, not recommended for production).
*
* @example N8N_RESTRICT_FILE_ACCESS_TO=/home/user/.n8n;/home/user/n8n-data
* @example N8N_RESTRICT_FILE_ACCESS_TO=/home/john/my-n8n-files
*/
@Env('N8N_RESTRICT_FILE_ACCESS_TO')
restrictFileAccessTo: string = '';
restrictFileAccessTo: string = '~/.n8n-files';
/**
* Whether to block access to all files at:
* - the ".n8n" directory,
* - the static cache dir at ~/.cache/n8n/public, and
* - user-defined config files.
* Whether to block nodes from accessing files at dirs internally used by n8n:
* - `~/.n8n`
* - `~/.cache/n8n/public`
* - any dirs specified by `N8N_CONFIG_FILES`, `N8N_CUSTOM_EXTENSIONS`, `N8N_BINARY_DATA_STORAGE_PATH`, `N8N_UM_EMAIL_TEMPLATES_INVITE`, and `UM_EMAIL_TEMPLATES_PWRESET`.
*/
@Env('N8N_BLOCK_FILE_ACCESS_TO_N8N_FILES')
blockFileAccessToN8nFiles: boolean = true;
@@ -50,7 +51,7 @@ export class SecurityConfig {
* Whether to disable bare repositories support in the Git node.
*/
@Env('N8N_GIT_NODE_DISABLE_BARE_REPOS')
disableBareRepos: boolean = false;
disableBareRepos: boolean = true;
/** Whether to allow access to AWS system credentials, e.g. in awsAssumeRole credentials */
@Env('N8N_AWS_SYSTEM_CREDENTIALS_ACCESS_ENABLED')

View File

@@ -22,8 +22,4 @@ export class WorkflowsConfig {
/** Whether to enable workflow dependency indexing */
@Env('N8N_WORKFLOWS_INDEXING_ENABLED')
indexingEnabled: boolean = false;
/** DO NOT USE - Enable draft/publish workflow feature */
@Env('N8N_ENV_FEAT_WORKFLOWS_DRAFT_PUBLISH_ENABLED')
draftPublishEnabled: boolean = false;
}

View File

@@ -5,6 +5,7 @@ import { tmpdir } from 'node:os';
import path from 'node:path';
import type { UserManagementConfig } from '../src/configs/user-management.config';
import type { DatabaseConfig } from '../src/index';
import { GlobalConfig } from '../src/index';
jest.mock('fs');
@@ -95,15 +96,15 @@ describe('GlobalConfig', () => {
},
sqlite: {
database: 'database.sqlite',
enableWAL: false,
enableWAL: true,
executeVacuumOnStartup: false,
poolSize: 0,
poolSize: 3,
},
tablePrefix: '',
type: 'sqlite',
isLegacySqlite: true,
isLegacySqlite: false,
pingIntervalSeconds: 2,
},
} as DatabaseConfig,
credentials: {
defaultName: 'My credentials',
overwrite: {
@@ -158,7 +159,7 @@ describe('GlobalConfig', () => {
nodes: {
errorTriggerType: 'n8n-nodes-base.errorTrigger',
include: [],
exclude: [],
exclude: ['n8n-nodes-base.executeCommand', 'n8n-nodes-base.localFileTrigger'],
pythonEnabled: true,
},
publicApi: {
@@ -186,7 +187,6 @@ describe('GlobalConfig', () => {
callerPolicyDefaultOption: 'workflowsFromSameOwner',
activationBatchSize: 1,
indexingEnabled: false,
draftPublishEnabled: false,
},
endpoints: {
metrics: {
@@ -259,7 +259,6 @@ describe('GlobalConfig', () => {
lockDuration: 60_000,
lockRenewTime: 10_000,
stalledInterval: 30_000,
maxStalledCount: 1,
},
},
},
@@ -277,7 +276,7 @@ describe('GlobalConfig', () => {
taskRequestTimeout: 60,
heartbeatInterval: 30,
insecureMode: false,
isNativePythonRunnerEnabled: false,
isNativePythonRunnerEnabled: true,
},
sentry: {
backendDsn: '',
@@ -318,13 +317,13 @@ describe('GlobalConfig', () => {
cert: '',
},
security: {
restrictFileAccessTo: '',
restrictFileAccessTo: '~/.n8n-files',
blockFileAccessToN8nFiles: true,
daysAbandonedWorkflow: 90,
contentSecurityPolicy: '{}',
contentSecurityPolicyReportOnly: false,
disableWebhookHtmlSandboxing: false,
disableBareRepos: false,
disableBareRepos: true,
awsSystemCredentialsAccess: false,
enableGitNodeHooks: false,
},

View File

@@ -6,6 +6,7 @@ import { WorkflowEntity } from '../../entities';
import { mockEntityManager } from '../../utils/test-utils/mock-entity-manager';
import { mockInstance } from '../../utils/test-utils/mock-instance';
import { FolderRepository } from '../folder.repository';
import { WorkflowHistoryRepository } from '../workflow-history.repository';
import { WorkflowRepository } from '../workflow.repository';
describe('WorkflowRepository', () => {
@@ -14,10 +15,12 @@ describe('WorkflowRepository', () => {
database: { type: 'postgresdb' },
});
const folderRepository = mockInstance(FolderRepository);
const workflowHistoryRepository = mockInstance(WorkflowHistoryRepository);
const workflowRepository = new WorkflowRepository(
entityManager.connection,
globalConfig,
folderRepository,
workflowHistoryRepository,
);
let queryBuilder: jest.Mocked<SelectQueryBuilder<WorkflowEntity>>;
@@ -141,6 +144,7 @@ describe('WorkflowRepository', () => {
entityManager.connection,
sqliteConfig,
folderRepository,
workflowHistoryRepository,
);
jest.spyOn(sqliteWorkflowRepository, 'createQueryBuilder').mockReturnValue(queryBuilder);

View File

@@ -10,9 +10,10 @@ import type {
FindOptionsRelations,
EntityManager,
} from '@n8n/typeorm';
import { PROJECT_ROOT } from 'n8n-workflow';
import { PROJECT_ROOT, UserError } from 'n8n-workflow';
import { FolderRepository } from './folder.repository';
import { WorkflowHistoryRepository } from './workflow-history.repository';
import {
WebhookEntity,
TagEntity,
@@ -54,6 +55,7 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
dataSource: DataSource,
private readonly globalConfig: GlobalConfig,
private readonly folderRepository: FolderRepository,
private readonly workflowHistoryRepository: WorkflowHistoryRepository,
) {
super(WorkflowEntity, dataSource.manager);
}
@@ -824,6 +826,12 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
}
async updateActiveState(workflowId: string, newState: boolean) {
const workflow = await this.findById(workflowId);
if (!workflow) {
throw new UserError(`Workflow "${workflowId}" not found.`);
}
if (newState) {
return await this.createQueryBuilder()
.update(WorkflowEntity)
@@ -838,26 +846,49 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
}
}
async deactivateAll() {
/**
* Publish a specific version of a workflow
* @param workflowId - The ID of the workflow
* @param versionId - The ID of the version to publish (optional; if not provided, uses the current version)
* */
async publishVersion(workflowId: string, versionId?: string) {
let versionIdToPublish = versionId;
if (!versionIdToPublish) {
const workflow = await this.findOne({
where: { id: workflowId },
select: ['id', 'versionId'],
});
if (!workflow) {
throw new UserError(`Workflow "${workflowId}" not found.`);
}
versionIdToPublish = workflow.versionId;
}
const version = await this.workflowHistoryRepository.findOneBy({
workflowId,
versionId: versionIdToPublish,
});
if (!version) {
throw new UserError(
`Version "${versionIdToPublish}" not found for workflow "${workflowId}".`,
);
}
return await this.update(
{ id: workflowId },
{ active: true, activeVersionId: versionIdToPublish },
);
}
async unpublishAll() {
return await this.update(
{ activeVersionId: Not(IsNull()) },
{ active: false, activeVersionId: null },
);
}
// We're planning to remove this command in V2, so for now set activeVersion to the current version
async activateAll() {
await this.manager
.createQueryBuilder()
.update(WorkflowEntity)
.set({
active: true,
activeVersionId: () => 'versionId',
})
.where('activeVersionId IS NULL')
.execute();
}
async findByActiveState(activeState: boolean) {
return await this.findBy({ activeVersionId: activeState ? Not(IsNull()) : IsNull() });
}

View File

@@ -37,7 +37,7 @@ require('reflect-metadata');
// Also, do not use `inE2ETests` from constants here, because that'd end up code that might read from `process.env` before the values are loaded from an `.env` file.
if (process.env.E2E_TESTS !== 'true') {
// Loading dotenv early ensures that `process.env` is up-to-date everywhere in code
require('dotenv').config();
require('dotenv').config({ quiet: true });
}
// Load config early to ensure `N8N_CONFIG_FILES` values are populated into `GlobalConfig`

View File

@@ -26,14 +26,14 @@
"test:dev": "N8N_LOG_LEVEL=silent DB_SQLITE_POOL_SIZE=4 DB_TYPE=sqlite jest --watch",
"test:sqlite": "N8N_LOG_LEVEL=silent DB_SQLITE_POOL_SIZE=4 DB_TYPE=sqlite jest --config=jest.config.integration.js --no-coverage",
"test:postgres": "N8N_LOG_LEVEL=silent DB_TYPE=postgresdb DB_POSTGRESDB_SCHEMA=alt_schema DB_TABLE_PREFIX=test_ jest --config=jest.config.integration.js --no-coverage",
"test:mariadb": "N8N_LOG_LEVEL=silent DB_TYPE=mariadb DB_TABLE_PREFIX=test_ jest --config=jest.config.integration.js --no-coverage",
"test:mysql": "N8N_LOG_LEVEL=silent DB_TYPE=mysqldb DB_TABLE_PREFIX=test_ jest --config=jest.config.integration.js --no-coverage --maxWorkers=1",
"test:mariadb": "echo true",
"test:mysql": "echo true",
"test:win": "set N8N_LOG_LEVEL=silent&& set DB_SQLITE_POOL_SIZE=4&& set DB_TYPE=sqlite&& jest",
"test:dev:win": "set N8N_LOG_LEVEL=silent&& set DB_SQLITE_POOL_SIZE=4&& set DB_TYPE=sqlite&& jest --watch",
"test:sqlite:win": "set N8N_LOG_LEVEL=silent&& set DB_SQLITE_POOL_SIZE=4&& set DB_TYPE=sqlite&& jest --config=jest.config.integration.js",
"test:postgres:win": "set N8N_LOG_LEVEL=silent&& set DB_TYPE=postgresdb&& set DB_POSTGRESDB_SCHEMA=alt_schema&& set DB_TABLE_PREFIX=test_&& jest --config=jest.config.integration.js --no-coverage",
"test:mariadb:win": "set N8N_LOG_LEVEL=silent&& set DB_TYPE=mariadb&& set DB_TABLE_PREFIX=test_&& jest --config=jest.config.integration.js --no-coverage",
"test:mysql:win": "set N8N_LOG_LEVEL=silent&& set DB_TYPE=mysqldb&& set DB_TABLE_PREFIX=test_&& jest --config=jest.config.integration.js --no-coverage",
"test:mariadb:win": "echo true",
"test:mysql:win": "echo true",
"watch": "tsc-watch -p tsconfig.build.json --onCompilationComplete \"tsc-alias -p tsconfig.build.json\""
},
"bin": {
@@ -128,7 +128,7 @@
"convict": "6.2.4",
"cookie-parser": "1.4.7",
"csrf": "3.1.0",
"dotenv": "8.6.0",
"dotenv": "17.2.3",
"express": "5.1.0",
"express-handlebars": "8.0.1",
"express-openapi-validator": "5.5.3",

View File

@@ -3,7 +3,8 @@ import { CommandMetadata, type CommandEntry } from '@n8n/decorators';
import { Container, Service } from '@n8n/di';
import glob from 'fast-glob';
import picocolors from 'picocolors';
import { z } from 'zod';
import { z, ZodError } from 'zod';
import './zod-alias-support';
/**
@@ -37,7 +38,9 @@ export class CommandRegistry {
// Try to load regular commands
try {
await import(`./commands/${this.commandName.replaceAll(':', '/')}.js`);
} catch {}
} catch {
// Do nothing
}
// Load modules to ensure all module commands are registered
await this.moduleRegistry.loadModules();
@@ -53,10 +56,23 @@ export class CommandRegistry {
return process.exit(0);
}
const { flags } = this.cliParser.parse({
argv: process.argv,
flagsSchema: commandEntry.flagsSchema,
});
let flags: Record<string, unknown>;
try {
({ flags } = this.cliParser.parse({
argv: process.argv,
flagsSchema: commandEntry.flagsSchema,
}));
} catch (error) {
if (error instanceof ZodError) {
this.logger.error(this.formatZodError(error));
this.logger.info('');
this.printCommandUsage(commandEntry);
return process.exit(1);
}
// Preserve previous behavior for non-Zod errors
throw error;
}
const command = Container.get(commandEntry.class);
command.flags = flags;
@@ -163,4 +179,31 @@ export class CommandRegistry {
this.logger.info(output);
}
private formatZodError(error: ZodError): string {
const issuesByFlag: Record<string, z.ZodIssue[]> = {};
for (const issue of error.issues) {
const flag = (issue.path[0] as string | undefined) ?? 'flags';
if (!issuesByFlag[flag]) issuesByFlag[flag] = [];
issuesByFlag[flag].push(issue);
}
let output = '';
output += picocolors.red(
`\nError: Invalid flags provided for command "${this.commandName}".\n\n`,
);
for (const [flag, issues] of Object.entries(issuesByFlag)) {
const flagLabel = flag === 'flags' ? '(general)' : `--${flag}`;
output += ` ${picocolors.bold(flagLabel)}\n`;
for (const issue of issues) {
output += ` - ${issue.message}\n`;
}
output += '\n';
}
return output.trimEnd();
}
}

View File

@@ -1,7 +1,7 @@
import { mockInstance } from '@n8n/backend-test-utils';
import { GlobalConfig } from '@n8n/config';
import type { User, WorkflowEntity } from '@n8n/db';
import { WorkflowRepository, DbConnection, AuthRolesService } from '@n8n/db';
import { WorkflowRepository, DbConnection, AuthRolesService, BinaryDataRepository } from '@n8n/db';
import { Container } from '@n8n/di';
import { type SelectQueryBuilder } from '@n8n/typeorm';
import { mock } from 'jest-mock-extended';
@@ -40,6 +40,7 @@ const dbConnection = mockInstance(DbConnection);
dbConnection.init.mockResolvedValue(undefined);
dbConnection.migrate.mockResolvedValue(undefined);
mockInstance(AuthRolesService);
mockInstance(BinaryDataRepository);
test('should start a task runner when task runners are enabled', async () => {
// arrange

View File

@@ -1,7 +1,7 @@
import { mockInstance } from '@n8n/backend-test-utils';
import { GlobalConfig } from '@n8n/config';
import type { User, WorkflowEntity } from '@n8n/db';
import { WorkflowRepository, DbConnection, AuthRolesService } from '@n8n/db';
import { WorkflowRepository, DbConnection, AuthRolesService, BinaryDataRepository } from '@n8n/db';
import { Container } from '@n8n/di';
import { mock } from 'jest-mock-extended';
import type { IRun } from 'n8n-workflow';
@@ -39,6 +39,7 @@ const dbConnection = mockInstance(DbConnection);
dbConnection.init.mockResolvedValue(undefined);
dbConnection.migrate.mockResolvedValue(undefined);
mockInstance(AuthRolesService);
mockInstance(BinaryDataRepository);
test('should start a task runner when task runners are enabled', async () => {
// arrange

View File

@@ -20,7 +20,7 @@ import {
ErrorReporter,
ExecutionContextHookRegistry,
} from 'n8n-core';
import { ensureError, sleep, UnexpectedError, UserError } from 'n8n-workflow';
import { ensureError, sleep, UnexpectedError } from 'n8n-workflow';
import type { AbstractServer } from '@/abstract-server';
import { N8N_VERSION, N8N_RELEASE_DATE } from '@/constants';
@@ -197,54 +197,41 @@ export abstract class BaseCommand<F = never> {
throw new UnexpectedError(message);
}
async initObjectStoreService() {
const binaryDataConfig = Container.get(BinaryDataConfig);
const isSelected = binaryDataConfig.mode === 's3';
const isAvailable = binaryDataConfig.availableModes.includes('s3');
if (!isSelected) return;
if (isSelected && !isAvailable) {
throw new UserError(
'External storage selected but unavailable. Please make external storage available by adding "s3" to `N8N_AVAILABLE_BINARY_DATA_MODES`.',
);
}
const isLicensed = Container.get(License).isLicensed(LICENSE_FEATURES.BINARY_DATA_S3);
if (!isLicensed) {
this.logger.error(
'No license found for S3 storage. \n Either set `N8N_DEFAULT_BINARY_DATA_MODE` to something else, or upgrade to a license that supports this feature.',
);
return process.exit(1);
}
this.logger.debug('License found for external storage - Initializing object store service');
try {
await Container.get(ObjectStoreService).init();
this.logger.debug('Object store init completed');
} catch (e) {
const error = e instanceof Error ? e : new Error(`${e}`);
this.logger.debug('Object store init failed', { error });
}
}
async initBinaryDataService() {
const binaryDataConfig = Container.get(BinaryDataConfig);
const binaryDataService = Container.get(BinaryDataService);
const isS3WriteMode = binaryDataConfig.mode === 's3';
const { DatabaseManager } = await import('@/binary-data/database.manager');
binaryDataService.setManager('database', Container.get(DatabaseManager));
if (isS3WriteMode) {
const isLicensed = Container.get(License).isLicensed(LICENSE_FEATURES.BINARY_DATA_S3);
if (!isLicensed) {
this.logger.error(
'S3 binary data storage requires a valid license. Either set `N8N_DEFAULT_BINARY_DATA_MODE` to something else, or upgrade to a license that supports this feature.',
);
process.exit(1);
}
}
// we always try to init S3 for reading - silently fail if not configured
try {
await this.initObjectStoreService();
} catch (e) {
const error = e instanceof Error ? e : new Error(`${e}`);
this.logger.error(`Failed to init object store: ${error.message}`, { error });
process.exit(1);
const objectStoreService = Container.get(ObjectStoreService);
await objectStoreService.init();
const { ObjectStoreManager } = await import('n8n-core/dist/binary-data/object-store.manager');
binaryDataService.setManager('s3', new ObjectStoreManager(objectStoreService));
} catch {
if (isS3WriteMode) {
this.logger.error(
'Failed to connect to S3 for binary data storage. Please check your S3 configuration.',
);
process.exit(1);
}
// S3 not configured - users without S3 data are unaffected; users with S3 data will fail at runtime when reading
}
if (Container.get(BinaryDataConfig).availableModes.includes('database')) {
const binaryDataService = Container.get(BinaryDataService);
const { DatabaseManager } = await import('@/binary-data/database.manager');
const databaseManager = Container.get(DatabaseManager);
binaryDataService.setManager('database', databaseManager);
}
await Container.get(BinaryDataService).init();
await binaryDataService.init();
}
protected async initDataDeduplicationService() {

View File

@@ -0,0 +1,56 @@
import { WorkflowRepository } from '@n8n/db';
import { Command } from '@n8n/decorators';
import { Container } from '@n8n/di';
import { z } from 'zod';
import { BaseCommand } from '../base-command';
const flagsSchema = z.object({
id: z.string().describe('The ID of the workflow to publish'),
versionId: z
.string()
.describe('The version ID to publish. If not provided, publishes the current version')
.optional(),
all: z.boolean().describe('(Deprecated) This flag is no longer supported').optional(),
});
@Command({
name: 'publish:workflow',
description:
'Publish a specific version of a workflow. If no version is specified, publishes the current version.',
examples: ['--id=5 --versionId=abc123', '--id=5'],
flagsSchema,
})
export class PublishWorkflowCommand extends BaseCommand<z.infer<typeof flagsSchema>> {
async run() {
const { flags } = this;
// Educate users who try to use --all flag
if (flags.all) {
this.logger.error('The --all flag is no longer supported for workflow publishing.');
this.logger.error(
'Please publish workflows individually using: publish:workflow --id=<workflow-id> [--versionId=<version-id>]',
);
return;
}
if (flags.versionId) {
this.logger.info(`Publishing workflow with ID: ${flags.id}, version: ${flags.versionId}`);
} else {
this.logger.info(`Publishing workflow with ID: ${flags.id} (current version)`);
}
await Container.get(WorkflowRepository).publishVersion(flags.id, flags.versionId);
this.logger.info('Note: Changes will not take effect if n8n is running.');
this.logger.info('Please restart n8n for changes to take effect if n8n is currently running.');
}
async catch(error: Error) {
this.logger.error('Error updating database. See log messages for details.');
this.logger.error('\nGOT ERROR');
this.logger.error('====================================');
this.logger.error(error.message);
this.logger.error(error.stack!);
}
}

View File

@@ -7,7 +7,7 @@ import { Container } from '@n8n/di';
import glob from 'fast-glob';
import { createReadStream, createWriteStream, existsSync } from 'fs';
import { mkdir } from 'fs/promises';
import { jsonParse, randomString, type IWorkflowExecutionDataProcess } from 'n8n-workflow';
import { jsonParse, type IWorkflowExecutionDataProcess } from 'n8n-workflow';
import path from 'path';
import replaceStream from 'replacestream';
import { pipeline } from 'stream/promises';
@@ -52,7 +52,7 @@ const flagsSchema = z.object({
@Command({
name: 'start',
description: 'Starts n8n. Makes Web-UI available and starts active workflows',
examples: ['', '--tunnel', '-o', '--tunnel -o'],
examples: ['', '-o'],
flagsSchema,
})
export class Start extends BaseCommand<z.infer<typeof flagsSchema>> {
@@ -299,34 +299,6 @@ export class Start extends BaseCommand<z.infer<typeof flagsSchema>> {
}
}
if (flags.tunnel) {
this.log('\nWaiting for tunnel ...');
let tunnelSubdomain =
process.env.N8N_TUNNEL_SUBDOMAIN ?? this.instanceSettings.tunnelSubdomain ?? '';
if (tunnelSubdomain === '') {
// When no tunnel subdomain did exist yet create a new random one
tunnelSubdomain = randomString(24).toLowerCase();
this.instanceSettings.update({ tunnelSubdomain });
}
const { default: localtunnel } = await import('@n8n/localtunnel');
const { port } = this.globalConfig;
const webhookTunnel = await localtunnel(port, {
host: 'https://hooks.n8n.cloud',
subdomain: tunnelSubdomain,
});
process.env.WEBHOOK_URL = `${webhookTunnel.url}/`;
this.log(`Tunnel URL: ${process.env.WEBHOOK_URL}\n`);
this.log(
'IMPORTANT! Do not share with anybody as it would give people access to your n8n instance!',
);
}
if (this.globalConfig.database.isLegacySqlite) {
// Employ lazy loading to avoid unnecessary imports in the CLI
// and to ensure that the legacy recovery service is only used when needed.

View File

@@ -0,0 +1,54 @@
import { WorkflowRepository } from '@n8n/db';
import { Command } from '@n8n/decorators';
import { Container } from '@n8n/di';
import { z } from 'zod';
import { BaseCommand } from '../base-command';
const flagsSchema = z.object({
all: z.boolean().describe('Unpublish all workflows').optional(),
id: z.string().describe('The ID of the workflow to unpublish').optional(),
});
@Command({
name: 'unpublish:workflow',
description: 'Unpublish workflow(s)',
examples: ['--all', '--id=5'],
flagsSchema,
})
export class UnpublishWorkflowCommand extends BaseCommand<z.infer<typeof flagsSchema>> {
async run() {
const { flags } = this;
if (!flags.all && !flags.id) {
this.logger.error('Either option "--all" or "--id" must be set.');
return;
}
if (flags.all && flags.id) {
this.logger.error('Cannot use both "--all" and "--id" flags together.');
return;
}
if (flags.id) {
this.logger.info(`Unpublishing workflow with ID: ${flags.id}`);
await Container.get(WorkflowRepository).updateActiveState(flags.id, false);
this.logger.info('Workflow unpublished successfully');
} else {
this.logger.info('Unpublishing all workflows');
await Container.get(WorkflowRepository).unpublishAll();
this.logger.info('All workflows unpublished successfully');
}
this.logger.info('Note: Changes will not take effect if n8n is running.');
this.logger.info('Please restart n8n for changes to take effect if n8n is currently running.');
}
async catch(error: Error) {
this.logger.error('Error unpublishing workflow(s). See log messages for details.');
this.logger.error('\nGOT ERROR');
this.logger.error('====================================');
this.logger.error(error.message);
this.logger.error(error.stack!);
}
}

View File

@@ -13,14 +13,17 @@ const flagsSchema = z.object({
@Command({
name: 'update:workflow',
description: 'Update workflows',
description: '[DEPRECATED] Update workflows - use publish:workflow or unpublish:workflow instead',
examples: ['--all --active=false', '--id=5 --active=true'],
flagsSchema,
})
export class UpdateWorkflowCommand extends BaseCommand<z.infer<typeof flagsSchema>> {
async run() {
const workflowRepository = Container.get(WorkflowRepository);
const { flags } = this;
this.logger.warn('⚠️ WARNING: The "update:workflow" command is deprecated.\n');
if (!flags.all && !flags.id) {
this.logger.error('Either option "--all" or "--id" have to be set!');
return;
@@ -46,19 +49,49 @@ export class UpdateWorkflowCommand extends BaseCommand<z.infer<typeof flagsSchem
const newState = flags.active === 'true';
const action = newState ? 'Activating' : 'Deactivating';
if (flags.id) {
this.logger.info(`${action} workflow with ID: ${flags.id}`);
await Container.get(WorkflowRepository).updateActiveState(flags.id, newState);
} else {
this.logger.info(`${action} all workflows`);
if (newState) {
await Container.get(WorkflowRepository).activateAll();
} else {
await Container.get(WorkflowRepository).deactivateAll();
// Backwards compatibility: if --id and --active=true, publish the current version
if (flags.id && newState) {
this.logger.info(`Publishing workflow ${flags.id} with current version`);
this.logger.warn(`Please use: publish:workflow --id=${flags.id}\n`);
try {
await workflowRepository.publishVersion(flags.id);
} catch (error) {
this.logger.error('Failed to publish workflow');
throw error;
}
this.logger.info('Note: Changes will not take effect if n8n is running.');
this.logger.info(
'Please restart n8n for changes to take effect if n8n is currently running.',
);
return;
}
this.logger.info('Activation or deactivation will not take effect if n8n is running.');
// Block publishing with --all flag
if (flags.all && newState) {
this.logger.error('Workflow publishing via "update:workflow --all" is no longer supported.');
this.logger.error(
'Please publish workflows individually using: publish:workflow --id=<workflow-id>',
);
return;
}
// Show appropriate replacement command suggestion for unpublishing
if (flags.id) {
this.logger.warn(`Please use: unpublish:workflow --id=${flags.id}\n`);
} else {
this.logger.warn('Please use: unpublish:workflow --all\n');
}
if (flags.id) {
this.logger.info(`${action} workflow with ID: ${flags.id}`);
await workflowRepository.updateActiveState(flags.id, newState);
} else {
this.logger.info(`${action} all workflows`);
await workflowRepository.unpublishAll();
}
this.logger.info('Note: Changes will not take effect if n8n is running.');
this.logger.info('Please restart n8n for changes to take effect if n8n is currently running.');
}

View File

@@ -1,12 +1,9 @@
import { inTest, Logger } from '@n8n/backend-common';
import { inTest } from '@n8n/backend-common';
import { GlobalConfig } from '@n8n/config';
import { Container } from '@n8n/di';
import convict from 'convict';
import { flatten } from 'flat';
import { readFileSync } from 'fs';
import merge from 'lodash/merge';
import { setGlobalState, UserError } from 'n8n-workflow';
import assert from 'node:assert';
import { inE2ETests } from '@/constants';
@@ -33,42 +30,8 @@ const config = convict(schema, { args: [] });
// eslint-disable-next-line @typescript-eslint/unbound-method
config.getEnv = config.get;
const logger = Container.get(Logger);
// Load overwrites when not in tests
if (!inE2ETests && !inTest) {
// Overwrite default configuration with settings which got defined in
// optional configuration files
const { N8N_CONFIG_FILES } = process.env;
if (N8N_CONFIG_FILES !== undefined) {
const configFiles = N8N_CONFIG_FILES.split(',');
for (const configFile of configFiles) {
if (!configFile) continue;
// NOTE: This is "temporary" code until we have migrated all config to the new package
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const data = JSON.parse(readFileSync(configFile, 'utf8'));
for (const prefix in data) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
const innerData = data[prefix];
if (prefix in globalConfig) {
// @ts-ignore
merge(globalConfig[prefix], innerData);
} else {
const flattenedData: Record<string, string> = flatten(innerData);
for (const key in flattenedData) {
config.set(`${prefix}.${key}`, flattenedData[key]);
}
}
}
logger.debug(`Loaded config overwrites from ${configFile}`);
} catch (error) {
assert(error instanceof Error);
logger.error(`Error loading config file ${configFile}`, { error });
}
}
}
// Overwrite config from files defined in "_FILE" environment variables
Object.entries(process.env).forEach(([envName, fileName]) => {
if (envName.endsWith('_FILE') && fileName) {

View File

@@ -18,7 +18,7 @@ describe('shouldSkipAuthOnOAuthCallback', () => {
});
it('should return true', () => {
expect(shouldSkipAuthOnOAuthCallback()).toBe(true);
expect(shouldSkipAuthOnOAuthCallback()).toBe(false);
});
});

View File

@@ -35,8 +35,7 @@ type CsrfStateParam = {
const MAX_CSRF_AGE = 5 * Time.minutes.toMilliseconds;
export function shouldSkipAuthOnOAuthCallback() {
// TODO: Flip this flag in v2 https://linear.app/n8n/issue/CAT-329
const value = process.env.N8N_SKIP_AUTH_ON_OAUTH_CALLBACK?.toLowerCase() ?? 'true';
const value = process.env.N8N_SKIP_AUTH_ON_OAUTH_CALLBACK?.toLowerCase() ?? 'false';
return value === 'true';
}

View File

@@ -7,6 +7,23 @@ post:
description: Active a workflow.
parameters:
- $ref: '../schemas/parameters/workflowId.yml'
requestBody:
description: Optional parameters for workflow activation.
content:
application/json:
schema:
type: object
properties:
versionId:
type: string
description: The specific version ID to activate. If not provided, activates the latest version.
name:
type: string
description: Optional name for the workflow version during activation.
description:
type: string
description: Optional description for the workflow version during activation.
required: false
responses:
'200':
description: Workflow object
@@ -14,6 +31,8 @@ post:
application/json:
schema:
$ref: '../schemas/workflow.yml'
'400':
$ref: '../../../../shared/spec/responses/badRequest.yml'
'401':
$ref: '../../../../shared/spec/responses/unauthorized.yml'
'404':

View File

@@ -51,7 +51,7 @@ put:
tags:
- Workflow
summary: Update a workflow
description: Update a workflow.
description: Update a workflow. If the workflow is active, the updated version will be automatically reactivated.
parameters:
- $ref: '../schemas/parameters/workflowId.yml'
requestBody:

View File

@@ -309,6 +309,7 @@ export = {
{
forceSave: true, // Skip version conflict check for public API
publicApi: true,
publishIfActive: true,
},
);

View File

@@ -70,7 +70,7 @@ describe('ScalingService', () => {
QUEUE_NAME,
{
prefix: globalConfig.queue.bull.prefix,
settings: globalConfig.queue.bull.settings,
settings: { ...globalConfig.queue.bull.settings, maxStalledCount: 0 },
createClient: expect.any(Function),
},
];

View File

@@ -67,7 +67,7 @@ export class ScalingService {
this.queue = new BullQueue(QUEUE_NAME, {
prefix,
settings: this.globalConfig.queue.bull.settings,
settings: { ...this.globalConfig.queue.bull.settings, maxStalledCount: 0 },
createClient: (type) => service.createClient({ type: `${type}(bull)` }),
});

View File

@@ -6,7 +6,6 @@ import type {
ListQueryDb,
WorkflowFolderUnionFull,
WorkflowHistoryUpdate,
WorkflowHistory,
} from '@n8n/db';
import {
SharedWorkflow,
@@ -24,6 +23,7 @@ import type { EntityManager } from '@n8n/typeorm';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import { In } from '@n8n/typeorm';
import type { QueryDeepPartialEntity } from '@n8n/typeorm/query-builder/QueryPartialEntity';
import isEqual from 'lodash/isEqual';
import omit from 'lodash/omit';
import pick from 'lodash/pick';
import { FileLocation, BinaryDataService } from 'n8n-core';
@@ -34,6 +34,7 @@ import { ActiveWorkflowManager } from '@/active-workflow-manager';
import { FolderNotFoundError } from '@/errors/folder-not-found.error';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { WorkflowHistoryVersionNotFoundError } from '@/errors/workflow-history-version-not-found.error';
import { EventService } from '@/events/event.service';
import { ExternalHooks } from '@/external-hooks';
import { validateEntity } from '@/generic-helpers';
@@ -50,12 +51,6 @@ import { WorkflowFinderService } from './workflow-finder.service';
import { WorkflowHistoryService } from './workflow-history/workflow-history.service';
import { WorkflowSharingService } from './workflow-sharing.service';
type RollbackPayload = {
active: boolean;
activeVersionId: string | null;
activeVersion: WorkflowHistory | null;
} & Partial<Omit<WorkflowEntity, 'active' | 'activeVersionId' | 'activeVersion'>>;
@Service()
export class WorkflowService {
constructor(
@@ -211,6 +206,15 @@ export class WorkflowService {
);
}
/**
* Updates the workflow content (such as name, nodes, connections, settings, etc.).
*
* This method never updates the workflow's active fields (active, activeVersionId) directly.
* However, if settings change and the workflow has an active version, the workflow will be
* automatically reactivated to ensure the ActiveWorkflowManager uses the updated settings.
* For explicit activation or deactivation, use the activate/deactivate methods.
*/
// eslint-disable-next-line complexity
async update(
user: User,
@@ -221,9 +225,16 @@ export class WorkflowService {
parentFolderId?: string;
forceSave?: boolean;
publicApi?: boolean;
publishIfActive?: boolean;
} = {},
): Promise<WorkflowEntity> {
const { tagIds, parentFolderId, forceSave = false, publicApi = false } = options;
const {
tagIds,
parentFolderId,
forceSave = false,
publicApi = false,
publishIfActive = false,
} = options;
const workflow = await this.workflowFinderService.findWorkflowForUser(
workflowId,
user,
@@ -252,8 +263,6 @@ export class WorkflowService {
);
}
const isDraftPublishDisabled = !this.globalConfig.workflows.draftPublishEnabled;
if (
Object.keys(omit(workflowUpdateData, ['id', 'versionId', 'active', 'activeVersionId']))
.length > 0
@@ -272,22 +281,9 @@ export class WorkflowService {
},
);
}
// Convert 'active' boolean from frontend to 'activeVersionId' for backend
// Forbid updating active fields with FF on
if (isDraftPublishDisabled && 'active' in workflowUpdateData) {
if (workflowUpdateData.active) {
workflowUpdateData.activeVersionId = workflowUpdateData.versionId ?? workflow.versionId;
} else {
workflowUpdateData.activeVersionId = null;
}
}
const versionChanged =
workflowUpdateData.versionId && workflowUpdateData.versionId !== workflow.versionId;
const wasActive = workflow.activeVersionId !== null;
const isNowActive = workflowUpdateData.active ?? wasActive;
const activationStatusChanged = isNowActive !== wasActive;
const needsActiveVersionUpdate = activationStatusChanged || (versionChanged && isNowActive);
if (versionChanged) {
// To save a version, we need both nodes and connections
@@ -308,18 +304,12 @@ export class WorkflowService {
};
}
await this.externalHooks.run('workflow.update', [workflowUpdateData]);
// Check if settings actually changed
const settingsChanged =
workflowUpdateData.settings !== undefined &&
!isEqual(workflow.settings, workflowUpdateData.settings);
/**
* If the workflow being updated is stored as `active`, remove it from
* active workflows in memory, and re-add it after the update.
*
* If a trigger or poller in the workflow was updated, the new value
* will take effect only on removing and re-adding.
*/
if (isDraftPublishDisabled && wasActive) {
await this.activeWorkflowManager.remove(workflowId);
}
await this.externalHooks.run('workflow.update', [workflowUpdateData]);
const workflowSettings = workflowUpdateData.settings ?? {};
const keysAllowingDefault = [
@@ -359,11 +349,8 @@ export class WorkflowService {
'versionId',
'description',
'updatedAt',
// do not update active fields
];
// Forbid updating active fields with FF on
if (isDraftPublishDisabled) {
fieldsToUpdate.push('activeVersionId', 'active');
}
const updatePayload: QueryDeepPartialEntity<WorkflowEntity> = pick(
workflowUpdateData,
@@ -374,19 +361,13 @@ export class WorkflowService {
if (versionChanged) {
await this.workflowHistoryService.saveVersion(user, workflowUpdateData, workflowId);
}
if (isDraftPublishDisabled && needsActiveVersionUpdate) {
const versionIdToFetch = versionChanged ? workflowUpdateData.versionId : workflow.versionId;
const version = await this.workflowHistoryService.getVersion(
user,
workflowId,
versionIdToFetch,
);
updatePayload.activeVersion = WorkflowHelpers.getActiveVersionUpdateValue(
workflow,
version,
isNowActive,
);
const publishCurrent = workflow.activeVersionId && publishIfActive;
if (publishCurrent) {
updatePayload.active = true;
updatePayload.activeVersionId = workflowUpdateData.versionId;
}
if (parentFolderId) {
const project = await this.sharedWorkflowRepository.getWorkflowOwningProject(workflow.id);
if (parentFolderId !== PROJECT_ROOT) {
@@ -434,50 +415,16 @@ export class WorkflowService {
workflow: updatedWorkflow,
publicApi,
});
// Skip activation/deactivation logic if draft/publish feature flag is enabled
if (isDraftPublishDisabled) {
if (activationStatusChanged && isNowActive) {
this.eventService.emit('workflow-activated', {
user,
workflowId,
workflow: updatedWorkflow,
publicApi: false,
});
} else if (activationStatusChanged && !isNowActive) {
// Workflow is being deactivated
this.eventService.emit('workflow-deactivated', {
user,
workflowId,
workflow: updatedWorkflow,
publicApi,
});
assert(workflow.activeVersionId !== null);
await this.workflowPublishHistoryRepository.addRecord({
workflowId,
versionId: workflow.activeVersionId,
event: 'deactivated',
userId: user.id,
});
}
if (isNowActive) {
// When the workflow is supposed to be active add it again
await this._addToActiveWorkflowManager(
user,
workflowId,
updatedWorkflow,
wasActive ? 'update' : 'activate',
// If workflow could not be activated, set it again to inactive
// and revert the versionId and activeVersionId change so UI remains consistent
{
versionId: workflow.versionId,
active: false,
activeVersionId: null,
activeVersion: null,
},
publicApi,
);
}
// Activate workflow if requested, or
// Reactivate workflow if settings changed and workflow has an active version
if (updatedWorkflow.activeVersionId && (publishCurrent || settingsChanged)) {
await this.activateWorkflow(
user,
workflowId,
{ versionId: updatedWorkflow.activeVersionId },
publicApi,
);
}
return updatedWorkflow;
}
@@ -491,7 +438,6 @@ export class WorkflowService {
workflowId: string,
workflow: WorkflowEntity,
mode: 'activate' | 'update',
rollbackPayload: RollbackPayload,
publicApi: boolean = false,
): Promise<void> {
let didPublish = false;
@@ -500,6 +446,11 @@ export class WorkflowService {
await this.activeWorkflowManager.add(workflowId, mode);
didPublish = true;
} catch (error) {
const rollbackPayload = {
active: false,
activeVersionId: null,
activeVersion: null,
};
const previouslyActiveId = workflow.activeVersionId;
await this.workflowRepository.update(workflowId, rollbackPayload);
@@ -557,7 +508,6 @@ export class WorkflowService {
options?: { versionId?: string; name?: string; description?: string },
publicApi: boolean = false,
): Promise<WorkflowEntity> {
const isDraftPublishEnabled = this.globalConfig.workflows.draftPublishEnabled;
const workflow = await this.workflowFinderService.findWorkflowForUser(
workflowId,
user,
@@ -575,15 +525,24 @@ export class WorkflowService {
);
}
const versionToActivate = isDraftPublishEnabled
? (options?.versionId ?? workflow.versionId)
: workflow.versionId;
const versionToActivate = options?.versionId ?? workflow.versionId;
const wasActive = workflow.activeVersionId !== null;
if (workflow.activeVersionId === versionToActivate) {
return workflow;
try {
await this.workflowHistoryService.getVersion(user, workflow.id, versionToActivate, {
includePublishHistory: false,
});
} catch (error) {
if (error instanceof WorkflowHistoryVersionNotFoundError) {
throw new NotFoundError('Version not found');
}
throw error;
}
if (wasActive) {
await this.activeWorkflowManager.remove(workflowId);
}
const wasActive = workflow.activeVersionId !== null;
const activationMode = wasActive ? 'update' : 'activate';
await this.workflowRepository.update(workflowId, {
@@ -614,24 +573,10 @@ export class WorkflowService {
workflowId,
updatedWorkflow,
activationMode,
isDraftPublishEnabled
? {
active: workflow.active,
activeVersionId: workflow.activeVersionId,
activeVersion: workflow.activeVersion,
}
: {
active: false,
activeVersionId: null,
activeVersion: null,
},
publicApi,
);
if (
isDraftPublishEnabled &&
(options?.name !== undefined || options?.description !== undefined)
) {
if (options?.name !== undefined || options?.description !== undefined) {
const updateFields: WorkflowHistoryUpdate = {};
if (options.name !== undefined) updateFields.name = options.name;
if (options.description !== undefined) updateFields.description = options.description;

View File

@@ -0,0 +1,111 @@
import {
mockInstance,
testDb,
createWorkflowWithTriggerAndHistory,
getWorkflowById,
} from '@n8n/backend-test-utils';
import { PublishWorkflowCommand } from '@/commands/publish/workflow';
import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
import { setupTestCommand } from '@test-integration/utils/test-command';
mockInstance(LoadNodesAndCredentials);
const command = setupTestCommand(PublishWorkflowCommand);
beforeEach(async () => {
await testDb.truncate(['WorkflowEntity', 'WorkflowHistory']);
});
test('publish:workflow can publish a specific workflow version', async () => {
//
// ARRANGE
//
const workflow = await createWorkflowWithTriggerAndHistory();
//
// ACT
//
await command.run([`--id=${workflow.id}`, `--versionId=${workflow.versionId}`]);
//
// ASSERT
//
const updatedWorkflow = await getWorkflowById(workflow.id);
expect(updatedWorkflow).toMatchObject({
activeVersionId: workflow.versionId,
active: true,
});
});
test('publish:workflow does not publish when --all flag is used', async () => {
//
// ARRANGE
//
const workflow = await createWorkflowWithTriggerAndHistory();
//
// ACT
//
await command.run(['--all', `--id=${workflow.id}`, `--versionId=${workflow.versionId}`]);
//
// ASSERT
//
// Verify the workflow was not published (--all flag prevents publishing)
const unchangedWorkflow = await getWorkflowById(workflow.id);
expect(unchangedWorkflow).toMatchObject({
activeVersionId: null,
active: false,
});
});
test('publish:workflow publishes current version when --versionId is missing', async () => {
//
// ARRANGE
//
const workflow = await createWorkflowWithTriggerAndHistory();
//
// ACT
//
await command.run([`--id=${workflow.id}`]);
//
// ASSERT
//
const updatedWorkflow = await getWorkflowById(workflow.id);
expect(updatedWorkflow).toMatchObject({
activeVersionId: workflow.versionId,
active: true,
});
});
test('unpublish:workflow throws error when workflow does not exist', async () => {
//
// ARRANGE
//
const nonExistentWorkflowId = 'non-existent-workflow-id';
//
// ACT & ASSERT
//
await expect(command.run([`--id=${nonExistentWorkflowId}`])).rejects.toThrow(
`Workflow "${nonExistentWorkflowId}" not found.`,
);
});
test('publish:workflow throws error when version does not exist', async () => {
//
// ARRANGE
//
const workflow = await createWorkflowWithTriggerAndHistory();
const nonExistentVersionId = 'non-existent-version';
//
// ACT & ASSERT
//
await expect(
command.run([`--id=${workflow.id}`, `--versionId=${nonExistentVersionId}`]),
).rejects.toThrow(`Version "${nonExistentVersionId}" not found for workflow "${workflow.id}".`);
});

View File

@@ -0,0 +1,144 @@
import {
mockInstance,
testDb,
createManyActiveWorkflows,
getWorkflowById,
} from '@n8n/backend-test-utils';
import { UnpublishWorkflowCommand } from '@/commands/unpublish/workflow';
import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
import { setupTestCommand } from '@test-integration/utils/test-command';
mockInstance(LoadNodesAndCredentials);
const command = setupTestCommand(UnpublishWorkflowCommand);
beforeEach(async () => {
await testDb.truncate(['WorkflowEntity', 'WorkflowHistory']);
});
test('unpublish:workflow can unpublish all workflows', async () => {
//
// ARRANGE
//
const workflows = await createManyActiveWorkflows(2);
//
// ACT
//
await command.run(['--all']);
//
// ASSERT
//
const workflow1 = await getWorkflowById(workflows[0].id);
const workflow2 = await getWorkflowById(workflows[1].id);
expect(workflow1).toMatchObject({
activeVersionId: null,
active: false,
});
expect(workflow2).toMatchObject({
activeVersionId: null,
active: false,
});
});
test('unpublish:workflow can unpublish a specific workflow', async () => {
//
// ARRANGE
//
const workflows = await createManyActiveWorkflows(2);
//
// ACT
//
await command.run([`--id=${workflows[0].id}`]);
//
// ASSERT
//
const unpublishedWorkflow = await getWorkflowById(workflows[0].id);
const activeWorkflow = await getWorkflowById(workflows[1].id);
expect(unpublishedWorkflow).toMatchObject({
activeVersionId: null,
active: false,
});
expect(activeWorkflow).toMatchObject({
activeVersionId: workflows[1].versionId,
active: true,
});
});
test('unpublish:workflow does nothing when neither --all nor --id is provided', async () => {
//
// ARRANGE
//
const workflows = await createManyActiveWorkflows(2);
//
// ACT
//
await command.run([]);
//
// ASSERT
//
// Verify workflows are still active
const workflow1 = await getWorkflowById(workflows[0].id);
const workflow2 = await getWorkflowById(workflows[1].id);
expect(workflow1).toMatchObject({
activeVersionId: workflows[0].versionId,
active: true,
});
expect(workflow2).toMatchObject({
activeVersionId: workflows[1].versionId,
active: true,
});
});
test('unpublish:workflow does nothing when both --all and --id are provided', async () => {
//
// ARRANGE
//
const workflows = await createManyActiveWorkflows(2);
//
// ACT
//
await command.run(['--all', '--id=123']);
//
// ASSERT
//
// Verify workflows are still active
const workflow1 = await getWorkflowById(workflows[0].id);
const workflow2 = await getWorkflowById(workflows[1].id);
expect(workflow1).toMatchObject({
activeVersionId: workflows[0].versionId,
active: true,
});
expect(workflow2).toMatchObject({
activeVersionId: workflows[1].versionId,
active: true,
});
});
test('unpublish:workflow throws error when workflow does not exist', async () => {
//
// ARRANGE
//
const nonExistentWorkflowId = 'non-existent-workflow-id';
//
// ACT & ASSERT
//
await expect(command.run([`--id=${nonExistentWorkflowId}`])).rejects.toThrow(
`Workflow "${nonExistentWorkflowId}" not found.`,
);
});

View File

@@ -19,7 +19,7 @@ beforeEach(async () => {
await testDb.truncate(['WorkflowEntity', 'WorkflowHistory']);
});
test('update:workflow can activate all workflows', async () => {
test('update:workflow does not publish when trying to publish all workflows', async () => {
//
// ARRANGE
//
@@ -27,8 +27,6 @@ test('update:workflow can activate all workflows', async () => {
createWorkflowWithTriggerAndHistory({}),
createWorkflowWithTriggerAndHistory({}),
]);
expect(workflows[0].activeVersionId).toBeNull();
expect(workflows[1].activeVersionId).toBeNull();
//
// ACT
@@ -38,24 +36,16 @@ test('update:workflow can activate all workflows', async () => {
//
// ASSERT
//
// Verify activeVersionId is now set to the current versionId
// Verify workflows were NOT published (publishing all is no longer supported)
const workflowRepo = Container.get(WorkflowRepository);
const workflow1 = await workflowRepo.findOne({
where: { id: workflows[0].id },
relations: ['activeVersion'],
});
const workflow2 = await workflowRepo.findOne({
where: { id: workflows[1].id },
relations: ['activeVersion'],
});
const workflow1 = await workflowRepo.findOneBy({ id: workflows[0].id });
const workflow2 = await workflowRepo.findOneBy({ id: workflows[1].id });
expect(workflow1?.activeVersionId).toBe(workflows[0].versionId);
expect(workflow1?.activeVersion?.versionId).toBe(workflows[0].versionId);
expect(workflow2?.activeVersionId).toBe(workflows[1].versionId);
expect(workflow2?.activeVersion?.versionId).toBe(workflows[1].versionId);
expect(workflow1?.activeVersionId).toBeNull();
expect(workflow2?.activeVersionId).toBeNull();
});
test('update:workflow can deactivate all workflows', async () => {
test('update:workflow can unpublish all workflows', async () => {
//
// ARRANGE
//
@@ -92,33 +82,29 @@ test('update:workflow can deactivate all workflows', async () => {
expect(workflow2?.activeVersion).toBeNull();
});
test('update:workflow can activate a specific workflow', async () => {
test('update:workflow publishes current version when --active=true (backwards compatibility)', async () => {
//
// ARRANGE
//
const workflows = (
await Promise.all([
createWorkflowWithTriggerAndHistory(),
createWorkflowWithTriggerAndHistory(),
])
).sort((wf1, wf2) => wf1.id.localeCompare(wf2.id));
const workflow = await createWorkflowWithTriggerAndHistory();
//
// ACT
//
await command.run([`--id=${workflows[0].id}`, '--active=true']);
await command.run([`--id=${workflow.id}`, '--active=true']);
//
// ASSERT
//
const after = (await getAllWorkflows()).sort((wf1, wf2) => wf1.id.localeCompare(wf2.id));
expect(after).toMatchObject([
{ activeVersionId: workflows[0].versionId },
{ activeVersionId: null },
]);
// Verify workflow was published with current version
const workflowRepo = Container.get(WorkflowRepository);
const updatedWorkflow = await workflowRepo.findOneBy({ id: workflow.id });
expect(updatedWorkflow?.activeVersionId).toBe(workflow.versionId);
expect(updatedWorkflow?.active).toBe(true);
});
test('update:workflow can deactivate a specific workflow', async () => {
test('update:workflow can unpublish a specific workflow', async () => {
//
// ARRANGE
//

View File

@@ -25,6 +25,7 @@ import { setupTestCommand } from '@test-integration/utils/test-command';
Container.get(ExecutionsConfig).mode = 'queue';
config.set('binaryDataManager.availableModes', 'filesystem');
Container.get(TaskRunnersConfig).enabled = true;
Container.get(TaskRunnersConfig).isNativePythonRunnerEnabled = false;
mockInstance(LoadNodesAndCredentials);
const binaryDataService = mockInstance(BinaryDataService);
const communityPackagesService = mockInstance(CommunityPackagesService);

View File

@@ -3,14 +3,16 @@ import {
createWorkflowWithHistory,
createActiveWorkflow,
createManyActiveWorkflows,
createWorkflowWithActiveVersion,
createWorkflow,
testDb,
getWorkflowById,
} from '@n8n/backend-test-utils';
import { GlobalConfig } from '@n8n/config';
import { WorkflowRepository, WorkflowDependencyRepository, WorkflowDependencies } from '@n8n/db';
import { Container } from '@n8n/di';
import { createWorkflowHistoryItem } from '@test-integration/db/workflow-history';
import { createTestRun } from '../../shared/db/evaluation';
describe('WorkflowRepository', () => {
@@ -26,73 +28,96 @@ describe('WorkflowRepository', () => {
await testDb.terminate();
});
describe('activateAll', () => {
it('should activate all workflows', async () => {
describe('publishVersion', () => {
it('should publish a specific workflow version', async () => {
//
// ARRANGE
//
const workflowRepository = Container.get(WorkflowRepository);
const workflows = await Promise.all([
createWorkflowWithTriggerAndHistory(),
createWorkflowWithTriggerAndHistory(),
]);
expect(workflows[0].activeVersionId).toBeNull();
expect(workflows[1].activeVersionId).toBeNull();
const workflow = await createWorkflowWithTriggerAndHistory();
const targetVersionId = 'custom-version-123';
await createWorkflowHistoryItem(workflow.id, { versionId: targetVersionId });
//
// ACT
//
await workflowRepository.activateAll();
await workflowRepository.publishVersion(workflow.id, targetVersionId);
//
// ASSERT
//
const workflow1 = await workflowRepository.findOne({
where: { id: workflows[0].id },
});
const workflow2 = await workflowRepository.findOne({
where: { id: workflows[1].id },
});
const updatedWorkflow = await getWorkflowById(workflow.id);
expect(workflow1?.activeVersionId).toBe(workflows[0].versionId);
expect(workflow2?.activeVersionId).toBe(workflows[1].versionId);
expect(updatedWorkflow?.activeVersionId).toBe(targetVersionId);
expect(updatedWorkflow?.active).toBe(true);
});
it('should not change activeVersionId for already-active workflows', async () => {
it('should update activeVersionId when publishing an already published workflow', async () => {
//
// ARRANGE
//
const workflowRepository = Container.get(WorkflowRepository);
const activeVersionId = 'old-active-version-id';
// Create workflow with different active and current versions
const workflow = await createWorkflowWithActiveVersion(activeVersionId, {});
const currentVersionId = workflow.versionId;
expect(workflow.active).toBe(true);
expect(workflow.activeVersionId).toBe(activeVersionId);
expect(workflow.versionId).toBe(currentVersionId);
const workflow = await createActiveWorkflow();
const newVersionId = 'new-version-id';
await createWorkflowHistoryItem(workflow.id, { versionId: newVersionId });
//
// ACT
//
await workflowRepository.activateAll();
await workflowRepository.publishVersion(workflow.id, newVersionId);
//
// ASSERT
//
// activeVersionId should remain unchanged
const after = await workflowRepository.findOne({
where: { id: workflow.id },
});
const updatedWorkflow = await getWorkflowById(workflow.id);
expect(after?.activeVersionId).toBe(activeVersionId); // Unchanged
expect(after?.versionId).toBe(currentVersionId);
expect(updatedWorkflow?.activeVersionId).toBe(newVersionId);
expect(updatedWorkflow?.active).toBe(true);
expect(updatedWorkflow?.versionId).toBe(workflow.versionId);
});
it('should throw error when version does not exist for workflow', async () => {
//
// ARRANGE
//
const workflowRepository = Container.get(WorkflowRepository);
const workflow = await createWorkflowWithTriggerAndHistory();
const nonExistentVersionId = 'non-existent-version';
//
// ACT & ASSERT
//
await expect(
workflowRepository.publishVersion(workflow.id, nonExistentVersionId),
).rejects.toThrow(
`Version "${nonExistentVersionId}" not found for workflow "${workflow.id}".`,
);
});
it('should publish current version when versionId is not provided', async () => {
//
// ARRANGE
//
const workflowRepository = Container.get(WorkflowRepository);
const workflow = await createWorkflowWithTriggerAndHistory();
//
// ACT
//
await workflowRepository.publishVersion(workflow.id);
//
// ASSERT
//
const updatedWorkflow = await getWorkflowById(workflow.id);
expect(updatedWorkflow?.activeVersionId).toBe(workflow.versionId);
expect(updatedWorkflow?.active).toBe(true);
});
});
describe('deactivateAll', () => {
it('should deactivate all workflows and clear activeVersionId', async () => {
describe('unpublishAll', () => {
it('should unpublish all workflows and clear activeVersionId', async () => {
//
// ARRANGE
//
@@ -106,7 +131,7 @@ describe('WorkflowRepository', () => {
//
// ACT
//
await workflowRepository.deactivateAll();
await workflowRepository.unpublishAll();
//
// ASSERT
//

View File

@@ -5,11 +5,13 @@ import {
testDb,
mockInstance,
createActiveWorkflow,
createWorkflowWithHistory,
} from '@n8n/backend-test-utils';
import { GlobalConfig } from '@n8n/config';
import type { Project, TagEntity, User, WorkflowHistory } from '@n8n/db';
import { ProjectRepository, WorkflowHistoryRepository, SharedWorkflowRepository } from '@n8n/db';
import { Container } from '@n8n/di';
import { Not } from '@n8n/typeorm';
import { InstanceSettings } from 'n8n-core';
import type { INode } from 'n8n-workflow';
import { v4 as uuid } from 'uuid';
@@ -97,9 +99,9 @@ describe('GET /workflows', () => {
test('should return all owned workflows', async () => {
await Promise.all([
createWorkflow({}, member),
createWorkflow({}, member),
createWorkflow({}, member),
createWorkflowWithHistory({}, member),
createWorkflowWithHistory({}, member),
createWorkflowWithHistory({}, member),
]);
const response = await authMemberAgent.get('/workflows');
@@ -139,9 +141,9 @@ describe('GET /workflows', () => {
test('should return all owned workflows with pagination', async () => {
await Promise.all([
createWorkflow({}, member),
createWorkflow({}, member),
createWorkflow({}, member),
createWorkflowWithHistory({}, member),
createWorkflowWithHistory({}, member),
createWorkflowWithHistory({}, member),
]);
const response = await authMemberAgent.get('/workflows?limit=1');
@@ -197,8 +199,8 @@ describe('GET /workflows', () => {
const tag = await createTag({});
const [workflow] = await Promise.all([
createWorkflow({ tags: [tag] }, member),
createWorkflow({}, member),
createWorkflowWithHistory({ tags: [tag] }, member),
createWorkflowWithHistory({}, member),
]);
const response = await authMemberAgent.get(`/workflows?tags=${tag.name}`);
@@ -240,11 +242,11 @@ describe('GET /workflows', () => {
const tagNames = tags.map((tag) => tag.name).join(',');
const [workflow1, workflow2] = await Promise.all([
createWorkflow({ tags }, member),
createWorkflow({ tags }, member),
createWorkflow({}, member),
createWorkflow({ tags: [tags[0]] }, member),
createWorkflow({ tags: [tags[1]] }, member),
createWorkflowWithHistory({ tags }, member),
createWorkflowWithHistory({ tags }, member),
createWorkflowWithHistory({}, member),
createWorkflowWithHistory({ tags: [tags[0]] }, member),
createWorkflowWithHistory({ tags: [tags[1]] }, member),
]);
const response = await authMemberAgent.get(`/workflows?tags=${tagNames}`);
@@ -296,8 +298,8 @@ describe('GET /workflows', () => {
});
await Promise.all([
createWorkflow({ name: 'First workflow' }, firstProject),
createWorkflow({ name: 'Second workflow' }, secondProject),
createWorkflowWithHistory({ name: 'First workflow' }, firstProject),
createWorkflowWithHistory({ name: 'Second workflow' }, secondProject),
]);
const firstResponse = await authOwnerAgent.get(`/workflows?projectId=${firstProject.id}`);
@@ -319,8 +321,8 @@ describe('GET /workflows', () => {
});
await Promise.all([
createWorkflow({}, member),
createWorkflow({ name: 'Other workflow' }, otherProject),
createWorkflowWithHistory({}, member),
createWorkflowWithHistory({ name: 'Other workflow' }, otherProject),
]);
const response = await authMemberAgent.get(`/workflows?projectId=${otherProject.id}`);
@@ -333,7 +335,10 @@ describe('GET /workflows', () => {
test('should return all owned workflows filtered by name', async () => {
const workflowName = 'Workflow 1';
await Promise.all([createWorkflow({ name: workflowName }, member), createWorkflow({}, member)]);
await Promise.all([
createWorkflowWithHistory({ name: workflowName }, member),
createWorkflowWithHistory({}, member),
]);
const response = await authMemberAgent.get(`/workflows?name=${workflowName}`);
@@ -369,11 +374,11 @@ describe('GET /workflows', () => {
test('should return all workflows for owner', async () => {
await Promise.all([
createWorkflow({}, owner),
createWorkflow({}, member),
createWorkflow({}, owner),
createWorkflow({}, member),
createWorkflow({}, owner),
createWorkflowWithHistory({}, owner),
createWorkflowWithHistory({}, member),
createWorkflowWithHistory({}, owner),
createWorkflowWithHistory({}, member),
createWorkflowWithHistory({}, owner),
]);
const response = await authOwnerAgent.get('/workflows');
@@ -413,7 +418,7 @@ describe('GET /workflows', () => {
test('should return all owned workflows without pinned data', async () => {
await Promise.all([
createWorkflow(
createWorkflowWithHistory(
{
pinData: {
Webhook1: [{ json: { first: 'first' } }],
@@ -421,7 +426,7 @@ describe('GET /workflows', () => {
},
member,
),
createWorkflow(
createWorkflowWithHistory(
{
pinData: {
Webhook2: [{ json: { second: 'second' } }],
@@ -429,7 +434,7 @@ describe('GET /workflows', () => {
},
member,
),
createWorkflow(
createWorkflowWithHistory(
{
pinData: {
Webhook3: [{ json: { third: 'third' } }],
@@ -453,7 +458,7 @@ describe('GET /workflows', () => {
});
test('should return activeVersion for all workflows', async () => {
const inactiveWorkflow = await createWorkflow({}, member);
const inactiveWorkflow = await createWorkflowWithHistory({}, member);
const activeWorkflow = await createWorkflowWithTriggerAndHistory({}, member);
await authMemberAgent.post(`/workflows/${activeWorkflow.id}/activate`);
@@ -485,7 +490,7 @@ describe('GET /workflows', () => {
});
test('should return activeVersion when filtering by active=true', async () => {
await createWorkflow({}, member);
await createWorkflowWithHistory({}, member);
const activeWorkflow = await createWorkflowWithTriggerAndHistory({}, member);
await authMemberAgent.post(`/workflows/${activeWorkflow.id}/activate`);
@@ -516,7 +521,7 @@ describe('GET /workflows/:id', () => {
test('should retrieve workflow', async () => {
// create and assign workflow to owner
const workflow = await createWorkflow({}, member);
const workflow = await createWorkflowWithHistory({}, member);
const response = await authMemberAgent.get(`/workflows/${workflow.id}`);
@@ -551,7 +556,7 @@ describe('GET /workflows/:id', () => {
test('should retrieve non-owned workflow for owner', async () => {
// create and assign workflow to owner
const workflow = await createWorkflow({}, member);
const workflow = await createWorkflowWithHistory({}, member);
const response = await authOwnerAgent.get(`/workflows/${workflow.id}`);
@@ -584,7 +589,7 @@ describe('GET /workflows/:id', () => {
test('should retrieve workflow without pinned data', async () => {
// create and assign workflow to owner
const workflow = await createWorkflow(
const workflow = await createWorkflowWithHistory(
{
pinData: {
Webhook1: [{ json: { first: 'first' } }],
@@ -603,7 +608,7 @@ describe('GET /workflows/:id', () => {
});
test('should return activeVersion as null for inactive workflow', async () => {
const workflow = await createWorkflow({}, member);
const workflow = await createWorkflowWithHistory({}, member);
const response = await authMemberAgent.get(`/workflows/${workflow.id}`);
@@ -732,7 +737,7 @@ describe('DELETE /workflows/:id', () => {
test('should delete the workflow', async () => {
// create and assign workflow to owner
const workflow = await createWorkflow({}, member);
const workflow = await createWorkflowWithHistory({}, member);
const response = await authMemberAgent.delete(`/workflows/${workflow.id}`);
@@ -772,7 +777,7 @@ describe('DELETE /workflows/:id', () => {
test('should delete non-owned workflow when owner', async () => {
// create and assign workflow to owner
const workflow = await createWorkflow({}, member);
const workflow = await createWorkflowWithHistory({}, member);
const response = await authMemberAgent.delete(`/workflows/${workflow.id}`);
@@ -825,13 +830,13 @@ describe('POST /workflows/:id/activate', () => {
});
test('should fail due to trying to activate a workflow without any nodes', async () => {
const workflow = await createWorkflow({ nodes: [] }, owner);
const workflow = await createWorkflowWithHistory({ nodes: [] }, owner);
const response = await authOwnerAgent.post(`/workflows/${workflow.id}/activate`);
expect(response.statusCode).toBe(400);
});
test('should fail due to trying to activate a workflow without a trigger', async () => {
const workflow = await createWorkflow(
const workflow = await createWorkflowWithHistory(
{
nodes: [
{
@@ -1341,7 +1346,7 @@ describe('PUT /workflows/:id', () => {
});
test('should update workflow', async () => {
const workflow = await createWorkflow({}, member);
const workflow = await createWorkflowWithHistory({}, member);
const payload = {
name: 'name updated',
nodes: [
@@ -1419,61 +1424,7 @@ describe('PUT /workflows/:id', () => {
);
});
test('should always create workflow history version', async () => {
const workflow = await createWorkflow({}, member);
const payload = {
name: 'name updated',
nodes: [
{
id: 'uuid-1234',
parameters: {},
name: 'Start',
type: 'n8n-nodes-base.start',
typeVersion: 1,
position: [240, 300],
},
{
id: 'uuid-1234',
parameters: {},
name: 'Cron',
type: 'n8n-nodes-base.cron',
typeVersion: 1,
position: [400, 300],
},
],
connections: {},
staticData: '{"id":1}',
settings: {
saveExecutionProgress: false,
saveManualExecutions: false,
saveDataErrorExecution: 'all',
saveDataSuccessExecution: 'all',
executionTimeout: 3600,
timezone: 'America/New_York',
},
};
const response = await authMemberAgent.put(`/workflows/${workflow.id}`).send(payload);
const { id } = response.body;
expect(response.statusCode).toBe(200);
expect(id).toBe(workflow.id);
expect(
await Container.get(WorkflowHistoryRepository).count({ where: { workflowId: id } }),
).toBe(1);
const historyVersion = await Container.get(WorkflowHistoryRepository).findOne({
where: {
workflowId: id,
},
});
expect(historyVersion).not.toBeNull();
expect(historyVersion!.connections).toEqual(payload.connections);
expect(historyVersion!.nodes).toEqual(payload.nodes);
});
test('should update activeVersionId when updating an active workflow', async () => {
test('should update active version if workflow is published', async () => {
const workflow = await createActiveWorkflow({}, member);
const updatedPayload = {
@@ -1498,64 +1449,24 @@ describe('PUT /workflows/:id', () => {
.send(updatedPayload);
expect(updateResponse.statusCode).toBe(200);
expect(updateResponse.body.active).toBe(true);
expect(updateResponse.body.activeVersionId).not.toBeNull();
expect(updateResponse.body.activeVersionId).not.toBe(workflow.versionId);
expect(updateResponse.body.nodes).toEqual(updatedPayload.nodes);
await authMemberAgent.post(`/workflows/${workflow.id}/activate`);
const sharedWorkflow = await Container.get(SharedWorkflowRepository).findOne({
const versionInTheDb = await Container.get(WorkflowHistoryRepository).findOne({
where: {
projectId: memberPersonalProject.id,
workflowId: workflow.id,
versionId: Not(workflow.versionId),
},
relations: ['workflow', 'workflow.activeVersion'],
});
expect(sharedWorkflow?.workflow.activeVersionId).toBe(sharedWorkflow?.workflow.versionId);
expect(sharedWorkflow?.workflow.activeVersionId).not.toBe(workflow.activeVersionId);
expect(sharedWorkflow?.workflow.activeVersion?.nodes).toEqual(updatedPayload.nodes);
expect(versionInTheDb).not.toBeNull();
expect(updateResponse.body.activeVersionId).toBe(versionInTheDb!.versionId);
expect(versionInTheDb!.nodes).toEqual(updatedPayload.nodes);
});
test('should not update activeVersionId when updating an inactive workflow', async () => {
const workflow = await createWorkflow({}, member);
// Update workflow without activating it
const updatedPayload = {
name: 'Updated inactive workflow',
nodes: [
{
id: 'uuid-inactive',
parameters: {},
name: 'Start Node',
type: 'n8n-nodes-base.start',
typeVersion: 1,
position: [200, 300],
},
],
connections: {},
staticData: workflow.staticData,
settings: workflow.settings,
};
const updateResponse = await authMemberAgent
.put(`/workflows/${workflow.id}`)
.send(updatedPayload);
expect(updateResponse.statusCode).toBe(200);
expect(updateResponse.body.active).toBe(false);
expect(updateResponse.body.activeVersionId).toBeNull();
// Verify activeVersion is still null
const sharedWorkflow = await Container.get(SharedWorkflowRepository).findOne({
where: {
projectId: memberPersonalProject.id,
workflowId: workflow.id,
},
relations: ['workflow'],
});
expect(sharedWorkflow?.workflow.activeVersionId).toBeNull();
});
test('should not allow setting active field via PUT request', async () => {
test('should not allow updating active field', async () => {
const workflow = await createWorkflowWithTriggerAndHistory({}, member);
const updatePayload = {
@@ -1587,7 +1498,7 @@ describe('PUT /workflows/:id', () => {
});
test('should update non-owned workflow if owner', async () => {
const workflow = await createWorkflow({}, member);
const workflow = await createWorkflowWithHistory({}, member);
const payload = {
name: 'name owner updated',
@@ -1677,7 +1588,7 @@ describe('PUT /workflows/:id', () => {
});
test('should merge settings when updating workflow', async () => {
const workflow = await createWorkflow(
const workflow = await createWorkflowWithHistory(
{
name: 'Test Workflow',
nodes: [],
@@ -1744,7 +1655,7 @@ describe('GET /workflows/:id/tags', () => {
test('should return all tags of owned workflow', async () => {
const tags = await Promise.all([await createTag({}), await createTag({})]);
const workflow = await createWorkflow({ tags }, member);
const workflow = await createWorkflowWithHistory({ tags }, member);
const response = await authMemberAgent.get(`/workflows/${workflow.id}/tags`);
@@ -1766,7 +1677,7 @@ describe('GET /workflows/:id/tags', () => {
});
test('should return empty array if workflow does not have tags', async () => {
const workflow = await createWorkflow({}, member);
const workflow = await createWorkflowWithHistory({}, member);
const response = await authMemberAgent.get(`/workflows/${workflow.id}/tags`);

View File

@@ -241,7 +241,10 @@ test('should report security settings', async () => {
templatesEnabled: true,
publicApiEnabled: false,
},
nodes: { nodesExclude: 'none', nodesInclude: 'none' },
nodes: {
nodesExclude: 'n8n-nodes-base.executeCommand, n8n-nodes-base.localFileTrigger',
nodesInclude: 'none',
},
telemetry: { diagnosticsEnabled: true },
});
});

View File

@@ -30,6 +30,7 @@ describe('JS TaskRunner execution on internal mode', () => {
runnerConfig.mode = 'internal';
runnerConfig.enabled = true;
runnerConfig.port = 45678;
runnerConfig.isNativePythonRunnerEnabled = false;
const taskRunnerModule = Container.get(TaskRunnerModule);
const taskRequester = Container.get(LocalTaskRequester);

View File

@@ -10,6 +10,7 @@ describe('TaskRunnerModule in internal mode', () => {
runnerConfig.port = 0; // Random port
runnerConfig.mode = 'internal';
runnerConfig.enabled = true;
runnerConfig.isNativePythonRunnerEnabled = false;
const module = Container.get(TaskRunnerModule);
afterEach(async () => {

View File

@@ -1,9 +1,4 @@
import {
createWorkflowWithHistory,
createActiveWorkflow,
testDb,
mockInstance,
} from '@n8n/backend-test-utils';
import { createWorkflowWithHistory, testDb, mockInstance } from '@n8n/backend-test-utils';
import { GlobalConfig } from '@n8n/config';
import {
SharedWorkflowRepository,
@@ -63,71 +58,9 @@ beforeAll(async () => {
afterEach(async () => {
await testDb.truncate(['WorkflowEntity', 'WorkflowHistory']);
jest.restoreAllMocks();
globalConfig.workflows.draftPublishEnabled = false;
});
describe('update()', () => {
test('should remove and re-add to active workflows on `active: true` payload', async () => {
const owner = await createOwner();
const workflow = await createActiveWorkflow({}, owner);
const addRecordSpy = jest.spyOn(workflowPublishHistoryRepository, 'addRecord');
const removeSpy = jest.spyOn(activeWorkflowManager, 'remove');
const addSpy = jest.spyOn(activeWorkflowManager, 'add');
const updateData = {
active: true,
versionId: workflow.versionId,
};
await workflowService.update(owner, updateData as WorkflowEntity, workflow.id);
expect(removeSpy).toHaveBeenCalledTimes(1);
const [removedWorkflowId] = removeSpy.mock.calls[0];
expect(removedWorkflowId).toBe(workflow.id);
expect(addSpy).toHaveBeenCalledTimes(1);
const [addedWorkflowId, activationMode] = addSpy.mock.calls[0];
expect(addedWorkflowId).toBe(workflow.id);
expect(activationMode).toBe('update');
expect(addRecordSpy).toBeCalledWith({
event: 'activated',
workflowId: workflow.id,
versionId: workflow.versionId,
userId: owner.id,
});
});
test('should remove from active workflows on `active: false` payload', async () => {
const owner = await createOwner();
const workflow = await createActiveWorkflow({}, owner);
const addRecordSpy = jest.spyOn(workflowPublishHistoryRepository, 'addRecord');
const removeSpy = jest.spyOn(activeWorkflowManager, 'remove');
const addSpy = jest.spyOn(activeWorkflowManager, 'add');
const updateData = {
active: false,
versionId: workflow.versionId,
};
await workflowService.update(owner, updateData as WorkflowEntity, workflow.id);
expect(removeSpy).toHaveBeenCalledTimes(1);
const [removedWorkflowId] = removeSpy.mock.calls[0];
expect(removedWorkflowId).toBe(workflow.id);
expect(addSpy).not.toHaveBeenCalled();
expect(addRecordSpy).toBeCalledWith({
event: 'deactivated',
workflowId: workflow.id,
versionId: workflow.versionId,
userId: owner.id,
});
});
test('should fetch missing connections from DB when updating nodes', async () => {
const owner = await createOwner();
const workflow = await createWorkflowWithHistory({}, owner);
@@ -157,29 +90,6 @@ describe('update()', () => {
expect(updatedWorkflow.versionId).not.toBe(workflow.versionId);
});
test('should not save workflow history version when updating only active status', async () => {
const owner = await createOwner();
const workflow = await createWorkflowWithHistory({}, owner);
const addRecordSpy = jest.spyOn(workflowPublishHistoryRepository, 'addRecord');
const saveVersionSpy = jest.spyOn(workflowHistoryService, 'saveVersion');
const updateData = {
active: true,
versionId: workflow.versionId,
};
await workflowService.update(owner, updateData as WorkflowEntity, workflow.id);
expect(saveVersionSpy).not.toHaveBeenCalled();
expect(addRecordSpy).toBeCalledWith({
event: 'activated',
workflowId: workflow.id,
versionId: workflow.versionId,
userId: owner.id,
});
});
test('should save workflow history version with backfilled data when versionId changes', async () => {
const owner = await createOwner();
const workflow = await createWorkflowWithHistory({}, owner);
@@ -209,7 +119,7 @@ describe('update()', () => {
});
describe('activateWorkflow()', () => {
test('should activate current workflow version', async () => {
test('should activate current workflow version if no version provided', async () => {
const owner = await createOwner();
const workflow = await createWorkflowWithHistory({}, owner);
@@ -227,34 +137,7 @@ describe('activateWorkflow()', () => {
});
});
test('should ignore provided workflow versionId', async () => {
const owner = await createOwner();
const workflow = await createWorkflowWithHistory({}, owner);
const addRecordSpy = jest.spyOn(workflowPublishHistoryRepository, 'addRecord');
const newVersionId = uuid();
await createWorkflowHistoryItem(workflow.id, { versionId: newVersionId });
const updatedWorkflow = await workflowService.activateWorkflow(owner, workflow.id, {
versionId: newVersionId,
});
expect(updatedWorkflow.active).toBe(true);
expect(updatedWorkflow.activeVersionId).toBe(workflow.versionId);
expect(updatedWorkflow.versionId).toBe(workflow.versionId);
expect(addRecordSpy).toBeCalledWith({
event: 'activated',
workflowId: workflow.id,
versionId: workflow.versionId,
userId: owner.id,
});
});
test('with draft/publish enabled: should activate the provided workflow version', async () => {
globalConfig.workflows.draftPublishEnabled = true;
test('should activate the provided workflow version', async () => {
const owner = await createOwner();
const workflow = await createWorkflowWithHistory({}, owner);

View File

@@ -1419,53 +1419,6 @@ describe('PATCH /workflows/:workflowId', () => {
expect(historyVersion!.nodes).toEqual(payload.nodes);
});
});
describe('activate workflow', () => {
test('should activate workflow without changing version ID', async () => {
const workflow = await createWorkflowWithHistory({}, owner);
const payload = {
versionId: workflow.versionId,
active: true,
};
const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload);
expect(response.statusCode).toBe(200);
expect(activeWorkflowManager.add).toBeCalled();
const {
data: { id, versionId, active, activeVersionId },
} = response.body;
expect(id).toBe(workflow.id);
expect(versionId).toBe(workflow.versionId);
expect(active).toBe(true);
expect(activeVersionId).toBe(workflow.versionId);
});
test('should deactivate workflow without changing version ID', async () => {
const workflow = await createActiveWorkflow({}, owner);
const payload = {
versionId: workflow.versionId,
active: false,
};
const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload);
expect(response.statusCode).toBe(200);
expect(activeWorkflowManager.add).not.toBeCalled();
expect(activeWorkflowManager.remove).toBeCalled();
const {
data: { id, versionId, active, activeVersionId },
} = response.body;
expect(id).toBe(workflow.id);
expect(versionId).toBe(workflow.versionId);
expect(active).toBe(false);
expect(activeVersionId).toBeNull();
});
});
});
describe('PUT /:workflowId/transfer', () => {

View File

@@ -6,14 +6,12 @@ import {
createActiveWorkflow,
setActiveVersion,
createWorkflowWithHistory,
createWorkflowWithTriggerAndHistory,
shareWorkflowWithProjects,
shareWorkflowWithUsers,
randomCredentialPayload,
testDb,
mockInstance,
} from '@n8n/backend-test-utils';
import { GlobalConfig } from '@n8n/config';
import type {
User,
ListQueryDb,
@@ -74,7 +72,6 @@ let projectRepository: ProjectRepository;
let workflowRepository: WorkflowRepository;
let workflowHistoryRepository: WorkflowHistoryRepository;
let eventService: EventService;
let globalConfig: GlobalConfig;
let folderListMissingRole: Role;
let workflowPublishHistoryRepository: WorkflowPublishHistoryRepository;
@@ -94,7 +91,6 @@ beforeEach(async () => {
workflowRepository = Container.get(WorkflowRepository);
workflowHistoryRepository = Container.get(WorkflowHistoryRepository);
eventService = Container.get(EventService);
globalConfig = Container.get(GlobalConfig);
workflowPublishHistoryRepository = Container.get(WorkflowPublishHistoryRepository);
owner = await createOwner();
authOwnerAgent = testServer.authAgentFor(owner);
@@ -107,9 +103,6 @@ beforeEach(async () => {
displayName: 'Workflow Read-Only',
description: 'Can only read and list workflows',
});
// Default: draft/publish feature disabled
globalConfig.workflows.draftPublishEnabled = false;
});
afterEach(() => {
@@ -1514,7 +1507,10 @@ describe('GET /workflows', () => {
});
test('should handle skip with take parameter', async () => {
const response = await authOwnerAgent.get('/workflows').query('skip=2&take=2').expect(200);
const response = await authOwnerAgent
.get('/workflows')
.query('skip=2&take=2&sortBy=name:asc')
.expect(200);
const body = response.body as { data: WorkflowEntity[]; count: number };
expect(body.count).toBe(5);
@@ -2372,7 +2368,7 @@ describe('GET /workflows?includeFolders=true', () => {
test('should handle skip with take parameter', async () => {
const response = await authOwnerAgent
.get('/workflows')
.query('skip=2&take=4&includeFolders=true');
.query('skip=2&take=4&includeFolders=true&sortBy=name:asc');
const body = response.body as { data: WorkflowEntity[]; count: number };
expect(body.count).toBe(6);
@@ -2519,180 +2515,27 @@ describe('PATCH /workflows/:workflowId', () => {
expect(versionCounter).toBe(workflow.versionCounter + 1);
});
test('should activate workflow without changing version ID', async () => {
const addRecordSpy = jest.spyOn(workflowPublishHistoryRepository, 'addRecord');
const workflow = await createWorkflowWithHistory({}, owner);
const payload = {
versionId: workflow.versionId,
active: true,
};
const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload);
expect(response.statusCode).toBe(200);
expect(activeWorkflowManagerLike.add).toBeCalled();
const {
data: { id, versionId, active, activeVersionId },
} = response.body;
expect(id).toBe(workflow.id);
expect(versionId).toBe(workflow.versionId);
expect(active).toBe(true);
expect(activeVersionId).toBe(workflow.versionId);
expect(addRecordSpy).toBeCalledWith({
event: 'activated',
userId: owner.id,
versionId: workflow.versionId,
workflowId: workflow.id,
});
});
test('should deactivate workflow without changing version ID', async () => {
test('should update workflow without updating its active version', async () => {
const addRecordSpy = jest.spyOn(workflowPublishHistoryRepository, 'addRecord');
const workflow = await createActiveWorkflow({}, owner);
await setActiveVersion(workflow.id, workflow.versionId);
const payload = {
name: 'Updated Workflow',
versionId: workflow.versionId,
active: false,
};
const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload);
expect(response.statusCode).toBe(200);
expect(activeWorkflowManagerLike.remove).not.toBeCalled();
expect(activeWorkflowManagerLike.add).not.toBeCalled();
expect(activeWorkflowManagerLike.remove).toBeCalled();
expect(addRecordSpy).not.toBeCalled();
const {
data: { id, versionId, active, activeVersionId },
} = response.body;
expect(id).toBe(workflow.id);
expect(versionId).toBe(workflow.versionId);
expect(active).toBe(false);
expect(activeVersionId).toBeNull();
expect(addRecordSpy).toBeCalledWith({
event: 'deactivated',
userId: owner.id,
versionId: workflow.versionId,
workflowId: workflow.id,
});
});
test('should set activeVersionId when activating via PATCH', async () => {
const workflow = await createWorkflowWithTriggerAndHistory({}, owner);
const payload = {
versionId: workflow.versionId,
active: true,
};
const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload);
expect(response.statusCode).toBe(200);
expect(activeWorkflowManagerLike.add).toBeCalled();
const {
data: { id, activeVersionId },
} = response.body;
expect(id).toBe(workflow.id);
expect(activeVersionId).toBe(workflow.versionId);
// Verify activeVersion is set
const updatedWorkflow = await workflowRepository.findOne({
where: { id: workflow.id },
relations: ['activeVersion'],
});
expect(updatedWorkflow?.activeVersionId).toBe(workflow.versionId);
expect(updatedWorkflow?.activeVersion).not.toBeNull();
expect(updatedWorkflow?.activeVersion?.versionId).toBe(workflow.versionId);
expect(updatedWorkflow?.activeVersion?.nodes).toEqual(workflow.nodes);
expect(updatedWorkflow?.activeVersion?.connections).toEqual(workflow.connections);
});
test('should clear activeVersionId when deactivating via PATCH', async () => {
const workflow = await createActiveWorkflow({}, owner);
await setActiveVersion(workflow.id, workflow.versionId);
const payload = {
versionId: workflow.versionId,
active: false,
};
const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload);
expect(response.statusCode).toBe(200);
expect(activeWorkflowManagerLike.remove).toBeCalled();
const {
data: { id, activeVersionId },
} = response.body;
expect(id).toBe(workflow.id);
expect(activeVersionId).toBeNull();
// Verify activeVersion is cleared
const updatedWorkflow = await workflowRepository.findOne({
where: { id: workflow.id },
});
expect(updatedWorkflow?.activeVersionId).toBeNull();
});
test('should update activeVersionId when updating an active workflow', async () => {
const workflow = await createActiveWorkflow({}, owner);
await setActiveVersion(workflow.id, workflow.versionId);
// Verify initial state
const initialWorkflow = await workflowRepository.findOne({
where: { id: workflow.id },
relations: ['activeVersion'],
});
expect(initialWorkflow?.activeVersion?.versionId).toBe(workflow.versionId);
// Update workflow nodes
const updatedNodes: INode[] = [
{
id: 'uuid-updated',
parameters: { triggerTimes: { item: [{ mode: 'everyHour' }] } },
name: 'Cron Updated',
type: 'n8n-nodes-base.cron',
typeVersion: 1,
position: [500, 400],
},
];
const payload = {
versionId: workflow.versionId,
nodes: updatedNodes,
connections: {},
};
const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload);
expect(response.statusCode).toBe(200);
const {
data: { id, versionId: newVersionId },
} = response.body;
expect(id).toBe(workflow.id);
expect(newVersionId).not.toBe(workflow.versionId);
// Verify activeVersion points to the new version
const updatedWorkflow = await workflowRepository.findOne({
where: { id: workflow.id },
relations: ['activeVersion'],
});
expect(updatedWorkflow?.active).toBe(true);
expect(updatedWorkflow?.activeVersionId).not.toBeNull();
expect(updatedWorkflow?.activeVersion?.versionId).toBe(newVersionId);
expect(updatedWorkflow?.activeVersion?.nodes).toEqual(updatedNodes);
const { data } = response.body;
expect(data.name).toBe('Updated Workflow');
expect(data.versionId).not.toBe(workflow.versionId); // New version created
expect(data.activeVersionId).toBe(workflow.versionId); // Should remain active
});
test('should update workflow meta', async () => {
@@ -2791,127 +2634,106 @@ describe('PATCH /workflows/:workflowId', () => {
expect(response.statusCode).toBe(500);
});
describe('with draft/publish feature enabled', () => {
beforeEach(() => {
globalConfig.workflows.draftPublishEnabled = true;
test('should not activate when updating with active: true', async () => {
const workflow = await createWorkflow({}, owner);
const payload = {
versionId: workflow.versionId,
active: true,
};
const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload);
expect(response.statusCode).toBe(200);
expect(activeWorkflowManagerLike.add).not.toBeCalled();
const { data } = response.body;
expect(data.active).toBe(false);
expect(data.activeVersionId).toBeNull();
});
test('should not deactivate workflow when updating with active: false', async () => {
const addRecordSpy = jest.spyOn(workflowPublishHistoryRepository, 'addRecord');
const workflow = await createActiveWorkflow({}, owner);
await setActiveVersion(workflow.id, workflow.versionId);
const payload = {
versionId: workflow.versionId,
active: false,
};
const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload);
expect(response.statusCode).toBe(200);
expect(activeWorkflowManagerLike.remove).not.toBeCalled();
expect(addRecordSpy).not.toBeCalled();
const { data } = response.body;
expect(data.active).toBe(true);
expect(data.activeVersionId).toBe(workflow.versionId);
});
test('should not modify activeVersionId when explicitly provided', async () => {
const workflow = await createWorkflowWithHistory({}, owner);
const payload = {
versionId: workflow.versionId,
activeVersionId: workflow.versionId,
};
const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload);
expect(response.statusCode).toBe(200);
expect(activeWorkflowManagerLike.add).not.toBeCalled();
const { data } = response.body;
expect(data.activeVersionId).toBeNull(); // Should not be activated
});
test('should reactivate workflow when settings change', async () => {
const workflow = await createActiveWorkflow({}, owner);
await setActiveVersion(workflow.id, workflow.versionId);
const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send({
versionId: workflow.versionId,
settings: {
timezone: 'America/New_York',
},
});
afterEach(() => {
globalConfig.workflows.draftPublishEnabled = false;
expect(response.statusCode).toBe(200);
expect(activeWorkflowManagerLike.remove).toHaveBeenCalledWith(workflow.id);
expect(activeWorkflowManagerLike.add).toHaveBeenCalledWith(workflow.id, 'update');
});
test('should not reactivate when settings unchanged', async () => {
const workflow = await createActiveWorkflow({}, owner);
await setActiveVersion(workflow.id, workflow.versionId);
const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send({
versionId: workflow.versionId,
name: 'New Name',
});
test('should not update activeVersionId when updating with active: true', async () => {
const workflow = await createWorkflow({}, owner);
const payload = {
versionId: workflow.versionId,
active: true,
};
expect(response.statusCode).toBe(200);
const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload);
expect(activeWorkflowManagerLike.remove).not.toHaveBeenCalled();
expect(activeWorkflowManagerLike.add).not.toHaveBeenCalled();
});
expect(response.statusCode).toBe(200);
expect(activeWorkflowManagerLike.add).not.toBeCalled();
test('should not reactivate inactive workflow even when settings change', async () => {
const workflow = await createWorkflow({}, owner);
const { data } = response.body;
expect(data.activeVersionId).toBeNull();
const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send({
versionId: workflow.versionId,
settings: {
timezone: 'America/New_York',
},
});
test('should not deactivate workflow when updating with active: false', async () => {
const addRecordSpy = jest.spyOn(workflowPublishHistoryRepository, 'addRecord');
const workflow = await createActiveWorkflow({}, owner);
await setActiveVersion(workflow.id, workflow.versionId);
expect(response.statusCode).toBe(200);
const payload = {
versionId: workflow.versionId,
active: false,
};
const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload);
expect(response.statusCode).toBe(200);
expect(activeWorkflowManagerLike.remove).not.toBeCalled();
const { data } = response.body;
expect(data.activeVersionId).toBe(workflow.versionId);
expect(addRecordSpy).not.toBeCalled();
});
test('should NOT write "active" field to database when updating with active: true', async () => {
const workflow = await createWorkflowWithHistory({}, owner);
const payload = {
versionId: workflow.versionId,
active: true,
};
const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload);
expect(response.statusCode).toBe(200);
const updatedWorkflow = await workflowRepository.findOne({
where: { id: workflow.id },
});
expect(updatedWorkflow?.active).toBe(false);
expect(updatedWorkflow?.activeVersionId).toBeNull();
});
test('should NOT write "active" field to database when updating with active: false', async () => {
const workflow = await createActiveWorkflow({}, owner);
const payload = {
versionId: workflow.versionId,
active: false,
};
const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload);
expect(response.statusCode).toBe(200);
const updatedWorkflow = await workflowRepository.findOne({
where: { id: workflow.id },
});
expect(updatedWorkflow?.active).toBe(true);
expect(updatedWorkflow?.activeVersionId).toBe(workflow.activeVersionId);
});
test('should not modify activeVersionId when explicitly provided', async () => {
const workflow = await createWorkflowWithHistory({}, owner);
const payload = {
versionId: workflow.versionId,
activeVersionId: workflow.versionId,
};
const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload);
expect(response.statusCode).toBe(200);
expect(activeWorkflowManagerLike.add).not.toBeCalled();
const { data } = response.body;
expect(data.activeVersionId).toBeNull(); // Should not be activated
});
test('should allow updating active workflow without updating its active version', async () => {
const addRecordSpy = jest.spyOn(workflowPublishHistoryRepository, 'addRecord');
const workflow = await createActiveWorkflow({}, owner);
await setActiveVersion(workflow.id, workflow.versionId);
const payload = {
name: 'Updated Active Workflow',
versionId: workflow.versionId,
};
const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload);
expect(response.statusCode).toBe(200);
expect(activeWorkflowManagerLike.remove).not.toBeCalled();
expect(activeWorkflowManagerLike.add).not.toBeCalled();
const { data } = response.body;
expect(data.name).toBe('Updated Active Workflow');
expect(data.versionId).not.toBe(workflow.versionId); // New version created
expect(data.activeVersionId).toBe(workflow.versionId); // Should remain active
expect(addRecordSpy).not.toBeCalled();
});
expect(activeWorkflowManagerLike.remove).not.toHaveBeenCalled();
expect(activeWorkflowManagerLike.add).not.toHaveBeenCalled();
});
});
@@ -2919,22 +2741,25 @@ describe('POST /workflows/:workflowId/activate', () => {
test('should activate workflow with provided versionId', async () => {
const addRecordSpy = jest.spyOn(workflowPublishHistoryRepository, 'addRecord');
const workflow = await createWorkflowWithHistory({}, owner);
const newVersionId = uuid();
await createWorkflowHistoryItem(workflow.id, { versionId: newVersionId });
const response = await authOwnerAgent
.post(`/workflows/${workflow.id}/activate`)
.send({ versionId: workflow.versionId });
.send({ versionId: newVersionId });
expect(response.statusCode).toBe(200);
expect(activeWorkflowManagerLike.add).toBeCalledWith(workflow.id, 'activate');
const { data } = response.body;
expect(data.id).toBe(workflow.id);
expect(data.activeVersionId).toBe(workflow.versionId);
expect(data.activeVersion.versionId).toBe(workflow.versionId);
expect(data.activeVersionId).toBe(newVersionId);
expect(data.activeVersion.versionId).toBe(newVersionId);
expect(addRecordSpy).toBeCalledWith({
event: 'activated',
userId: owner.id,
versionId: workflow.versionId,
versionId: newVersionId,
workflowId: workflow.id,
});
});
@@ -2967,6 +2792,19 @@ describe('POST /workflows/:workflowId/activate', () => {
expect(response.statusCode).toBe(404);
});
test('should return 404 if version does not exist', async () => {
const workflow = await createWorkflowWithHistory({}, owner);
const newVersionId = uuid();
const response = await authOwnerAgent
.post(`/workflows/${workflow.id}/activate`)
.send({ versionId: newVersionId });
expect(response.statusCode).toBe(404);
expect(response.body.message).toBe('Version not found');
expect(activeWorkflowManagerLike.add).not.toBeCalled();
});
test('should return 403 when user does not have update permission', async () => {
const workflow = await createWorkflow({}, owner);
@@ -2994,175 +2832,16 @@ describe('POST /workflows/:workflowId/activate', () => {
expect(updatedWorkflow?.activeVersion?.versionId).toBe(workflow.versionId);
});
describe('with draft/publish feature enabled', () => {
beforeEach(() => {
globalConfig.workflows.draftPublishEnabled = true;
});
afterEach(() => {
globalConfig.workflows.draftPublishEnabled = false;
});
test('should activate workflow with provided versionId', async () => {
const workflow = await createWorkflowWithHistory({}, owner);
const newVersionId = uuid();
await createWorkflowHistoryItem(workflow.id, { versionId: newVersionId });
const response = await authOwnerAgent
.post(`/workflows/${workflow.id}/activate`)
.send({ versionId: newVersionId });
expect(response.statusCode).toBe(200);
expect(activeWorkflowManagerLike.add).toBeCalledWith(workflow.id, 'activate');
const { data } = response.body;
expect(data.id).toBe(workflow.id);
expect(data.activeVersionId).toBe(newVersionId);
expect(data.activeVersion.versionId).toBe(newVersionId);
});
test('should update version name when provided during activation', async () => {
const workflow = await createWorkflowWithHistory({}, owner);
const newVersionName = 'Production Version';
const response = await authOwnerAgent
.post(`/workflows/${workflow.id}/activate`)
.send({ versionId: workflow.versionId, name: newVersionName });
expect(response.statusCode).toBe(200);
expect(activeWorkflowManagerLike.add).toBeCalledWith(workflow.id, 'activate');
const { data } = response.body;
expect(data.activeVersionId).toBe(workflow.versionId);
const historyVersion = await workflowHistoryRepository.findOne({
where: { workflowId: workflow.id, versionId: workflow.versionId },
});
expect(historyVersion?.name).toBe(newVersionName);
});
test('should update version description when provided during activation', async () => {
const workflow = await createWorkflowWithHistory({}, owner);
const newDescription = 'This is the stable production release';
const response = await authOwnerAgent
.post(`/workflows/${workflow.id}/activate`)
.send({ versionId: workflow.versionId, description: newDescription });
expect(response.statusCode).toBe(200);
expect(activeWorkflowManagerLike.add).toBeCalledWith(workflow.id, 'activate');
const { data } = response.body;
expect(data.activeVersionId).toBe(workflow.versionId);
const historyVersion = await workflowHistoryRepository.findOne({
where: { workflowId: workflow.id, versionId: workflow.versionId },
});
expect(historyVersion?.description).toBe(newDescription);
});
test('should update both version name and description when provided during activation', async () => {
const workflow = await createWorkflowWithHistory({}, owner);
const newVersionName = 'Production Version';
const newDescription = 'Major update with new features';
const response = await authOwnerAgent.post(`/workflows/${workflow.id}/activate`).send({
versionId: workflow.versionId,
name: newVersionName,
description: newDescription,
});
expect(response.statusCode).toBe(200);
expect(activeWorkflowManagerLike.add).toBeCalledWith(workflow.id, 'activate');
const { data } = response.body;
expect(data.activeVersionId).toBe(workflow.versionId);
const historyVersion = await workflowHistoryRepository.findOne({
where: { workflowId: workflow.id, versionId: workflow.versionId },
});
expect(historyVersion?.name).toBe(newVersionName);
expect(historyVersion?.description).toBe(newDescription);
});
test('should not update version name and description when activation fails', async () => {
const workflow = await createWorkflowWithHistory({}, owner);
const newVersionName = 'Production Version';
const newDescription = 'Major update with new features';
activeWorkflowManagerLike.add.mockRejectedValueOnce(new Error('Validation failed'));
await authOwnerAgent.post(`/workflows/${workflow.id}/activate`).send({
versionId: workflow.versionId,
name: newVersionName,
description: newDescription,
});
const updatedVersion = await workflowHistoryRepository.findOne({
where: { versionId: workflow.versionId },
});
expect(updatedVersion?.name).toBeNull();
expect(updatedVersion?.description).toBeNull();
});
test('should preserve current active version when activation fails', async () => {
const workflow = await createActiveWorkflow({}, owner);
const newVersionId = uuid();
await createWorkflowHistoryItem(workflow.id, { versionId: newVersionId });
const emitSpy = jest.spyOn(eventService, 'emit');
activeWorkflowManagerLike.add.mockRejectedValueOnce(new Error('Validation failed'));
const response = await authOwnerAgent
.post(`/workflows/${workflow.id}/activate`)
.send({ versionId: newVersionId });
expect(response.statusCode).toBe(400);
expect(response.body.message).toBe('Validation failed');
const updatedWorkflow = await workflowRepository.findOne({
where: { id: workflow.id },
relations: ['activeVersion'],
});
expect(updatedWorkflow?.active).toBe(true);
expect(updatedWorkflow?.activeVersionId).toBe(workflow.versionId);
expect(updatedWorkflow?.activeVersion?.versionId).toBe(workflow.versionId);
expect(emitSpy).not.toHaveBeenCalledWith('workflow-deactivated', expect.anything());
});
test('should call active workflow manager with update mode if workflow is active', async () => {
const workflow = await createActiveWorkflow({}, owner);
const newVersionId = uuid();
await createWorkflowHistoryItem(workflow.id, { versionId: newVersionId });
await authOwnerAgent
.post(`/workflows/${workflow.id}/activate`)
.send({ versionId: newVersionId });
expect(activeWorkflowManagerLike.add).toBeCalledWith(workflow.id, 'update');
});
});
test('should ignore version id, name and description', async () => {
test('should update version name when provided during activation', async () => {
const workflow = await createWorkflowWithHistory({}, owner);
const newVersionId = uuid();
const newVersionName = 'Production Version';
const newDescription = 'Major update with new features';
const response = await authOwnerAgent.post(`/workflows/${workflow.id}/activate`).send({
versionId: newVersionId,
name: newVersionName,
description: newDescription,
});
const response = await authOwnerAgent
.post(`/workflows/${workflow.id}/activate`)
.send({ versionId: workflow.versionId, name: newVersionName });
expect(response.statusCode).toBe(200);
expect(activeWorkflowManagerLike.add).toBeCalledWith(workflow.id, 'activate');
const { data } = response.body;
expect(data.activeVersionId).toBe(workflow.versionId);
@@ -3171,8 +2850,123 @@ describe('POST /workflows/:workflowId/activate', () => {
where: { workflowId: workflow.id, versionId: workflow.versionId },
});
expect(historyVersion?.name).toBeNull();
expect(historyVersion?.description).toBeNull();
expect(historyVersion?.name).toBe(newVersionName);
});
test('should update version description when provided during activation', async () => {
const workflow = await createWorkflowWithHistory({}, owner);
const newDescription = 'This is the stable production release';
const response = await authOwnerAgent
.post(`/workflows/${workflow.id}/activate`)
.send({ versionId: workflow.versionId, description: newDescription });
expect(response.statusCode).toBe(200);
expect(activeWorkflowManagerLike.add).toBeCalledWith(workflow.id, 'activate');
const { data } = response.body;
expect(data.activeVersionId).toBe(workflow.versionId);
const historyVersion = await workflowHistoryRepository.findOne({
where: { workflowId: workflow.id, versionId: workflow.versionId },
});
expect(historyVersion?.description).toBe(newDescription);
});
test('should update both version name and description when provided during activation', async () => {
const workflow = await createWorkflowWithHistory({}, owner);
const newVersionName = 'Production Version';
const newDescription = 'Major update with new features';
const response = await authOwnerAgent.post(`/workflows/${workflow.id}/activate`).send({
versionId: workflow.versionId,
name: newVersionName,
description: newDescription,
});
expect(response.statusCode).toBe(200);
expect(activeWorkflowManagerLike.add).toBeCalledWith(workflow.id, 'activate');
const { data } = response.body;
expect(data.activeVersionId).toBe(workflow.versionId);
const historyVersion = await workflowHistoryRepository.findOne({
where: { workflowId: workflow.id, versionId: workflow.versionId },
});
expect(historyVersion?.name).toBe(newVersionName);
expect(historyVersion?.description).toBe(newDescription);
});
test('should not update version name and description when activation fails', async () => {
const workflow = await createWorkflowWithHistory({}, owner);
const newVersionName = 'Production Version';
const newDescription = 'Major update with new features';
activeWorkflowManagerLike.add.mockRejectedValueOnce(new Error('Validation failed'));
await authOwnerAgent.post(`/workflows/${workflow.id}/activate`).send({
versionId: workflow.versionId,
name: newVersionName,
description: newDescription,
});
const updatedVersion = await workflowHistoryRepository.findOne({
where: { versionId: workflow.versionId },
});
expect(updatedVersion?.name).toBeNull();
expect(updatedVersion?.description).toBeNull();
});
test('should deactivate workflow when activation fails', async () => {
const workflow = await createActiveWorkflow({}, owner);
const newVersionId = uuid();
await createWorkflowHistoryItem(workflow.id, { versionId: newVersionId });
const emitSpy = jest.spyOn(eventService, 'emit');
activeWorkflowManagerLike.add.mockRejectedValueOnce(new Error('Validation failed'));
const response = await authOwnerAgent
.post(`/workflows/${workflow.id}/activate`)
.send({ versionId: newVersionId });
expect(response.statusCode).toBe(400);
expect(response.body.message).toBe('Validation failed');
const updatedWorkflow = await workflowRepository.findOne({
where: { id: workflow.id },
relations: ['activeVersion'],
});
// Workflow should be deactivated after failed activation
expect(updatedWorkflow?.active).toBe(false);
expect(updatedWorkflow?.activeVersionId).toBeNull();
// Should emit deactivation event
expect(emitSpy).toHaveBeenCalledWith('workflow-deactivated', expect.anything());
// Verify workflow was removed once (no re-add)
expect(activeWorkflowManagerLike.remove).toHaveBeenCalledWith(workflow.id);
expect(activeWorkflowManagerLike.add).toHaveBeenCalledTimes(1);
});
test('should call active workflow manager with update mode if workflow is active', async () => {
const workflow = await createActiveWorkflow({}, owner);
const newVersionId = uuid();
await createWorkflowHistoryItem(workflow.id, { versionId: newVersionId });
await authOwnerAgent
.post(`/workflows/${workflow.id}/activate`)
.send({ versionId: newVersionId });
// First remove active version
expect(activeWorkflowManagerLike.remove).toBeCalledWith(workflow.id);
expect(activeWorkflowManagerLike.add).toBeCalledWith(workflow.id, 'update');
});
test('should call active workflow manager with activate mode if workflow is not active', async () => {
@@ -3183,6 +2977,7 @@ describe('POST /workflows/:workflowId/activate', () => {
.post(`/workflows/${workflow.id}/activate`)
.send({ versionId: workflow.versionId });
expect(activeWorkflowManagerLike.remove).not.toBeCalledWith(workflow.id);
expect(activeWorkflowManagerLike.add).toBeCalledWith(workflow.id, 'activate');
expect(addRecordSpy).toBeCalledWith({
event: 'activated',
@@ -3569,8 +3364,8 @@ describe('POST /workflows/:workflowId/unarchive', () => {
const { data: unarchivedWorkflow } = unarchiveResponse.body;
const activateResponse = await authOwnerAgent
.patch(`/workflows/${workflow.id}`)
.send({ active: true, versionId: unarchivedWorkflow.versionId })
.post(`/workflows/${workflow.id}/activate`)
.send({ versionId: unarchivedWorkflow.versionId })
.expect(200);
expect(activateResponse.body.data.active).toBe(true);

View File

@@ -10,7 +10,7 @@ mkdirSync(baseDir, { recursive: true });
const testDir = mkdtempSync(baseDir);
mkdirSync(join(testDir, '.n8n'));
process.env.N8N_USER_FOLDER = testDir;
process.env.N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS = 'false';
process.env.N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS = 'true';
writeFileSync(
join(testDir, '.n8n/config'),

View File

@@ -23,13 +23,12 @@ describe('BinaryDataConfig', () => {
it('should use default values when no env variables are defined', () => {
const config = Container.get(BinaryDataConfig);
expect(config.availableModes).toEqual(['filesystem']);
expect(config.mode).toBe('default');
expect(config.availableModes).toEqual(['filesystem', 's3', 'database']);
expect(config.mode).toBe('filesystem');
expect(config.localStoragePath).toBe('/test/n8n/binaryData');
});
it('should use values from env variables when defined', () => {
process.env.N8N_AVAILABLE_BINARY_DATA_MODES = 'filesystem,s3';
process.env.N8N_DEFAULT_BINARY_DATA_MODE = 's3';
process.env.N8N_BINARY_DATA_STORAGE_PATH = '/custom/storage/path';
process.env.N8N_BINARY_DATA_SIGNING_SECRET = 'super-secret';
@@ -37,7 +36,7 @@ describe('BinaryDataConfig', () => {
const config = Container.get(BinaryDataConfig);
expect(config.mode).toEqual('s3');
expect(config.availableModes).toEqual(['filesystem', 's3']);
expect(config.availableModes).toEqual(['filesystem', 's3', 'database']);
expect(config.localStoragePath).toEqual('/custom/storage/path');
expect(config.signingSecret).toBe('super-secret');
});
@@ -48,25 +47,14 @@ describe('BinaryDataConfig', () => {
expect(config.signingSecret).toBe('96eHYcXMF6J1Pn6dhdkOEt6H2BMa6kR5oR0ce7llWyA=');
});
it('should fallback to default for mode', () => {
it('should fallback to filesystem for invalid mode', () => {
process.env.N8N_DEFAULT_BINARY_DATA_MODE = 'invalid-mode';
const config = Container.get(BinaryDataConfig);
expect(config.mode).toEqual('default');
expect(config.mode).toEqual('filesystem');
expect(console.warn).toHaveBeenCalledWith(
expect.stringContaining('Invalid value for N8N_DEFAULT_BINARY_DATA_MODE'),
);
});
it('should fallback to default for available modes', () => {
process.env.N8N_AVAILABLE_BINARY_DATA_MODES = 'filesystem,invalid-mode,s3';
const config = Container.get(BinaryDataConfig);
expect(config.availableModes).toEqual(['filesystem']);
expect(console.warn).toHaveBeenCalledWith(
expect.stringContaining('Invalid value for N8N_AVAILABLE_BINARY_DATA_MODES'),
);
});
});

View File

@@ -1,4 +1,4 @@
import { Config, Env } from '@n8n/config';
import { Config, Env, ExecutionsConfig } from '@n8n/config';
import { createHash } from 'node:crypto';
import path from 'node:path';
import { z } from 'zod';
@@ -21,12 +21,11 @@ const dbMaxFileSizeSchema = z
@Config
export class BinaryDataConfig {
/** Available modes of binary data storage, as comma separated strings. */
@Env('N8N_AVAILABLE_BINARY_DATA_MODES', availableModesSchema)
availableModes: z.infer<typeof availableModesSchema> = ['filesystem'];
availableModes: z.infer<typeof availableModesSchema> = ['filesystem', 's3', 'database'];
/** Storage mode for binary data. */
/** Storage mode for binary data. Defaults to 'filesystem' in regular mode, 'database' in scaling mode. */
@Env('N8N_DEFAULT_BINARY_DATA_MODE', binaryDataModesSchema)
mode: z.infer<typeof binaryDataModesSchema> = 'default';
mode!: z.infer<typeof binaryDataModesSchema>;
/** Path for binary data storage in "filesystem" mode. */
@Env('N8N_BINARY_DATA_STORAGE_PATH')
@@ -43,10 +42,12 @@ export class BinaryDataConfig {
@Env('N8N_BINARY_DATA_DATABASE_MAX_FILE_SIZE', dbMaxFileSizeSchema)
dbMaxFileSize: number = 512;
constructor({ encryptionKey, n8nFolder }: InstanceSettings) {
constructor({ encryptionKey, n8nFolder }: InstanceSettings, executionsConfig: ExecutionsConfig) {
this.localStoragePath = path.join(n8nFolder, 'binaryData');
this.signingSecret = createHash('sha256')
.update(`url-signing:${encryptionKey}`)
.digest('base64');
this.mode ??= executionsConfig.mode === 'queue' ? 'database' : 'filesystem';
}
}

View File

@@ -1,5 +1,5 @@
import { Logger } from '@n8n/backend-common';
import { Container, Service } from '@n8n/di';
import { Service } from '@n8n/di';
import jwt from 'jsonwebtoken';
import type { StringValue as TimeUnitValue } from 'ms';
import { BINARY_ENCODING, UnexpectedError } from 'n8n-workflow';
@@ -17,7 +17,7 @@ import { InvalidManagerError } from '../errors/invalid-manager.error';
@Service()
export class BinaryDataService {
private mode: BinaryData.ServiceMode = 'default';
private mode: BinaryData.ServiceMode = 'filesystem-v2';
private managers: Record<string, BinaryData.Manager> = {};
@@ -36,29 +36,12 @@ export class BinaryDataService {
this.mode = config.mode === 'filesystem' ? 'filesystem-v2' : config.mode;
if (config.availableModes.includes('filesystem')) {
const { FileSystemManager } = await import('./file-system.manager');
const { FileSystemManager } = await import('./file-system.manager');
this.managers.filesystem = new FileSystemManager(config.localStoragePath, this.errorReporter);
this.managers['filesystem-v2'] = this.managers.filesystem;
await this.managers.filesystem.init();
this.managers.filesystem = new FileSystemManager(config.localStoragePath, this.errorReporter);
this.managers['filesystem-v2'] = this.managers.filesystem;
await this.managers.filesystem.init();
}
if (config.availableModes.includes('s3')) {
const { ObjectStoreManager } = await import('./object-store.manager');
const { ObjectStoreService } = await import('./object-store/object-store.service.ee');
this.managers.s3 = new ObjectStoreManager(Container.get(ObjectStoreService));
await this.managers.s3.init();
}
/**
* DB manager is set directly at `BaseCommand` in `cli`.
* to prevent a circular dependency (`core` -> `@n8n/db`
* -> `core`) until we reorganize our dependency graph.
*/
// DB and S3 managers are set via `setManager()` from `cli`
}
createSignedToken(binaryData: IBinaryData, expiresIn: TimeUnitValue = '1 day') {

View File

@@ -1,3 +1,4 @@
import { SecurityConfig } from '@n8n/config';
import { Container } from '@n8n/di';
import type { INode } from 'n8n-workflow';
import { createReadStream } from 'node:fs';
@@ -9,7 +10,6 @@ import {
BLOCK_FILE_ACCESS_TO_N8N_FILES,
CONFIG_FILES,
CUSTOM_EXTENSION_ENV,
RESTRICT_FILE_ACCESS_TO,
UM_EMAIL_TEMPLATES_INVITE,
UM_EMAIL_TEMPLATES_PWRESET,
} from '@/constants';
@@ -23,6 +23,7 @@ jest.mock('node:fs/promises');
const originalProcessEnv = { ...process.env };
let instanceSettings: InstanceSettings;
let securityConfig: SecurityConfig;
beforeEach(() => {
process.env = { ...originalProcessEnv };
@@ -33,6 +34,8 @@ beforeEach(() => {
(fsRealpath as jest.Mock).mockImplementation((path: string) => path);
instanceSettings = Container.get(InstanceSettings);
securityConfig = Container.get(SecurityConfig);
securityConfig.restrictFileAccessTo = '';
});
describe('isFilePathBlocked', () => {
@@ -51,25 +54,25 @@ describe('isFilePathBlocked', () => {
});
it('should handle empty allowed paths', async () => {
delete process.env[RESTRICT_FILE_ACCESS_TO];
securityConfig.restrictFileAccessTo = '';
const result = await isFilePathBlocked('/some/random/path');
expect(result).toBe(false);
});
it('should handle multiple allowed paths', async () => {
process.env[RESTRICT_FILE_ACCESS_TO] = '/path1;/path2;/path3';
securityConfig.restrictFileAccessTo = '/path1;/path2;/path3';
const allowedPath = '/path2/somefile';
expect(await isFilePathBlocked(allowedPath)).toBe(false);
});
it('should handle empty strings in allowed paths', async () => {
process.env[RESTRICT_FILE_ACCESS_TO] = '/path1;;/path2';
securityConfig.restrictFileAccessTo = '/path1;;/path2';
const allowedPath = '/path2/somefile';
expect(await isFilePathBlocked(allowedPath)).toBe(false);
});
it('should trim whitespace in allowed paths', async () => {
process.env[RESTRICT_FILE_ACCESS_TO] = ' /path1 ; /path2 ; /path3 ';
securityConfig.restrictFileAccessTo = ' /path1 ; /path2 ; /path3 ';
const allowedPath = '/path2/somefile';
expect(await isFilePathBlocked(allowedPath)).toBe(false);
});
@@ -81,14 +84,14 @@ describe('isFilePathBlocked', () => {
});
it('should return true when path is in allowed paths but still restricted', async () => {
process.env[RESTRICT_FILE_ACCESS_TO] = '/some/allowed/path';
securityConfig.restrictFileAccessTo = '/some/allowed/path';
const restrictedPath = instanceSettings.n8nFolder;
expect(await isFilePathBlocked(restrictedPath)).toBe(true);
});
it('should return false when path is in allowed paths', async () => {
const allowedPath = '/some/allowed/path';
process.env[RESTRICT_FILE_ACCESS_TO] = allowedPath;
securityConfig.restrictFileAccessTo = allowedPath;
expect(await isFilePathBlocked(allowedPath)).toBe(false);
});
@@ -125,7 +128,7 @@ describe('isFilePathBlocked', () => {
const homeVarName = process.platform === 'win32' ? 'USERPROFILE' : 'HOME';
const userHome = process.env.N8N_USER_FOLDER ?? process.env[homeVarName] ?? process.cwd();
process.env[RESTRICT_FILE_ACCESS_TO] = userHome;
securityConfig.restrictFileAccessTo = userHome;
process.env[BLOCK_FILE_ACCESS_TO_N8N_FILES] = 'true';
const restrictedPath = instanceSettings.n8nFolder;
expect(await isFilePathBlocked(restrictedPath)).toBe(true);
@@ -135,7 +138,7 @@ describe('isFilePathBlocked', () => {
const homeVarName = process.platform === 'win32' ? 'USERPROFILE' : 'HOME';
const userHome = process.env.N8N_USER_FOLDER ?? process.env[homeVarName] ?? process.cwd();
process.env[RESTRICT_FILE_ACCESS_TO] = userHome;
securityConfig.restrictFileAccessTo = userHome;
process.env[BLOCK_FILE_ACCESS_TO_N8N_FILES] = 'true';
const restrictedPath = join(userHome, 'somefile.txt');
expect(await isFilePathBlocked(restrictedPath)).toBe(false);
@@ -145,14 +148,14 @@ describe('isFilePathBlocked', () => {
const homeVarName = process.platform === 'win32' ? 'USERPROFILE' : 'HOME';
const userHome = process.env.N8N_USER_FOLDER ?? process.env[homeVarName] ?? process.cwd();
process.env[RESTRICT_FILE_ACCESS_TO] = userHome;
securityConfig.restrictFileAccessTo = userHome;
process.env[BLOCK_FILE_ACCESS_TO_N8N_FILES] = 'true';
const restrictedPath = join(userHome, '.n8n_x');
expect(await isFilePathBlocked(restrictedPath)).toBe(false);
});
it('should return true for a symlink in a allowed path to a restricted path', async () => {
process.env[RESTRICT_FILE_ACCESS_TO] = '/path1';
securityConfig.restrictFileAccessTo = '/path1';
const allowedPath = '/path1/symlink';
const actualPath = '/path2/realfile';
(fsRealpath as jest.Mock).mockImplementation((path: string) =>
@@ -173,7 +176,7 @@ describe('isFilePathBlocked', () => {
it('should handle non-existent file when it is not allowed', async () => {
const filePath = '/non/existent/file';
const allowedPath = '/some/allowed/path';
process.env[RESTRICT_FILE_ACCESS_TO] = allowedPath;
securityConfig.restrictFileAccessTo = allowedPath;
const error = new Error('ENOENT');
// @ts-expect-error undefined property
error.code = 'ENOENT';
@@ -216,7 +219,7 @@ describe('getFileSystemHelperFunctions', () => {
});
it('should throw when file access is blocked', async () => {
process.env[RESTRICT_FILE_ACCESS_TO] = '/allowed/path';
securityConfig.restrictFileAccessTo = '/allowed/path';
(fsAccess as jest.Mock).mockResolvedValueOnce({});
await expect(helperFunctions.createReadStream('/blocked/path')).rejects.toThrow(
'Access to the file is not allowed',
@@ -224,7 +227,7 @@ describe('getFileSystemHelperFunctions', () => {
});
it('should not reveal if file exists if it is within restricted path', async () => {
process.env[RESTRICT_FILE_ACCESS_TO] = '/allowed/path';
securityConfig.restrictFileAccessTo = '/allowed/path';
const error = new Error('ENOENT');
// @ts-expect-error undefined property

View File

@@ -1,4 +1,5 @@
import { isContainedWithin, safeJoinPath } from '@n8n/backend-common';
import { SecurityConfig } from '@n8n/config';
import { Container } from '@n8n/di';
import type { FileSystemHelperFunctions, INode } from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
@@ -8,6 +9,7 @@ import {
writeFile as fsWriteFile,
realpath as fsRealpath,
} from 'node:fs/promises';
import { homedir } from 'node:os';
import { resolve } from 'node:path';
import {
@@ -15,21 +17,21 @@ import {
BLOCK_FILE_ACCESS_TO_N8N_FILES,
CONFIG_FILES,
CUSTOM_EXTENSION_ENV,
RESTRICT_FILE_ACCESS_TO,
UM_EMAIL_TEMPLATES_INVITE,
UM_EMAIL_TEMPLATES_PWRESET,
} from '@/constants';
import { InstanceSettings } from '@/instance-settings';
const getAllowedPaths = () => {
const restrictFileAccessTo = process.env[RESTRICT_FILE_ACCESS_TO];
if (!restrictFileAccessTo) {
return [];
}
const { restrictFileAccessTo } = Container.get(SecurityConfig);
if (restrictFileAccessTo === '') return [];
const allowedPaths = restrictFileAccessTo
.split(';')
.map((path) => path.trim())
.filter((path) => path);
.filter((path) => path)
.map((path) => (path.startsWith('~') ? path.replace('~', homedir()) : path));
return allowedPaths;
};

View File

@@ -57,7 +57,10 @@ describe('InstanceSettings', () => {
it('should check if the settings file has the correct permissions', () => {
mockFs.readFileSync.mockReturnValueOnce(JSON.stringify({ encryptionKey: 'test_key' }));
mockFs.statSync.mockReturnValueOnce({ mode: 0o600 } as fs.Stats);
const settings = createInstanceSettings({ encryptionKey: 'test_key' });
const settings = createInstanceSettings({
encryptionKey: 'test_key',
enforceSettingsFilePermissions: true,
});
expect(settings.encryptionKey).toEqual('test_key');
expect(settings.instanceId).toEqual(
'6ce26c63596f0cc4323563c529acfca0cccb0e57f6533d79a60a42c9ff862ae7',
@@ -65,18 +68,22 @@ describe('InstanceSettings', () => {
expect(mockFs.statSync).toHaveBeenCalledWith('/test/.n8n/config');
});
it('should check the permissions but not fix them if settings file has incorrect permissions by default', () => {
it('should check the permissions and fix them if settings file has incorrect permissions by default', () => {
mockFs.readFileSync.mockReturnValueOnce(JSON.stringify({ encryptionKey: 'test_key' }));
mockFs.statSync.mockReturnValueOnce({ mode: 0o644 } as fs.Stats);
createInstanceSettings();
createInstanceSettings({
enforceSettingsFilePermissions: true,
});
expect(mockFs.statSync).toHaveBeenCalledWith('/test/.n8n/config');
expect(mockFs.chmodSync).not.toHaveBeenCalled();
expect(mockFs.chmodSync).toHaveBeenCalledWith('/test/.n8n/config', 0o600);
});
it("should not check the permissions if 'N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS' is false", () => {
process.env.N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS = 'false';
mockFs.readFileSync.mockReturnValueOnce(JSON.stringify({ encryptionKey: 'test_key' }));
createInstanceSettings();
createInstanceSettings({
enforceSettingsFilePermissions: false,
});
expect(mockFs.statSync).not.toHaveBeenCalled();
expect(mockFs.chmodSync).not.toHaveBeenCalled();
});
@@ -100,8 +107,11 @@ describe('InstanceSettings', () => {
mockFs.writeFileSync.mockReturnValue();
});
it('should create a new settings file without explicit permissions if N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS is not set', () => {
const settings = createInstanceSettings({ encryptionKey: 'key_2' });
it('should create a new settings file with explicit permissions if N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS is not set', () => {
const settings = createInstanceSettings({
encryptionKey: 'key_2',
enforceSettingsFilePermissions: true,
});
expect(settings.encryptionKey).not.toEqual('test_key');
expect(mockFs.mkdirSync).toHaveBeenCalledWith('/test/.n8n', { recursive: true });
expect(mockFs.writeFileSync).toHaveBeenCalledWith(
@@ -109,14 +119,17 @@ describe('InstanceSettings', () => {
expect.stringContaining('"encryptionKey":'),
{
encoding: 'utf-8',
mode: undefined,
mode: 0o600,
},
);
});
it('should create a new settings file without explicit permissions if N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=false', () => {
process.env.N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS = 'false';
const settings = createInstanceSettings({ encryptionKey: 'key_2' });
const settings = createInstanceSettings({
encryptionKey: 'key_2',
enforceSettingsFilePermissions: false,
});
expect(settings.encryptionKey).not.toEqual('test_key');
expect(mockFs.mkdirSync).toHaveBeenCalledWith('/test/.n8n', { recursive: true });
expect(mockFs.writeFileSync).toHaveBeenCalledWith(
@@ -148,7 +161,10 @@ describe('InstanceSettings', () => {
});
it('should pick up the encryption key from config', () => {
const settings = createInstanceSettings({ encryptionKey: 'env_key' });
const settings = createInstanceSettings({
encryptionKey: 'env_key',
enforceSettingsFilePermissions: true,
});
expect(settings.encryptionKey).toEqual('env_key');
expect(settings.instanceId).toEqual(
'2c70e12b7a0646f92279f427c7b38e7334d8e5389cff167a1dc30e73f826b683',
@@ -160,22 +176,7 @@ describe('InstanceSettings', () => {
expect.stringContaining('"encryptionKey":'),
{
encoding: 'utf-8',
mode: undefined,
},
);
});
it("should not set the permissions of the settings file if 'N8N_IGNORE_SETTINGS_FILE_PERMISSIONS' is true", () => {
process.env.N8N_IGNORE_SETTINGS_FILE_PERMISSIONS = 'true';
const settings = createInstanceSettings({ encryptionKey: 'key_2' });
expect(settings.encryptionKey).not.toEqual('test_key');
expect(mockFs.mkdirSync).toHaveBeenCalledWith('/test/.n8n', { recursive: true });
expect(mockFs.writeFileSync).toHaveBeenCalledWith(
'/test/.n8n/config',
expect.stringContaining('"encryptionKey":'),
{
encoding: 'utf-8',
mode: undefined,
mode: 0o600,
},
);
});

View File

@@ -273,19 +273,15 @@ export class InstanceSettings {
* Ensures that the settings file has the r/w permissions only for the owner.
*/
private ensureSettingsFilePermissions() {
// If the flag is explicitly set to false, skip the check
if (this.enforceSettingsFilePermissions.isSet && !this.enforceSettingsFilePermissions.enforce) {
return;
}
if (this.isWindows()) {
// Ignore windows as it does not support chmod. We have already logged a warning
return;
}
if (!this.enforceSettingsFilePermissions.enforce) return;
if (this.isWindows()) return; // ignore windows as it does not support chmod
const permissionsResult = toResult(() => {
const stats = statSync(this.settingsFile);
return stats?.mode & 0o777;
});
// If we can't determine the permissions, log a warning and skip the check
if (!permissionsResult.ok) {
this.logger.warn(
@@ -295,32 +291,22 @@ export class InstanceSettings {
}
const arePermissionsCorrect = permissionsResult.result === 0o600;
if (arePermissionsCorrect) {
return;
}
// If the permissions are incorrect and the flag is not set, log a warning
if (!this.enforceSettingsFilePermissions.isSet) {
this.logger.warn(
`Permissions 0${permissionsResult.result.toString(8)} for n8n settings file ${this.settingsFile} are too wide. This is ignored for now, but in the future n8n will attempt to change the permissions automatically. To automatically enforce correct permissions now set N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=true (recommended), or turn this check off set N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=false.`,
);
// The default is false so we skip the enforcement for now
return;
}
if (arePermissionsCorrect) return;
if (this.enforceSettingsFilePermissions.enforce) {
this.logger.error(
`Permissions 0${permissionsResult.result.toString(8)} for n8n settings file ${this.settingsFile} are too wide. Changing permissions to 0600..`,
);
const chmodResult = toResult(() => chmodSync(this.settingsFile, 0o600));
if (!chmodResult.ok) {
// Some filesystems don't support permissions. In this case we log the
// error and ignore it. We might want to prevent the app startup in the
// future in this case.
this.logger.warn(
`Permissions 0${permissionsResult.result.toString(8)} for n8n settings file ${this.settingsFile} are too wide. Changing permissions to 0600..`,
`Could not enforce settings file permissions: ${chmodResult.error.message}. To skip this check, set N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=false.`,
);
const chmodResult = toResult(() => chmodSync(this.settingsFile, 0o600));
if (!chmodResult.ok) {
// Some filesystems don't support permissions. In this case we log the
// error and ignore it. We might want to prevent the app startup in the
// future in this case.
this.logger.warn(
`Could not enforce settings file permissions: ${chmodResult.error.message}. To skip this check, set N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=false.`,
);
}
}
}

View File

@@ -138,6 +138,8 @@
"generic.your": "Your",
"generic.apiKey": "API Key",
"generic.search": "Search",
"generic.showMore": "Show more",
"generic.showLess": "Show less",
"about.aboutN8n": "About n8n",
"about.close": "Close",
"about.license": "License",
@@ -162,6 +164,7 @@
"activationModal.saveExecutions": "save executions.",
"activationModal.theseExecutionsWillNotShowUp": "These executions will not show up immediately in the editor,",
"activationModal.workflowActivated": "Workflow activated",
"activationModal.workflowPublished": "Workflow published",
"activationModal.yourTriggerWillNowFire": "Your trigger will now fire production executions automatically.",
"activationModal.yourTriggersWillNowFire": "Your triggers will now fire production executions automatically.",
"activationModal.yourWorkflowWillNowListenForEvents": "Your workflow will now listen for events from {serviceName} and trigger executions.",
@@ -1311,6 +1314,7 @@
"menuActions.delete": "Delete",
"menuActions.archive": "Archive",
"menuActions.unarchive": "Unarchive",
"menuActions.unpublish": "Unpublish",
"multipleParameter.addItem": "Add item",
"multipleParameter.currentlyNoItemsExist": "Currently no items exist",
"multipleParameter.deleteItem": "Delete item",
@@ -3156,12 +3160,17 @@
"workflowHistory.content.actions": "Actions",
"workflowHistory.item.id": "ID: {id}",
"workflowHistory.item.createdAt": "{date} at {time}",
"workflowHistory.item.savedAtLabel": "Saved:",
"workflowHistory.item.publishedAtLabel": "Published:",
"workflowHistory.item.actions.restore": "Restore this version",
"workflowHistory.item.actions.publish": "Publish this version",
"workflowHistory.item.actions.unpublish": "Unpublish workflow",
"workflowHistory.item.actions.clone": "Clone to new workflow",
"workflowHistory.item.actions.open": "Open version in new tab",
"workflowHistory.item.actions.download": "Download",
"workflowHistory.item.unsaved.title": "Unsaved version",
"workflowHistory.item.latest": "Latest saved",
"workflowHistory.item.active": "Published",
"workflowHistory.empty": "No versions yet.",
"workflowHistory.hint": "Save the workflow to create the first version!",
"workflowHistory.limit": "Version history is limited to {days} days",
@@ -3170,17 +3179,23 @@
"workflowHistory.action.error.title": "Failed to {action}",
"workflowHistory.action.restore.modal.title": "Restore previous workflow version?",
"workflowHistory.action.restore.modal.subtitle": "Your workflow will revert to the version from {date}",
"workflowHistory.action.restore.modal.text": "Your workflow is currently active, so production executions will immediately start using the restored version. If you'd like to deactivate it before restoring, click {buttonText}.",
"workflowHistory.action.restore.modal.publishedNote": "This will not affect the published version",
"workflowHistory.action.restore.modal.button.deactivateAndRestore": "Deactivate and restore",
"workflowHistory.action.restore.modal.button.restore": "Restore",
"workflowHistory.action.restore.modal.button.cancel": "Cancel",
"workflowHistory.action.restore.success.title": "Successfully restored workflow version",
"workflowHistory.action.clone.success.title": "Successfully cloned workflow version",
"workflowHistory.action.clone.success.message": "Open cloned workflow in a new tab",
"workflowHistory.action.unpublish.success.title": "Workflow unpublished successfully",
"workflowHistory.action.unpublish.modal.title": "Unpublish {versionName}",
"workflowHistory.action.unpublish.modal.description": "This will prevent all production executions to this workflow until you publish again.",
"workflowHistory.action.unpublish.modal.button.unpublish": "Unpublish",
"workflowHistory.button.tooltip.empty": "This workflow currently has no history to view. Once you've made your first save, you'll be able to view previous versions",
"workflowHistory.button.tooltip": "Workflow history to view and restore previous versions of your workflows",
"workflowHistory.publishModal.title": "Publish {versionName}",
"workflows.heading": "Workflows",
"workflows.add": "Add workflow",
"workflows.publish": "Publish",
"workflows.project.add": "Add workflow to project",
"workflows.item.open": "Open",
"workflows.item.share": "Share...",
@@ -3197,6 +3212,8 @@
"workflows.item.availableInMCP": "Available in MCP",
"workflows.item.enableMCPAccess": "Enable MCP access",
"workflows.item.disableMCPAccess": "Remove MCP access",
"workflows.item.published": "Published",
"workflows.item.notPublished": "Not published",
"workflows.itemSuggestion.try": "Try template",
"workflows.templateRecoV2.starterTemplates": "Starter templates",
"workflows.templateRecoV2.seeMoreStarterTemplates": "See more starter templates",
@@ -3297,6 +3314,12 @@
"workflows.preBuiltAgents.callout": "Get started faster with our",
"workflows.preBuiltAgents.linkText": "pre-built agents",
"workflowSelectorParameterInput.createNewSubworkflow.name": "My Sub-Workflow",
"workflows.publishModal.title": "Publish workflow",
"workflows.publishModal.noTriggerMessage": "This workflow has no trigger nodes that require publishing",
"workflows.publishModal.versionNameLabel": "Version name",
"workflows.publishModal.descriptionPlaceholder": "Describe changes (optional)",
"workflows.publishModal.noChanges": "No changes to publish",
"workflows.publishModal.lastPublished": "Last published by {author}, {date} at {time}",
"importCurlModal.title": "Import cURL command",
"importCurlModal.input.label": "cURL Command",
"importCurlModal.input.placeholder": "Paste the cURL command here",

View File

@@ -8,6 +8,18 @@ export type WorkflowHistory = {
authors: string;
createdAt: string;
updatedAt: string;
workflowPublishHistory: WorkflowPublishHistory[];
name: string | null;
description: string | null;
};
export type WorkflowPublishHistory = {
createdAt: string;
id: number;
event: 'activated' | 'deactivated';
userId: string | null;
versionId: string;
workflowId: string;
};
export type WorkflowVersionId = WorkflowHistory['versionId'];
@@ -18,7 +30,9 @@ export type WorkflowVersion = WorkflowHistory & {
connections: IConnections;
};
export type WorkflowHistoryActionTypes = ['restore', 'clone', 'open', 'download'];
export type WorkflowHistoryActionTypes = Array<
'restore' | 'publish' | 'unpublish' | 'clone' | 'open' | 'download'
>;
export type WorkflowHistoryRequestParams = { take: number; skip?: number };

View File

@@ -62,6 +62,7 @@ import type {
FolderListItem,
ResourceParentFolder,
} from '@/features/core/folders/folders.types';
import type { WorkflowHistory } from '@n8n/rest-api-client/api/workflowHistory';
export * from '@n8n/design-system/types';
@@ -260,6 +261,7 @@ export interface IWorkflowDb {
createdAt?: string;
updatedAt?: string;
};
activeVersion?: WorkflowHistory | null;
}
// For workflow list we don't need the full workflow data

View File

@@ -115,3 +115,27 @@ export async function getLastSuccessfulExecution(
`/workflows/${workflowId}/executions/last-successful`,
);
}
export async function activateWorkflow(
context: IRestApiContext,
workflowId: string,
data: { versionId: string; name?: string; description?: string },
): Promise<IWorkflowDb> {
return await makeRestApiRequest<IWorkflowDb>(
context,
'POST',
`/workflows/${workflowId}/activate`,
data,
);
}
export async function deactivateWorkflow(
context: IRestApiContext,
workflowId: string,
): Promise<IWorkflowDb> {
return await makeRestApiRequest<IWorkflowDb>(
context,
'POST',
`/workflows/${workflowId}/deactivate`,
);
}

View File

@@ -19,16 +19,23 @@ import { useI18n } from '@n8n/i18n';
import { ElCheckbox } from 'element-plus';
import { N8nButton, N8nText } from '@n8n/design-system';
import { IS_DRAFT_PUBLISH_ENABLED } from '@/app/constants';
const checked = ref(false);
const executionsStore = useExecutionsStore();
const workflowsStore = useWorkflowsStore();
const nodeTypesStore = useNodeTypesStore();
const uiStore = useUIStore();
const router = useRouter();
const i18n = useI18n();
const modalTitle = computed(() => {
if (IS_DRAFT_PUBLISH_ENABLED) {
return i18n.baseText('activationModal.workflowPublished');
}
return i18n.baseText('activationModal.workflowActivated');
});
const triggerContent = computed(() => {
const foundTriggers = getActivatableTriggerNodes(workflowsStore.workflowTriggerNodes);
if (!foundTriggers.length) {
@@ -94,11 +101,7 @@ const handleCheckboxChange = (checkboxValue: string | number | boolean) => {
</script>
<template>
<Modal
:name="WORKFLOW_ACTIVE_MODAL_KEY"
:title="i18n.baseText('activationModal.workflowActivated')"
width="460px"
>
<Modal :name="WORKFLOW_ACTIVE_MODAL_KEY" :title="modalTitle" width="460px">
<template #content>
<div>
<N8nText>{{ triggerContent }}</N8nText>

View File

@@ -0,0 +1,434 @@
<script lang="ts" setup>
import { computed, ref, useCssModule } from 'vue';
import { type ActionDropdownItem, N8nActionDropdown } from '@n8n/design-system';
import type { WorkflowDataUpdate } from '@n8n/rest-api-client';
import { useToast } from '@/app/composables/useToast';
import { useI18n } from '@n8n/i18n';
import { createEventBus } from '@n8n/utils/event-bus';
import {
WORKFLOW_MENU_ACTIONS,
VIEWS,
DUPLICATE_MODAL_KEY,
IMPORT_WORKFLOW_URL_MODAL_KEY,
WORKFLOW_SETTINGS_MODAL_KEY,
WORKFLOW_HISTORY_VERSION_UNPUBLISH,
IS_DRAFT_PUBLISH_ENABLED,
WORKFLOW_SHARE_MODAL_KEY,
EnterpriseEditionFeature,
} from '@/app/constants';
import { hasPermission } from '@/app/utils/rbac/permissions';
import { useRoute } from 'vue-router';
import { useProjectsStore } from '@/features/collaboration/projects/projects.store';
import { useSourceControlStore } from '@/features/integrations/sourceControl.ee/sourceControl.store';
import type { PermissionsRecord } from '@n8n/permissions';
import { useUIStore } from '@/app/stores/ui.store';
import { PROJECT_MOVE_RESOURCE_MODAL } from '@/features/collaboration/projects/projects.constants';
import { ResourceType } from '@/features/collaboration/projects/projects.utils';
import type { IWorkflowToShare, IWorkflowDb } from '@/Interface';
import { telemetry } from '@/app/plugins/telemetry';
import router from '@/app/router';
import { sanitizeFilename } from '@n8n/utils';
import saveAs from 'file-saver';
import { nodeViewEventBus } from '@/app/event-bus';
import type { FolderShortInfo } from '@/features/core/folders/folders.types';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { useRootStore } from '@n8n/stores/useRootStore';
import { useTagsStore } from '@/features/shared/tags/tags.store';
import { useSettingsStore } from '@/app/stores/settings.store';
import { useUsersStore } from '@/features/settings/users/users.store';
import { useTelemetry } from '@/app/composables/useTelemetry';
import { useWorkflowHelpers } from '@/app/composables/useWorkflowHelpers';
import { useWorkflowActivate } from '@/app/composables/useWorkflowActivate';
import { getWorkflowId } from '@/app/components/MainHeader/utils';
const props = defineProps<{
workflowPermissions: PermissionsRecord['workflow'];
isNewWorkflow: boolean;
readOnly?: boolean;
isArchived: IWorkflowDb['isArchived'];
id: IWorkflowDb['id'];
name: IWorkflowDb['name'];
tags: IWorkflowDb['tags'];
currentFolder?: FolderShortInfo;
meta: IWorkflowDb['meta'];
}>();
const emit = defineEmits<{
'workflow:saved': [];
}>();
const importFileRef = ref<HTMLInputElement | undefined>();
const toast = useToast();
const locale = useI18n();
const route = useRoute();
const projectsStore = useProjectsStore();
const sourceControlStore = useSourceControlStore();
const workflowsStore = useWorkflowsStore();
const uiStore = useUIStore();
const $style = useCssModule();
const rootStore = useRootStore();
const tagsStore = useTagsStore();
const settingsStore = useSettingsStore();
const usersStore = useUsersStore();
const workflowHelpers = useWorkflowHelpers();
const workflowActivate = useWorkflowActivate();
const changeOwnerEventBus = createEventBus();
const workflowTelemetry = useTelemetry();
const onWorkflowPage = computed(() => {
return route.meta && (route.meta.nodeView || route.meta.keepWorkflowAlive === true);
});
const onExecutionsTab = computed(() => {
return [
VIEWS.EXECUTION_HOME.toString(),
VIEWS.WORKFLOW_EXECUTIONS.toString(),
VIEWS.EXECUTION_PREVIEW,
].includes((route.name as string) || '');
});
const activeVersion = computed(() => workflowsStore.workflow.activeVersion);
const isDraftPublishEnabled = IS_DRAFT_PUBLISH_ENABLED;
const isSharingEnabled = computed(
() => settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.Sharing],
);
function handleFileImport() {
const inputRef = importFileRef.value;
if (inputRef?.files && inputRef.files.length !== 0) {
const reader = new FileReader();
reader.onload = () => {
let workflowData: WorkflowDataUpdate;
try {
workflowData = JSON.parse(reader.result as string);
} catch (error) {
toast.showMessage({
title: locale.baseText('mainSidebar.showMessage.handleFileImport.title'),
message: locale.baseText('mainSidebar.showMessage.handleFileImport.message'),
type: 'error',
});
return;
} finally {
reader.onload = null;
inputRef.value = '';
}
nodeViewEventBus.emit('importWorkflowData', { data: workflowData });
};
reader.readAsText(inputRef.files[0]);
}
}
const workflowMenuItems = computed<Array<ActionDropdownItem<WORKFLOW_MENU_ACTIONS>>>(() => {
const actions: Array<ActionDropdownItem<WORKFLOW_MENU_ACTIONS>> = [
{
id: WORKFLOW_MENU_ACTIONS.DOWNLOAD,
label: locale.baseText('menuActions.download'),
disabled: !onWorkflowPage.value,
},
];
if (isDraftPublishEnabled && isSharingEnabled.value) {
actions.push({
id: WORKFLOW_MENU_ACTIONS.SHARE,
label: locale.baseText('workflowDetails.share'),
disabled: !onWorkflowPage.value,
});
}
if (props.workflowPermissions.move && projectsStore.isTeamProjectFeatureEnabled) {
actions.push({
id: WORKFLOW_MENU_ACTIONS.CHANGE_OWNER,
label: locale.baseText('workflows.item.changeOwner'),
disabled: props.isNewWorkflow,
});
}
if (!props.readOnly && !props.isArchived) {
actions.push({
id: WORKFLOW_MENU_ACTIONS.RENAME,
label: locale.baseText('generic.rename'),
disabled: !onWorkflowPage.value || props.workflowPermissions.update !== true,
});
}
if (
(props.workflowPermissions.update === true && !props.readOnly && !props.isArchived) ||
props.isNewWorkflow
) {
actions.unshift({
id: WORKFLOW_MENU_ACTIONS.DUPLICATE,
label: locale.baseText('menuActions.duplicate'),
disabled: !onWorkflowPage.value || !props.id,
});
actions.push(
{
id: WORKFLOW_MENU_ACTIONS.IMPORT_FROM_URL,
label: locale.baseText('menuActions.importFromUrl'),
disabled: !onWorkflowPage.value || onExecutionsTab.value,
},
{
id: WORKFLOW_MENU_ACTIONS.IMPORT_FROM_FILE,
label: locale.baseText('menuActions.importFromFile'),
disabled: !onWorkflowPage.value || onExecutionsTab.value,
},
);
}
if (hasPermission(['rbac'], { rbac: { scope: 'sourceControl:push' } })) {
actions.push({
id: WORKFLOW_MENU_ACTIONS.PUSH,
label: locale.baseText('menuActions.push'),
disabled:
!sourceControlStore.isEnterpriseSourceControlEnabled ||
!onWorkflowPage.value ||
onExecutionsTab.value ||
sourceControlStore.preferences.branchReadOnly,
});
}
actions.push({
id: WORKFLOW_MENU_ACTIONS.SETTINGS,
label: locale.baseText('generic.settings'),
disabled: !onWorkflowPage.value || props.isNewWorkflow,
});
if (
isDraftPublishEnabled &&
activeVersion.value &&
props.workflowPermissions.update &&
!props.readOnly
) {
actions.push({
id: WORKFLOW_MENU_ACTIONS.UNPUBLISH,
label: locale.baseText('menuActions.unpublish'),
disabled: !onWorkflowPage.value,
});
}
if ((props.workflowPermissions.delete === true && !props.readOnly) || props.isNewWorkflow) {
if (props.isArchived) {
actions.push({
id: WORKFLOW_MENU_ACTIONS.UNARCHIVE,
label: locale.baseText('menuActions.unarchive'),
disabled: !onWorkflowPage.value || props.isNewWorkflow,
});
actions.push({
id: WORKFLOW_MENU_ACTIONS.DELETE,
label: locale.baseText('menuActions.delete'),
disabled: !onWorkflowPage.value || props.isNewWorkflow,
customClass: $style.deleteItem,
divided: true,
});
} else {
actions.push({
id: WORKFLOW_MENU_ACTIONS.ARCHIVE,
label: locale.baseText('menuActions.archive'),
disabled: !onWorkflowPage.value || props.isNewWorkflow,
customClass: $style.deleteItem,
divided: true,
});
}
}
return actions;
});
async function onWorkflowMenuSelect(action: WORKFLOW_MENU_ACTIONS): Promise<void> {
switch (action) {
case WORKFLOW_MENU_ACTIONS.DUPLICATE: {
uiStore.openModalWithData({
name: DUPLICATE_MODAL_KEY,
data: {
id: props.id,
name: props.name,
tags: props.tags,
parentFolderId: props.currentFolder?.id,
},
});
break;
}
case WORKFLOW_MENU_ACTIONS.RENAME: {
nodeViewEventBus.emit('renameWorkflow');
break;
}
case WORKFLOW_MENU_ACTIONS.DOWNLOAD: {
const workflowData = await workflowHelpers.getWorkflowDataToSave();
const { tags, ...data } = workflowData;
const exportData: IWorkflowToShare = {
...data,
meta: {
...props.meta,
instanceId: rootStore.instanceId,
},
tags: (tags ?? []).map((tagId) => {
const { usageCount, ...tag } = tagsStore.tagsById[tagId];
return tag;
}),
};
const blob = new Blob([JSON.stringify(exportData, null, 2)], {
type: 'application/json;charset=utf-8',
});
let name = props.name || 'unsaved_workflow';
name = sanitizeFilename(name);
telemetry.track('User exported workflow', { workflow_id: workflowData.id });
saveAs(blob, name + '.json');
break;
}
case WORKFLOW_MENU_ACTIONS.IMPORT_FROM_URL: {
uiStore.openModal(IMPORT_WORKFLOW_URL_MODAL_KEY);
break;
}
case WORKFLOW_MENU_ACTIONS.IMPORT_FROM_FILE: {
nodeViewEventBus.emit('importWorkflowFromFile');
break;
}
case WORKFLOW_MENU_ACTIONS.PUSH: {
try {
emit('workflow:saved');
// Navigate to route with sourceControl param - modal will handle data loading and loading states
void router.push({
query: {
...route.query,
sourceControl: 'push',
},
});
} catch (error) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
switch (error.message) {
case 'source_control_not_connected':
toast.showError(
{ ...error, message: '' },
locale.baseText('settings.sourceControl.error.not.connected.title'),
locale.baseText('settings.sourceControl.error.not.connected.message'),
);
break;
default:
toast.showError(error, locale.baseText('error'));
}
}
break;
}
case WORKFLOW_MENU_ACTIONS.SETTINGS: {
uiStore.openModal(WORKFLOW_SETTINGS_MODAL_KEY);
break;
}
case WORKFLOW_MENU_ACTIONS.SHARE: {
uiStore.openModalWithData({
name: WORKFLOW_SHARE_MODAL_KEY,
data: { id: props.id },
});
workflowTelemetry.track('User opened sharing modal', {
workflow_id: props.id,
user_id_sharer: usersStore.currentUser?.id,
sub_view: route.name === VIEWS.WORKFLOWS ? 'Workflows listing' : 'Workflow editor',
});
break;
}
case WORKFLOW_MENU_ACTIONS.ARCHIVE: {
nodeViewEventBus.emit('archiveWorkflow');
break;
}
case WORKFLOW_MENU_ACTIONS.UNARCHIVE: {
nodeViewEventBus.emit('unarchiveWorkflow');
break;
}
case WORKFLOW_MENU_ACTIONS.DELETE: {
nodeViewEventBus.emit('deleteWorkflow');
break;
}
case WORKFLOW_MENU_ACTIONS.CHANGE_OWNER: {
const workflowId = getWorkflowId(props.id, route.params.name);
if (!workflowId) {
return;
}
changeOwnerEventBus.once(
'resource-moved',
async () => await router.push({ name: VIEWS.WORKFLOWS }),
);
uiStore.openModalWithData({
name: PROJECT_MOVE_RESOURCE_MODAL,
data: {
resource: workflowsStore.workflowsById[workflowId],
resourceType: ResourceType.Workflow,
resourceTypeLabel: locale.baseText('generic.workflow').toLowerCase(),
eventBus: changeOwnerEventBus,
},
});
break;
}
case WORKFLOW_MENU_ACTIONS.UNPUBLISH: {
const workflowId = getWorkflowId(props.id, route.params.name);
if (!workflowId || !activeVersion.value) {
return;
}
const unpublishEventBus = createEventBus();
unpublishEventBus.once('unpublish', async () => {
const success = await workflowActivate.unpublishWorkflowFromHistory(workflowId);
uiStore.closeModal(WORKFLOW_HISTORY_VERSION_UNPUBLISH);
if (success) {
toast.showMessage({
title: locale.baseText('workflowHistory.action.unpublish.success.title'),
type: 'success',
});
}
});
uiStore.openModalWithData({
name: WORKFLOW_HISTORY_VERSION_UNPUBLISH,
data: {
versionName: activeVersion.value.name,
eventBus: unpublishEventBus,
},
});
break;
}
default:
break;
}
}
defineExpose({
importFileRef,
});
</script>
<template>
<div :class="[$style.group]">
<input
ref="importFileRef"
:class="$style.hiddenInput"
type="file"
data-test-id="workflow-import-input"
@change="handleFileImport()"
/>
<N8nActionDropdown
:items="workflowMenuItems"
data-test-id="workflow-menu"
@select="onWorkflowMenuSelect"
/>
</div>
</template>
<style lang="scss" module>
.deleteItem {
color: var(--color--danger);
}
.group {
display: flex;
gap: var(--spacing--xs);
}
.hiddenInput {
display: none;
}
</style>

View File

@@ -27,13 +27,18 @@ vi.mock('vue-router', async (importOriginal) => ({
useRoute: vi.fn().mockReturnValue({
params: { name: 'test' },
query: { parentFolderId: '1' },
meta: {
nodeView: true,
},
}),
useRouter: vi.fn().mockReturnValue({
replace: vi.fn(),
push: vi.fn().mockResolvedValue(undefined),
currentRoute: {
value: {
params: { name: 'test' },
params: {
name: 'test',
},
query: { parentFolderId: '1' },
},
},
@@ -109,6 +114,13 @@ const renderComponent = createComponentRenderer(WorkflowDetails, {
template: '<div><slot name="append" /></div>',
},
},
directives: {
loading: {
mounted() {},
updated() {},
unmounted() {},
},
},
},
});
@@ -125,6 +137,8 @@ const workflow = {
tags: ['1', '2'],
active: false,
isArchived: false,
scopes: [],
meta: {},
};
describe('WorkflowDetails', () => {
@@ -148,7 +162,7 @@ describe('WorkflowDetails', () => {
});
it('renders workflow name and tags', async () => {
(useRoute as Mock).mockReturnValue({
(useRoute as Mock).mockReturnValueOnce({
query: { parentFolderId: '1' },
});
const { getByTestId, getByText } = renderComponent({
@@ -193,7 +207,8 @@ describe('WorkflowDetails', () => {
},
});
await userEvent.click(getByTestId('workflow-share-button'));
await userEvent.click(getByTestId('workflow-menu'));
await userEvent.click(getByTestId('workflow-menu-item-share'));
expect(openModalSpy).toHaveBeenCalledWith({
name: WORKFLOW_SHARE_MODAL_KEY,
data: { id: '1' },
@@ -202,11 +217,12 @@ describe('WorkflowDetails', () => {
describe('Workflow menu', () => {
beforeEach(() => {
(useRoute as Mock).mockReturnValue({
(useRoute as Mock).mockReturnValueOnce({
meta: {
nodeView: true,
},
query: { parentFolderId: '1' },
params: { name: 'test' },
});
});
@@ -242,6 +258,7 @@ describe('WorkflowDetails', () => {
expect(getByTestId('workflow-menu-item-duplicate')).toBeInTheDocument();
expect(getByTestId('workflow-menu-item-import-from-url')).toBeInTheDocument();
expect(getByTestId('workflow-menu-item-import-from-file')).toBeInTheDocument();
expect(queryByTestId('workflow-menu-item-share')).toBeInTheDocument();
expect(queryByTestId('workflow-menu-item-delete')).not.toBeInTheDocument();
expect(queryByTestId('workflow-menu-item-archive')).not.toBeInTheDocument();
expect(queryByTestId('workflow-menu-item-unarchive')).not.toBeInTheDocument();
@@ -596,7 +613,7 @@ describe('WorkflowDetails', () => {
it("should call onWorkflowMenuSelect on 'Change owner' option click", async () => {
const openModalSpy = vi.spyOn(uiStore, 'openModalWithData');
workflowsStore.workflowsById = { [workflow.id]: workflow as IWorkflowDb };
workflowsStore.workflowsById = { [workflow.id]: workflow as unknown as IWorkflowDb };
const { getByTestId } = renderComponent({
props: {

View File

@@ -1,60 +1,36 @@
<script lang="ts" setup>
import BreakpointsObserver from '@/app/components/BreakpointsObserver.vue';
import EnterpriseEdition from '@/app/components/EnterpriseEdition.ee.vue';
import FolderBreadcrumbs from '@/features/core/folders/components/FolderBreadcrumbs.vue';
import CollaborationPane from '@/features/collaboration/collaboration/components/CollaborationPane.vue';
import WorkflowHistoryButton from '@/features/workflows/workflowHistory/components/WorkflowHistoryButton.vue';
import PushConnectionTracker from '@/app/components/PushConnectionTracker.vue';
import SaveButton from '@/app/components/SaveButton.vue';
import WorkflowActivator from '@/app/components/WorkflowActivator.vue';
import WorkflowProductionChecklist from '@/app/components/WorkflowProductionChecklist.vue';
import WorkflowTagsContainer from '@/features/shared/tags/components/WorkflowTagsContainer.vue';
import WorkflowTagsDropdown from '@/features/shared/tags/components/WorkflowTagsDropdown.vue';
import {
DUPLICATE_MODAL_KEY,
EnterpriseEditionFeature,
IMPORT_WORKFLOW_URL_MODAL_KEY,
MAX_WORKFLOW_NAME_LENGTH,
MODAL_CONFIRM,
PLACEHOLDER_EMPTY_WORKFLOW_ID,
VIEWS,
WORKFLOW_MENU_ACTIONS,
WORKFLOW_SETTINGS_MODAL_KEY,
WORKFLOW_SHARE_MODAL_KEY,
IS_DRAFT_PUBLISH_ENABLED,
} from '@/app/constants';
import { PROJECT_MOVE_RESOURCE_MODAL } from '@/features/collaboration/projects/projects.constants';
import { ResourceType } from '@/features/collaboration/projects/projects.utils';
import { useProjectsStore } from '@/features/collaboration/projects/projects.store';
import { useSettingsStore } from '@/app/stores/settings.store';
import { useSourceControlStore } from '@/features/integrations/sourceControl.ee/sourceControl.store';
import { useTagsStore } from '@/features/shared/tags/tags.store';
import { useUIStore } from '@/app/stores/ui.store';
import { useUsersStore } from '@/features/settings/users/users.store';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { useRootStore } from '@n8n/stores/useRootStore';
import { useDocumentTitle } from '@/app/composables/useDocumentTitle';
import { useMessage } from '@/app/composables/useMessage';
import { usePageRedirectionHelper } from '@/app/composables/usePageRedirectionHelper';
import { useTelemetry } from '@/app/composables/useTelemetry';
import { useToast } from '@/app/composables/useToast';
import { useWorkflowHelpers } from '@/app/composables/useWorkflowHelpers';
import { useWorkflowSaving } from '@/app/composables/useWorkflowSaving';
import { nodeViewEventBus } from '@/app/event-bus';
import type { ActionDropdownItem, IWorkflowDb, IWorkflowToShare } from '@/Interface';
import type { IWorkflowDb } from '@/Interface';
import type { FolderShortInfo } from '@/features/core/folders/folders.types';
import { useFoldersStore } from '@/features/core/folders/folders.store';
import { useNpsSurveyStore } from '@/app/stores/npsSurvey.store';
import { ProjectTypes } from '@/features/collaboration/projects/projects.types';
import { sanitizeFilename } from '@n8n/utils/files/sanitize';
import { hasPermission } from '@/app/utils/rbac/permissions';
import type { PathItem } from '@n8n/design-system/components/N8nBreadcrumbs/Breadcrumbs.vue';
import { type BaseTextKey, useI18n } from '@n8n/i18n';
import WorkflowHeaderActions from '@/app/components/MainHeader/WorkflowHeaderActions.vue';
import WorkflowHeaderDraftPublishActions from '@/app/components/MainHeader/WorkflowHeaderDraftPublishActions.vue';
import { useI18n } from '@n8n/i18n';
import { getResourcePermissions } from '@n8n/permissions';
import type { WorkflowDataUpdate } from '@n8n/rest-api-client/api/workflows';
import { createEventBus } from '@n8n/utils/event-bus';
import { saveAs } from 'file-saver';
import {
computed,
onBeforeUnmount,
@@ -64,18 +40,15 @@ import {
useTemplateRef,
watch,
} from 'vue';
import { I18nT } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import {
N8nActionDropdown,
N8nBadge,
N8nButton,
N8nInlineTextEdit,
N8nTooltip,
} from '@n8n/design-system';
import WorkflowDescriptionPopover from './WorkflowDescriptionPopover.vue';
import { N8nBadge, N8nInlineTextEdit } from '@n8n/design-system';
import { useSettingsStore } from '@/app/stores/settings.store';
import { useUIStore } from '@/app/stores/ui.store';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { getWorkflowId } from '@/app/components/MainHeader/utils';
const WORKFLOW_NAME_BP_TO_WIDTH: { [key: string]: number } = {
XS: 150,
SM: 200,
@@ -103,12 +76,8 @@ const emit = defineEmits<{
const $style = useCssModule();
const rootStore = useRootStore();
const settingsStore = useSettingsStore();
const sourceControlStore = useSourceControlStore();
const tagsStore = useTagsStore();
const uiStore = useUIStore();
const usersStore = useUsersStore();
const workflowsStore = useWorkflowsStore();
const projectsStore = useProjectsStore();
const foldersStore = useFoldersStore();
@@ -124,16 +93,15 @@ const message = useMessage();
const toast = useToast();
const documentTitle = useDocumentTitle();
const workflowSaving = useWorkflowSaving({ router });
const workflowHelpers = useWorkflowHelpers();
const pageRedirectionHelper = usePageRedirectionHelper();
const isTagsEditEnabled = ref(false);
const appliedTagIds = ref<string[]>([]);
const tagsSaving = ref(false);
const importFileRef = ref<HTMLInputElement | undefined>();
const workflowHeaderActionsRef = useTemplateRef<
| InstanceType<typeof WorkflowHeaderActions>
| InstanceType<typeof WorkflowHeaderDraftPublishActions>
>('workflowHeaderActions');
const tagsEventBus = createEventBus();
const changeOwnerEventBus = createEventBus();
const hasChanged = (prev: string[], curr: string[]) => {
if (prev.length !== curr.length) {
@@ -152,115 +120,8 @@ const isWorkflowSaving = computed(() => {
return uiStore.isActionActive.workflowSaving;
});
const onWorkflowPage = computed(() => {
return route.meta && (route.meta.nodeView || route.meta.keepWorkflowAlive === true);
});
const onExecutionsTab = computed(() => {
return [
VIEWS.EXECUTION_HOME.toString(),
VIEWS.WORKFLOW_EXECUTIONS.toString(),
VIEWS.EXECUTION_PREVIEW,
].includes((route.name as string) || '');
});
const workflowPermissions = computed(() => getResourcePermissions(props.scopes).workflow);
const workflowMenuItems = computed<Array<ActionDropdownItem<WORKFLOW_MENU_ACTIONS>>>(() => {
const actions: Array<ActionDropdownItem<WORKFLOW_MENU_ACTIONS>> = [
{
id: WORKFLOW_MENU_ACTIONS.DOWNLOAD,
label: locale.baseText('menuActions.download'),
disabled: !onWorkflowPage.value,
},
];
if (workflowPermissions.value.move && projectsStore.isTeamProjectFeatureEnabled) {
actions.push({
id: WORKFLOW_MENU_ACTIONS.CHANGE_OWNER,
label: locale.baseText('workflows.item.changeOwner'),
disabled: isNewWorkflow.value,
});
}
if (!props.readOnly && !props.isArchived) {
actions.push({
id: WORKFLOW_MENU_ACTIONS.RENAME,
label: locale.baseText('generic.rename'),
disabled: !onWorkflowPage.value || workflowPermissions.value.update !== true,
});
}
if (
(workflowPermissions.value.update === true && !props.readOnly && !props.isArchived) ||
isNewWorkflow.value
) {
actions.unshift({
id: WORKFLOW_MENU_ACTIONS.DUPLICATE,
label: locale.baseText('menuActions.duplicate'),
disabled: !onWorkflowPage.value || !props.id,
});
actions.push(
{
id: WORKFLOW_MENU_ACTIONS.IMPORT_FROM_URL,
label: locale.baseText('menuActions.importFromUrl'),
disabled: !onWorkflowPage.value || onExecutionsTab.value,
},
{
id: WORKFLOW_MENU_ACTIONS.IMPORT_FROM_FILE,
label: locale.baseText('menuActions.importFromFile'),
disabled: !onWorkflowPage.value || onExecutionsTab.value,
},
);
}
if (hasPermission(['rbac'], { rbac: { scope: 'sourceControl:push' } })) {
actions.push({
id: WORKFLOW_MENU_ACTIONS.PUSH,
label: locale.baseText('menuActions.push'),
disabled:
!sourceControlStore.isEnterpriseSourceControlEnabled ||
!onWorkflowPage.value ||
onExecutionsTab.value ||
sourceControlStore.preferences.branchReadOnly,
});
}
actions.push({
id: WORKFLOW_MENU_ACTIONS.SETTINGS,
label: locale.baseText('generic.settings'),
disabled: !onWorkflowPage.value || isNewWorkflow.value,
});
if ((workflowPermissions.value.delete === true && !props.readOnly) || isNewWorkflow.value) {
if (props.isArchived) {
actions.push({
id: WORKFLOW_MENU_ACTIONS.UNARCHIVE,
label: locale.baseText('menuActions.unarchive'),
disabled: !onWorkflowPage.value || isNewWorkflow.value,
});
actions.push({
id: WORKFLOW_MENU_ACTIONS.DELETE,
label: locale.baseText('menuActions.delete'),
disabled: !onWorkflowPage.value || isNewWorkflow.value,
customClass: $style.deleteItem,
divided: true,
});
} else {
actions.push({
id: WORKFLOW_MENU_ACTIONS.ARCHIVE,
label: locale.baseText('menuActions.archive'),
disabled: !onWorkflowPage.value || isNewWorkflow.value,
customClass: $style.deleteItem,
divided: true,
});
}
}
return actions;
});
const workflowTagIds = computed(() => {
return (props.tags ?? []).map((tag) => (typeof tag === 'string' ? tag : tag.id));
});
@@ -292,24 +153,13 @@ watch(
},
);
function getWorkflowId(): string | undefined {
let id: string | undefined = undefined;
if (props.id !== PLACEHOLDER_EMPTY_WORKFLOW_ID) {
id = props.id;
} else if (route.params.name && route.params.name !== 'new') {
id = route.params.name as string;
}
return id;
}
async function onSaveButtonClick() {
// If the workflow is saving, do not allow another save
if (isWorkflowSaving.value) {
return;
}
const id = getWorkflowId();
const id = getWorkflowId(props.id, route.params.name);
const name = props.name;
const tags = props.tags as string[];
@@ -334,19 +184,6 @@ async function onSaveButtonClick() {
}
}
function onShareButtonClick() {
uiStore.openModalWithData({
name: WORKFLOW_SHARE_MODAL_KEY,
data: { id: props.id },
});
telemetry.track('User opened sharing modal', {
workflow_id: props.id,
user_id_sharer: usersStore.currentUser?.id,
sub_view: route.name === VIEWS.WORKFLOWS ? 'Workflows listing' : 'Workflow editor',
});
}
function onTagsEditEnable() {
appliedTagIds.value = (props.tags ?? []) as string[];
isTagsEditEnabled.value = true;
@@ -413,7 +250,7 @@ async function onNameSubmit(name: string) {
}
uiStore.addActiveAction('workflowSaving');
const id = getWorkflowId();
const id = getWorkflowId(props.id, route.params.name);
const saved = await workflowSaving.saveCurrentWorkflow({ name });
if (saved) {
showCreateWorkflowSuccessToast(id);
@@ -423,32 +260,6 @@ async function onNameSubmit(name: string) {
renameInput.value?.forceCancel();
}
async function handleFileImport(): Promise<void> {
const inputRef = importFileRef.value;
if (inputRef?.files && inputRef.files.length !== 0) {
const reader = new FileReader();
reader.onload = () => {
let workflowData: WorkflowDataUpdate;
try {
workflowData = JSON.parse(reader.result as string);
} catch (error) {
toast.showMessage({
title: locale.baseText('mainSidebar.showMessage.handleFileImport.title'),
message: locale.baseText('mainSidebar.showMessage.handleFileImport.message'),
type: 'error',
});
return;
} finally {
reader.onload = null;
inputRef.value = '';
}
nodeViewEventBus.emit('importWorkflowData', { data: workflowData });
};
reader.readAsText(inputRef.files[0]);
}
}
async function handleArchiveWorkflow() {
if (props.active) {
const archiveConfirmed = await message.confirm(
@@ -561,133 +372,6 @@ async function handleDeleteWorkflow() {
}
}
async function onWorkflowMenuSelect(action: WORKFLOW_MENU_ACTIONS): Promise<void> {
switch (action) {
case WORKFLOW_MENU_ACTIONS.DUPLICATE: {
uiStore.openModalWithData({
name: DUPLICATE_MODAL_KEY,
data: {
id: props.id,
name: props.name,
tags: props.tags,
parentFolderId: props.currentFolder?.id,
},
});
break;
}
case WORKFLOW_MENU_ACTIONS.RENAME: {
onNameToggle();
break;
}
case WORKFLOW_MENU_ACTIONS.DOWNLOAD: {
const workflowData = await workflowHelpers.getWorkflowDataToSave();
const { tags, ...data } = workflowData;
const exportData: IWorkflowToShare = {
...data,
meta: {
...props.meta,
instanceId: rootStore.instanceId,
},
tags: (tags ?? []).map((tagId) => {
const { usageCount, ...tag } = tagsStore.tagsById[tagId];
return tag;
}),
};
const blob = new Blob([JSON.stringify(exportData, null, 2)], {
type: 'application/json;charset=utf-8',
});
let name = props.name || 'unsaved_workflow';
name = sanitizeFilename(name);
telemetry.track('User exported workflow', { workflow_id: workflowData.id });
saveAs(blob, name + '.json');
break;
}
case WORKFLOW_MENU_ACTIONS.IMPORT_FROM_URL: {
uiStore.openModal(IMPORT_WORKFLOW_URL_MODAL_KEY);
break;
}
case WORKFLOW_MENU_ACTIONS.IMPORT_FROM_FILE: {
handleImportWorkflowFromFile();
break;
}
case WORKFLOW_MENU_ACTIONS.PUSH: {
try {
await onSaveButtonClick();
// Navigate to route with sourceControl param - modal will handle data loading and loading states
void router.push({
query: {
...route.query,
sourceControl: 'push',
},
});
} catch (error) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
switch (error.message) {
case 'source_control_not_connected':
toast.showError(
{ ...error, message: '' },
locale.baseText('settings.sourceControl.error.not.connected.title'),
locale.baseText('settings.sourceControl.error.not.connected.message'),
);
break;
default:
toast.showError(error, locale.baseText('error'));
}
}
break;
}
case WORKFLOW_MENU_ACTIONS.SETTINGS: {
uiStore.openModal(WORKFLOW_SETTINGS_MODAL_KEY);
break;
}
case WORKFLOW_MENU_ACTIONS.ARCHIVE: {
await handleArchiveWorkflow();
break;
}
case WORKFLOW_MENU_ACTIONS.UNARCHIVE: {
await handleUnarchiveWorkflow();
break;
}
case WORKFLOW_MENU_ACTIONS.DELETE: {
await handleDeleteWorkflow();
break;
}
case WORKFLOW_MENU_ACTIONS.CHANGE_OWNER: {
const workflowId = getWorkflowId();
if (!workflowId) {
return;
}
changeOwnerEventBus.once(
'resource-moved',
async () => await router.push({ name: VIEWS.WORKFLOWS }),
);
uiStore.openModalWithData({
name: PROJECT_MOVE_RESOURCE_MODAL,
data: {
resource: workflowsStore.workflowsById[workflowId],
resourceType: ResourceType.Workflow,
resourceTypeLabel: locale.baseText('generic.workflow').toLowerCase(),
eventBus: changeOwnerEventBus,
},
});
break;
}
default:
break;
}
}
function goToUpgrade() {
void pageRedirectionHelper.goToUpgrade('workflow_sharing', 'upgrade-workflow-sharing');
}
function getPersonalProjectToastContent() {
const title = locale.baseText('workflows.create.personal.toast.title');
if (!props.currentFolder) {
@@ -754,7 +438,9 @@ const onBreadcrumbsItemSelected = (item: PathItem) => {
};
const handleImportWorkflowFromFile = () => {
importFileRef.value?.click();
if (workflowHeaderActionsRef.value?.importFileRef) {
workflowHeaderActionsRef.value.importFileRef.click();
}
};
onMounted(() => {
@@ -774,12 +460,6 @@ onBeforeUnmount(() => {
nodeViewEventBus.off('renameWorkflow', onNameToggle);
nodeViewEventBus.off('addTag', onTagsEditEnable);
});
const onWorkflowActiveToggle = async (value: { id: string; active: boolean }) => {
if (!value.active) {
emit('workflow:deactivated');
}
};
</script>
<template>
@@ -879,93 +559,40 @@ const onWorkflowActiveToggle = async (value: { id: string; active: boolean }) =>
<PushConnectionTracker class="actions">
<WorkflowProductionChecklist v-if="!isNewWorkflow" :workflow="workflowsStore.workflow" />
<span :class="`activator ${$style.group}`">
<WorkflowActivator
:is-archived="isArchived"
:workflow-active="active"
:workflow-id="id"
:workflow-permissions="workflowPermissions"
@update:workflow-active="onWorkflowActiveToggle"
/>
</span>
<EnterpriseEdition :features="[EnterpriseEditionFeature.Sharing]">
<div :class="$style.group">
<CollaborationPane v-if="!isNewWorkflow" />
<N8nButton
type="secondary"
data-test-id="workflow-share-button"
@click="onShareButtonClick"
>
{{ i18n.baseText('workflowDetails.share') }}
</N8nButton>
</div>
<template #fallback>
<N8nTooltip>
<N8nButton type="secondary" :class="['mr-2xs', $style.disabledShareButton]">
{{ i18n.baseText('workflowDetails.share') }}
</N8nButton>
<template #content>
<I18nT
:keypath="
uiStore.contextBasedTranslationKeys.workflows.sharing.unavailable.description
.tooltip
"
tag="span"
scope="global"
>
<template #action>
<a @click="goToUpgrade">
{{
i18n.baseText(
uiStore.contextBasedTranslationKeys.workflows.sharing.unavailable
.button as BaseTextKey,
)
}}
</a>
</template>
</I18nT>
</template>
</N8nTooltip>
</template>
</EnterpriseEdition>
<div :class="$style.group">
<SaveButton
type="primary"
:saved="!uiStore.stateIsDirty && !isNewWorkflow"
:disabled="
isWorkflowSaving ||
readOnly ||
isArchived ||
(!isNewWorkflow && !workflowPermissions.update)
"
:is-saving="isWorkflowSaving"
:with-shortcut="!readOnly && !isArchived && workflowPermissions.update"
:shortcut-tooltip="i18n.baseText('saveWorkflowButton.hint')"
data-test-id="workflow-save-button"
@click="onSaveButtonClick"
/>
<WorkflowHistoryButton :workflow-id="props.id" :is-new-workflow="isNewWorkflow" />
</div>
<div :class="[$style.workflowMenuContainer, $style.group]">
<input
ref="importFileRef"
:class="$style.hiddenInput"
type="file"
data-test-id="workflow-import-input"
@change="handleFileImport()"
/>
<N8nActionDropdown
:items="workflowMenuItems"
data-test-id="workflow-menu"
@select="onWorkflowMenuSelect"
/>
</div>
<WorkflowHeaderDraftPublishActions
v-if="IS_DRAFT_PUBLISH_ENABLED"
:id="id"
ref="workflowHeaderActions"
:tags="tags"
:name="name"
:meta="meta"
:read-only="readOnly"
:is-archived="isArchived"
:is-new-workflow="isNewWorkflow"
:workflow-permissions="workflowPermissions"
@workflow:saved="onSaveButtonClick"
/>
<WorkflowHeaderActions
v-else
:id="id"
ref="workflowHeaderActions"
:name="name"
:tags="tags"
:current-folder="currentFolder"
:meta="meta"
:read-only="readOnly"
:is-archived="isArchived"
:active="active"
:is-new-workflow="isNewWorkflow"
:workflow-permissions="workflowPermissions"
@workflow:saved="onSaveButtonClick"
@workflow:deactivated="emit('workflow:deactivated')"
/>
</PushConnectionTracker>
</div>
</template>
<style scoped lang="scss">
$--text-line-height: 24px;
$--header-spacing: 20px;
.name-container {
@@ -982,18 +609,6 @@ $--header-spacing: 20px;
padding: var(--spacing--3xs) var(--spacing--4xs) var(--spacing--4xs);
}
.activator {
color: $custom-font-dark;
font-weight: var(--font-weight--regular);
font-size: 13px;
line-height: $--text-line-height;
align-items: center;
> span {
margin-right: 5px;
}
}
.add-tag {
font-size: 12px;
padding: 20px 0; // to be more clickable
@@ -1072,23 +687,6 @@ $--header-spacing: 20px;
padding: var(--spacing--3xs) var(--spacing--4xs) var(--spacing--4xs);
}
.group {
display: flex;
gap: var(--spacing--xs);
}
.hiddenInput {
display: none;
}
.deleteItem {
color: var(--color--danger);
}
.disabledShareButton {
cursor: not-allowed;
}
.closeNodeViewDiscovery {
position: absolute;
right: var(--spacing--xs);

View File

@@ -0,0 +1,194 @@
<script lang="ts" setup>
import { computed, useCssModule, useTemplateRef } from 'vue';
import { useUIStore } from '@/app/stores/ui.store';
import { type BaseTextKey, useI18n } from '@n8n/i18n';
import { N8nButton, N8nTooltip } from '@n8n/design-system';
import type { IWorkflowDb } from '@/Interface';
import { EnterpriseEditionFeature, WORKFLOW_SHARE_MODAL_KEY, VIEWS } from '@/app/constants';
import WorkflowActivator from '@/app/components/WorkflowActivator.vue';
import EnterpriseEdition from '@/app/components/EnterpriseEdition.ee.vue';
import CollaborationPane from '@/features/collaboration/collaboration/components/CollaborationPane.vue';
import WorkflowHistoryButton from '@/features/workflows/workflowHistory/components/WorkflowHistoryButton.vue';
import SaveButton from '@/app/components/SaveButton.vue';
import { I18nT } from 'vue-i18n';
import type { PermissionsRecord } from '@n8n/permissions';
import { useTelemetry } from '@/app/composables/useTelemetry';
import { useUsersStore } from '@/features/settings/users/users.store';
import { useRoute } from 'vue-router';
import { usePageRedirectionHelper } from '@/app/composables/usePageRedirectionHelper';
import type { FolderShortInfo } from '@/features/core/folders/folders.types';
import ActionsMenu from '@/app/components/MainHeader/ActionsDropdownMenu.vue';
const i18n = useI18n();
const uiStore = useUIStore();
const telemetry = useTelemetry();
const usersStore = useUsersStore();
const route = useRoute();
const pageRedirectionHelper = usePageRedirectionHelper();
const $style = useCssModule();
const props = defineProps<{
readOnly?: boolean;
id: IWorkflowDb['id'];
tags: IWorkflowDb['tags'];
name: IWorkflowDb['name'];
meta: IWorkflowDb['meta'];
active: IWorkflowDb['active'];
currentFolder?: FolderShortInfo;
isArchived: IWorkflowDb['isArchived'];
isNewWorkflow: boolean;
workflowPermissions: PermissionsRecord['workflow'];
}>();
const emit = defineEmits<{
'workflow:deactivated': [];
'workflow:saved': [];
}>();
const isWorkflowSaving = computed(() => {
return uiStore.isActionActive.workflowSaving;
});
const actionsMenuRef = useTemplateRef<InstanceType<typeof ActionsMenu>>('actionsMenu');
const importFileRef = computed(() => actionsMenuRef.value?.importFileRef);
const onWorkflowActiveToggle = async (value: { id: string; active: boolean }) => {
if (!value.active) {
emit('workflow:deactivated');
}
};
function onShareButtonClick() {
uiStore.openModalWithData({
name: WORKFLOW_SHARE_MODAL_KEY,
data: { id: props.id },
});
telemetry.track('User opened sharing modal', {
workflow_id: props.id,
user_id_sharer: usersStore.currentUser?.id,
sub_view: route.name === VIEWS.WORKFLOWS ? 'Workflows listing' : 'Workflow editor',
});
}
function goToUpgrade() {
void pageRedirectionHelper.goToUpgrade('workflow_sharing', 'upgrade-workflow-sharing');
}
defineExpose({
importFileRef,
});
</script>
<template>
<div :class="$style.container">
<span :class="[$style.activator, $style.group]">
<WorkflowActivator
:is-archived="isArchived"
:workflow-active="active"
:workflow-id="id"
:workflow-permissions="workflowPermissions"
@update:workflow-active="onWorkflowActiveToggle"
/>
</span>
<EnterpriseEdition :features="[EnterpriseEditionFeature.Sharing]">
<div :class="$style.group">
<CollaborationPane v-if="!isNewWorkflow" />
<N8nButton
type="secondary"
data-test-id="workflow-share-button"
@click="onShareButtonClick"
>
{{ i18n.baseText('workflowDetails.share') }}
</N8nButton>
</div>
<template #fallback>
<N8nTooltip>
<N8nButton type="secondary" :class="['mr-2xs', $style.disabledShareButton]">
{{ i18n.baseText('workflowDetails.share') }}
</N8nButton>
<template #content>
<I18nT
:keypath="
uiStore.contextBasedTranslationKeys.workflows.sharing.unavailable.description
.tooltip
"
tag="span"
scope="global"
>
<template #action>
<a @click="goToUpgrade">
{{
i18n.baseText(
uiStore.contextBasedTranslationKeys.workflows.sharing.unavailable
.button as BaseTextKey,
)
}}
</a>
</template>
</I18nT>
</template>
</N8nTooltip>
</template>
</EnterpriseEdition>
<div :class="$style.group">
<SaveButton
type="primary"
:saved="!uiStore.stateIsDirty && !isNewWorkflow"
:disabled="
isWorkflowSaving ||
readOnly ||
isArchived ||
(!isNewWorkflow && !workflowPermissions.update)
"
:is-saving="isWorkflowSaving"
:with-shortcut="!readOnly && !isArchived && workflowPermissions.update"
:shortcut-tooltip="i18n.baseText('saveWorkflowButton.hint')"
data-test-id="workflow-save-button"
@click="$emit('workflow:saved')"
/>
<WorkflowHistoryButton :workflow-id="props.id" :is-new-workflow="isNewWorkflow" />
<ActionsMenu
:id="id"
ref="actionsMenu"
:workflow-permissions="workflowPermissions"
:is-new-workflow="isNewWorkflow"
:read-only="readOnly"
:is-archived="isArchived"
:name="name"
:tags="tags"
:current-folder="currentFolder"
:meta="meta"
@workflow:saved="$emit('workflow:saved')"
/>
</div>
</div>
</template>
<style module lang="scss">
$--text-line-height: 24px;
.container {
display: contents;
}
.activator {
color: $custom-font-dark;
font-weight: var(--font-weight--regular);
font-size: 13px;
line-height: $--text-line-height;
align-items: center;
> span {
margin-right: 5px;
}
}
.group {
display: flex;
gap: var(--spacing--xs);
}
.disabledShareButton {
cursor: not-allowed;
}
</style>

View File

@@ -0,0 +1,411 @@
import { createComponentRenderer } from '@/__tests__/render';
import { type MockedStore, mockedStore } from '@/__tests__/utils';
import { createTestingPinia } from '@pinia/testing';
import userEvent from '@testing-library/user-event';
import { within } from '@testing-library/vue';
import WorkflowHeaderDraftPublishActions from '@/app/components/MainHeader/WorkflowHeaderDraftPublishActions.vue';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { useUIStore } from '@/app/stores/ui.store';
import { WORKFLOW_PUBLISH_MODAL_KEY } from '@/app/constants';
import { STORES } from '@n8n/stores';
import type { INodeUi } from '@/Interface';
vi.mock('vue-router', async (importOriginal) => ({
...(await importOriginal()),
useRoute: vi.fn().mockReturnValue({
params: { name: 'test' },
query: {},
}),
useRouter: vi.fn().mockReturnValue({
replace: vi.fn(),
push: vi.fn().mockResolvedValue(undefined),
currentRoute: {
value: {
params: { name: 'test' },
query: {},
},
},
}),
}));
const mockSaveCurrentWorkflow = vi.fn().mockResolvedValue(true);
vi.mock('@/app/composables/useWorkflowSaving', () => ({
useWorkflowSaving: () => ({
saveCurrentWorkflow: mockSaveCurrentWorkflow,
}),
}));
const initialState = {
[STORES.SETTINGS]: {
settings: {
enterprise: {},
},
},
};
const defaultWorkflowProps = {
id: '1',
name: 'Test Workflow',
tags: [],
meta: {},
isArchived: false,
isNewWorkflow: false,
workflowPermissions: {
create: true,
read: true,
update: true,
delete: true,
},
};
const renderComponent = createComponentRenderer(WorkflowHeaderDraftPublishActions, {
props: defaultWorkflowProps,
pinia: createTestingPinia({ initialState }),
global: {
stubs: {
ActionsMenu: {
template: '<div data-test-id="actions-menu-stub"></div>',
},
WorkflowHistoryButton: {
template: '<div data-test-id="workflow-history-button-stub"></div>',
},
},
},
});
const createMockActiveVersion = (versionId: string) => ({
versionId,
authors: 'Test Author',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
workflowPublishHistory: [],
name: 'Published Version',
description: null,
});
describe('WorkflowHeaderDraftPublishActions', () => {
let workflowsStore: MockedStore<typeof useWorkflowsStore>;
let uiStore: MockedStore<typeof useUIStore>;
beforeEach(() => {
workflowsStore = mockedStore(useWorkflowsStore);
uiStore = mockedStore(useUIStore);
// Default workflow state
workflowsStore.workflow = {
id: '1',
name: 'Test Workflow',
active: false,
activeVersionId: null,
activeVersion: null,
versionId: 'version-1',
isArchived: false,
createdAt: Date.now(),
updatedAt: Date.now(),
nodes: [],
connections: {},
};
workflowsStore.workflowTriggerNodes = [];
uiStore.stateIsDirty = false;
uiStore.isActionActive = { workflowSaving: false };
mockSaveCurrentWorkflow.mockClear();
mockSaveCurrentWorkflow.mockResolvedValue(true);
});
afterEach(() => {
vi.clearAllMocks();
});
describe('Active version indicator', () => {
it('should not show active version indicator when there is no active version', () => {
workflowsStore.workflow.activeVersion = null;
const { queryByTestId } = renderComponent();
expect(queryByTestId('workflow-active-version-indicator')).not.toBeInTheDocument();
});
it('should show active version indicator when there is an active version', () => {
workflowsStore.workflow.activeVersion = createMockActiveVersion('active-version-1');
const { getByTestId } = renderComponent();
expect(getByTestId('workflow-active-version-indicator')).toBeInTheDocument();
});
});
describe('Publish button behavior', () => {
it('should open publish modal when clicked and workflow is saved', async () => {
const openModalSpy = vi.spyOn(uiStore, 'openModalWithData');
uiStore.stateIsDirty = false;
const { getByTestId } = renderComponent();
await userEvent.click(getByTestId('workflow-open-publish-modal-button'));
expect(mockSaveCurrentWorkflow).not.toHaveBeenCalled();
expect(openModalSpy).toHaveBeenCalledWith({
name: WORKFLOW_PUBLISH_MODAL_KEY,
data: {},
});
});
it('should save workflow first when dirty then open publish modal', async () => {
const openModalSpy = vi.spyOn(uiStore, 'openModalWithData');
uiStore.stateIsDirty = true;
const { getByTestId } = renderComponent();
await userEvent.click(getByTestId('workflow-open-publish-modal-button'));
expect(mockSaveCurrentWorkflow).toHaveBeenCalledWith({}, true);
expect(openModalSpy).toHaveBeenCalledWith({
name: WORKFLOW_PUBLISH_MODAL_KEY,
data: {},
});
});
it('should save workflow first when isNewWorkflow is true then open publish modal', async () => {
const openModalSpy = vi.spyOn(uiStore, 'openModalWithData');
uiStore.stateIsDirty = false;
const { getByTestId } = renderComponent({
props: {
...defaultWorkflowProps,
isNewWorkflow: true,
},
});
await userEvent.click(getByTestId('workflow-open-publish-modal-button'));
expect(mockSaveCurrentWorkflow).toHaveBeenCalledWith({}, true);
expect(openModalSpy).toHaveBeenCalledWith({
name: WORKFLOW_PUBLISH_MODAL_KEY,
data: {},
});
});
it('should not open publish modal if save fails', async () => {
const openModalSpy = vi.spyOn(uiStore, 'openModalWithData');
uiStore.stateIsDirty = true;
mockSaveCurrentWorkflow.mockResolvedValue(false);
const { getByTestId } = renderComponent();
await userEvent.click(getByTestId('workflow-open-publish-modal-button'));
expect(mockSaveCurrentWorkflow).toHaveBeenCalled();
expect(openModalSpy).not.toHaveBeenCalled();
});
});
describe('Publish indicator visibility', () => {
const triggerNode: INodeUi = {
id: 'trigger-1',
name: 'Webhook Trigger',
type: 'n8n-nodes-base.webhook',
typeVersion: 1,
position: [0, 0],
parameters: {},
disabled: false,
};
it('should not show publish indicator when there are no trigger nodes', () => {
workflowsStore.workflowTriggerNodes = [];
workflowsStore.workflow.versionId = 'version-1';
workflowsStore.workflow.activeVersion = createMockActiveVersion('version-2');
uiStore.stateIsDirty = true;
const { queryByTestId } = renderComponent();
expect(queryByTestId('workflow-publish-indicator')).not.toBeInTheDocument();
});
it('should not show publish indicator when trigger node is disabled', () => {
workflowsStore.workflowTriggerNodes = [{ ...triggerNode, disabled: true }];
workflowsStore.workflow.versionId = 'version-1';
workflowsStore.workflow.activeVersion = createMockActiveVersion('version-2');
uiStore.stateIsDirty = true;
const { queryByTestId } = renderComponent();
expect(queryByTestId('workflow-publish-indicator')).not.toBeInTheDocument();
});
it('should show publish indicator when there are unpublished changes (versionId mismatch)', () => {
workflowsStore.workflowTriggerNodes = [triggerNode];
workflowsStore.workflow.versionId = 'version-1';
workflowsStore.workflow.activeVersion = createMockActiveVersion('version-2');
uiStore.stateIsDirty = false;
const { getByTestId } = renderComponent();
expect(getByTestId('workflow-publish-indicator')).toBeInTheDocument();
});
it('should show publish indicator when state is dirty', () => {
workflowsStore.workflowTriggerNodes = [triggerNode];
workflowsStore.workflow.versionId = 'version-1';
workflowsStore.workflow.activeVersion = createMockActiveVersion('version-1');
uiStore.stateIsDirty = true;
const { getByTestId } = renderComponent();
expect(getByTestId('workflow-publish-indicator')).toBeInTheDocument();
});
it('should not show publish indicator when versions match and state is not dirty', () => {
workflowsStore.workflowTriggerNodes = [triggerNode];
workflowsStore.workflow.versionId = 'version-1';
workflowsStore.workflow.activeVersion = createMockActiveVersion('version-1');
uiStore.stateIsDirty = false;
const { queryByTestId } = renderComponent();
expect(queryByTestId('workflow-publish-indicator')).not.toBeInTheDocument();
});
it('should show publish indicator when workflow has never been published (no active version)', () => {
workflowsStore.workflowTriggerNodes = [triggerNode];
workflowsStore.workflow.versionId = 'version-1';
workflowsStore.workflow.activeVersion = null;
uiStore.stateIsDirty = false;
const { getByTestId } = renderComponent();
expect(getByTestId('workflow-publish-indicator')).toBeInTheDocument();
});
});
describe('Save button state', () => {
it('should show "Saved" label when workflow is saved', () => {
uiStore.stateIsDirty = false;
const { getByText } = renderComponent();
expect(getByText('Saved')).toBeInTheDocument();
});
it('should show save button when workflow has unsaved changes', () => {
uiStore.stateIsDirty = true;
const { queryByText, getByTestId } = renderComponent();
expect(queryByText('Saved')).not.toBeInTheDocument();
expect(getByTestId('workflow-save-button')).toBeInTheDocument();
});
it('should emit workflow:saved event when save button is clicked', async () => {
uiStore.stateIsDirty = true;
const { getByTestId, emitted } = renderComponent();
await userEvent.click(getByTestId('workflow-save-button'));
expect(emitted('workflow:saved')).toBeTruthy();
});
it('should contain disabled save button when update permission is missing', () => {
uiStore.stateIsDirty = true;
const { getByTestId } = renderComponent({
props: {
...defaultWorkflowProps,
workflowPermissions: { ...defaultWorkflowProps.workflowPermissions, update: false },
},
});
const saveButtonContainer = getByTestId('workflow-save-button');
const saveButton = within(saveButtonContainer).getByRole('button');
expect(saveButton).toBeDisabled();
});
it('should contain disabled save button when workflow is saving', () => {
uiStore.stateIsDirty = true;
uiStore.isActionActive.workflowSaving = true;
const { getByTestId } = renderComponent();
const saveButtonContainer = getByTestId('workflow-save-button');
const saveButton = within(saveButtonContainer).getByRole('button');
expect(saveButton).toBeDisabled();
});
});
describe('Read-only mode', () => {
it('should render save button when not read-only and has update permission', () => {
uiStore.stateIsDirty = true;
const { getByTestId } = renderComponent({
props: {
...defaultWorkflowProps,
readOnly: false,
workflowPermissions: { update: true },
},
});
const saveButtonContainer = getByTestId('workflow-save-button');
const saveButton = within(saveButtonContainer).getByRole('button');
expect(saveButtonContainer).toBeInTheDocument();
expect(saveButton).not.toBeDisabled();
});
it('should render disabled save button when read-only', () => {
uiStore.stateIsDirty = true;
const { getByTestId } = renderComponent({
props: {
...defaultWorkflowProps,
readOnly: true,
},
});
const saveButtonContainer = getByTestId('workflow-save-button');
const saveButton = within(saveButtonContainer).getByRole('button');
expect(saveButtonContainer).toBeInTheDocument();
expect(saveButton).toBeDisabled();
});
it('should render publish button when read-only', () => {
const { getByTestId } = renderComponent({
props: {
...defaultWorkflowProps,
readOnly: true,
},
});
const publishButton = getByTestId('workflow-open-publish-modal-button');
expect(publishButton).toBeInTheDocument();
});
});
describe('Archived workflow', () => {
it('should not render publish button when workflow is archived', () => {
const { queryByTestId } = renderComponent({
props: {
...defaultWorkflowProps,
isArchived: true,
},
});
expect(queryByTestId('workflow-open-publish-modal-button')).not.toBeInTheDocument();
});
it('should render disabled save button when workflow is archived', () => {
uiStore.stateIsDirty = true;
const { getByTestId } = renderComponent({
props: {
...defaultWorkflowProps,
isArchived: true,
},
});
const saveButtonContainer = getByTestId('workflow-save-button');
const saveButton = within(saveButtonContainer).getByRole('button');
expect(saveButtonContainer).toBeInTheDocument();
expect(saveButton).toBeDisabled();
});
});
});

View File

@@ -0,0 +1,186 @@
<script lang="ts" setup>
import ActionsDropdownMenu from '@/app/components/MainHeader/ActionsDropdownMenu.vue';
import WorkflowHistoryButton from '@/features/workflows/workflowHistory/components/WorkflowHistoryButton.vue';
import type { FolderShortInfo } from '@/features/core/folders/folders.types';
import type { IWorkflowDb } from '@/Interface';
import type { PermissionsRecord } from '@n8n/permissions';
import { computed, useTemplateRef } from 'vue';
import { WORKFLOW_PUBLISH_MODAL_KEY } from '@/app/constants';
import { N8nButton, N8nIcon, N8nTooltip } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { useUIStore } from '@/app/stores/ui.store';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import SaveButton from '@/app/components/SaveButton.vue';
import TimeAgo from '@/app/components/TimeAgo.vue';
import { getActivatableTriggerNodes } from '@/app/utils/nodeTypesUtils';
import { useWorkflowSaving } from '@/app/composables/useWorkflowSaving';
import { useRouter } from 'vue-router';
const props = defineProps<{
readOnly?: boolean;
id: IWorkflowDb['id'];
tags: IWorkflowDb['tags'];
name: IWorkflowDb['name'];
meta: IWorkflowDb['meta'];
currentFolder?: FolderShortInfo;
isArchived: IWorkflowDb['isArchived'];
isNewWorkflow: boolean;
workflowPermissions: PermissionsRecord['workflow'];
}>();
defineEmits<{
'workflow:saved': [];
}>();
const actionsMenuRef = useTemplateRef<InstanceType<typeof ActionsDropdownMenu>>('actionsMenu');
const locale = useI18n();
const uiStore = useUIStore();
const workflowsStore = useWorkflowsStore();
const i18n = useI18n();
const router = useRouter();
const { saveCurrentWorkflow } = useWorkflowSaving({ router });
const isWorkflowSaving = computed(() => {
return uiStore.isActionActive.workflowSaving;
});
const importFileRef = computed(() => actionsMenuRef.value?.importFileRef);
const onPublishButtonClick = async () => {
// If there are unsaved changes, save the workflow first
if (uiStore.stateIsDirty || props.isNewWorkflow) {
const saved = await saveCurrentWorkflow({}, true);
if (!saved) {
// If save failed, don't open the modal
return;
}
}
uiStore.openModalWithData({
name: WORKFLOW_PUBLISH_MODAL_KEY,
data: {},
});
};
const foundTriggers = computed(() =>
getActivatableTriggerNodes(workflowsStore.workflowTriggerNodes),
);
const containsTrigger = computed((): boolean => {
return foundTriggers.value.length > 0;
});
const isWorkflowSaved = computed(() => {
return !uiStore.stateIsDirty && !props.isNewWorkflow;
});
const showPublishIndicator = computed(() => {
if (!containsTrigger.value) {
return false;
}
return (
(workflowsStore.workflow.versionId &&
workflowsStore.workflow.versionId !== workflowsStore.workflow.activeVersion?.versionId) ||
uiStore.stateIsDirty
);
});
const activeVersion = computed(() => workflowsStore.workflow.activeVersion);
defineExpose({
importFileRef,
});
</script>
<template>
<div :class="$style.container">
<div
v-if="activeVersion"
:class="$style.activeVersionIndicator"
data-test-id="workflow-active-version-indicator"
>
<N8nTooltip>
<template #content>
{{ activeVersion.name }}<br />{{ i18n.baseText('workflowHistory.item.active') }}
<TimeAgo :date="activeVersion.createdAt" />
</template>
<N8nIcon icon="circle-check" color="success" size="xlarge" :class="$style.icon" />
</N8nTooltip>
</div>
<div v-if="!isArchived && workflowPermissions.update" :class="$style.publishButtonWrapper">
<N8nButton
type="secondary"
data-test-id="workflow-open-publish-modal-button"
@click="onPublishButtonClick"
>
{{ locale.baseText('workflows.publish') }}
</N8nButton>
<span
v-if="showPublishIndicator"
:class="$style.publishButtonIndicator"
data-test-id="workflow-publish-indicator"
></span>
</div>
<SaveButton
type="primary"
:saved="isWorkflowSaved"
:disabled="
isWorkflowSaving ||
readOnly ||
isArchived ||
(!isNewWorkflow && !workflowPermissions.update)
"
:is-saving="isWorkflowSaving"
:with-shortcut="!readOnly && !isArchived && workflowPermissions.update"
:shortcut-tooltip="i18n.baseText('saveWorkflowButton.hint')"
data-test-id="workflow-save-button"
@click="$emit('workflow:saved')"
/>
<WorkflowHistoryButton :workflow-id="props.id" :is-new-workflow="isNewWorkflow" />
<ActionsDropdownMenu
:id="id"
ref="actionsMenu"
:workflow-permissions="workflowPermissions"
:is-new-workflow="isNewWorkflow"
:read-only="readOnly"
:is-archived="isArchived"
:name="name"
:tags="tags"
:current-folder="currentFolder"
:meta="meta"
@workflow:saved="$emit('workflow:saved')"
/>
</div>
</template>
<style lang="scss" module>
.container {
display: contents;
}
.activeVersionIndicator {
display: inline-flex;
align-items: center;
.icon:focus {
outline: none;
}
}
.publishButtonWrapper {
position: relative;
display: inline-block;
}
.publishButtonIndicator {
position: absolute;
top: -2px;
right: -2px;
width: 7px;
height: 7px;
background-color: var(--color--primary);
border-radius: 50%;
box-shadow: 0 0 0 2px var(--color--background--light-3);
}
</style>

View File

@@ -0,0 +1,289 @@
<script setup lang="ts">
import { computed, ref, h, onMounted, onBeforeUnmount, useTemplateRef } from 'vue';
import type { VNode } from 'vue';
import Modal from '@/app/components/Modal.vue';
import {
WORKFLOW_PUBLISH_MODAL_KEY,
WORKFLOW_ACTIVATION_CONFLICTING_WEBHOOK_MODAL_KEY,
} from '@/app/constants';
import { telemetry } from '@/app/plugins/telemetry';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { createEventBus } from '@n8n/utils/event-bus';
import { useI18n } from '@n8n/i18n';
import { N8nHeading, N8nCallout, N8nButton } from '@n8n/design-system';
import WorkflowPublishForm from '@/app/components/WorkflowPublishForm.vue';
import { getActivatableTriggerNodes } from '@/app/utils/nodeTypesUtils';
import { useToast } from '@/app/composables/useToast';
import { useWorkflowActivate } from '@/app/composables/useWorkflowActivate';
import { useWorkflowHelpers } from '@/app/composables/useWorkflowHelpers';
import { useCredentialsStore } from '@/features/credentials/credentials.store';
import { useUIStore } from '@/app/stores/ui.store';
import { OPEN_AI_API_CREDENTIAL_TYPE } from 'n8n-workflow';
import type { INodeUi } from '@/Interface';
import type { IUsedCredential } from '@/features/credentials/credentials.types';
import WorkflowActivationErrorMessage from '@/app/components/WorkflowActivationErrorMessage.vue';
import { generateVersionName } from '@/features/workflows/workflowHistory/utils';
const modalBus = createEventBus();
const i18n = useI18n();
const workflowsStore = useWorkflowsStore();
const credentialsStore = useCredentialsStore();
const uiStore = useUIStore();
const { showMessage } = useToast();
const workflowActivate = useWorkflowActivate();
const workflowHelpers = useWorkflowHelpers();
const publishForm = useTemplateRef<InstanceType<typeof WorkflowPublishForm>>('publishForm');
const description = ref('');
const versionName = ref('');
const foundTriggers = computed(() =>
getActivatableTriggerNodes(workflowsStore.workflowTriggerNodes),
);
const containsTrigger = computed((): boolean => {
return foundTriggers.value.length > 0;
});
const wfHasAnyChanges = computed(() => {
return workflowsStore.workflow.versionId !== workflowsStore.workflow.activeVersion?.versionId;
});
const hasNodeIssues = computed(() => workflowsStore.nodesIssuesExist);
const inputsDisabled = computed(() => {
return !wfHasAnyChanges.value || !containsTrigger.value || hasNodeIssues.value;
});
const isPublishDisabled = computed(() => {
return inputsDisabled.value || versionName.value.trim().length === 0;
});
type WorkflowPublishCalloutId = 'noTrigger' | 'nodeIssues' | 'noChanges';
const activeCalloutId = computed<WorkflowPublishCalloutId | null>(() => {
if (!containsTrigger.value) {
return 'noTrigger';
}
if (hasNodeIssues.value) {
return 'nodeIssues';
}
if (!wfHasAnyChanges.value) {
return 'noChanges';
}
return null;
});
function onModalOpened() {
publishForm.value?.focusInput();
}
onMounted(() => {
if (!versionName.value && !inputsDisabled.value) {
versionName.value = generateVersionName(workflowsStore.workflow.versionId);
}
modalBus.on('opened', onModalOpened);
});
onBeforeUnmount(() => {
modalBus.off('opened', onModalOpened);
});
function findManagedOpenAiCredentialId(
usedCredentials: Record<string, IUsedCredential>,
): string | undefined {
return Object.keys(usedCredentials).find((credentialId) => {
const credential = credentialsStore.state.credentials[credentialId];
return credential.isManaged && credential.type === OPEN_AI_API_CREDENTIAL_TYPE;
});
}
function hasActiveNodeUsingCredential(nodes: INodeUi[], credentialId: string): boolean {
return nodes.some(
(node) =>
node?.credentials?.[OPEN_AI_API_CREDENTIAL_TYPE]?.id === credentialId && !node.disabled,
);
}
/**
* Determines if the warning for free AI credits should be shown in the workflow.
*
* This computed property evaluates whether to display a warning about free AI credits
* in the workflow. The warning is shown when both conditions are met:
* 1. The workflow uses managed OpenAI API credentials
* 2. Those credentials are associated with at least one enabled node
*
*/
const shouldShowFreeAiCreditsWarning = computed((): boolean => {
const usedCredentials = workflowsStore?.usedCredentials;
if (!usedCredentials) return false;
const managedOpenAiCredentialId = findManagedOpenAiCredentialId(usedCredentials);
if (!managedOpenAiCredentialId) return false;
return hasActiveNodeUsingCredential(workflowsStore.allNodes, managedOpenAiCredentialId);
});
async function displayActivationError() {
let errorMessage: string | VNode;
try {
const errorData = await workflowsStore.getActivationError(workflowsStore.workflow.id);
if (errorData === undefined) {
errorMessage = i18n.baseText(
'workflowActivator.showMessage.displayActivationError.message.errorDataUndefined',
);
} else {
errorMessage = h(WorkflowActivationErrorMessage, {
message: errorData,
});
}
} catch {
errorMessage = i18n.baseText(
'workflowActivator.showMessage.displayActivationError.message.catchBlock',
);
}
showMessage({
title: i18n.baseText('workflowActivator.showMessage.displayActivationError.title'),
message: errorMessage,
type: 'warning',
duration: 0,
});
}
async function handlePublish() {
if (isPublishDisabled.value) {
return;
}
// Check for conflicting webhooks before activating
const conflictData = await workflowHelpers.checkConflictingWebhooks(workflowsStore.workflow.id);
if (conflictData) {
const { trigger, conflict } = conflictData;
const conflictingWorkflow = await workflowsStore.fetchWorkflow(conflict.workflowId);
uiStore.openModalWithData({
name: WORKFLOW_ACTIVATION_CONFLICTING_WEBHOOK_MODAL_KEY,
data: {
triggerType: trigger.type,
workflowName: conflictingWorkflow.name,
...conflict,
},
});
return;
}
// Activate the workflow
const success = await workflowActivate.publishWorkflow(
workflowsStore.workflow.id,
workflowsStore.workflow.versionId,
{
name: versionName.value,
description: description.value,
},
);
if (success) {
// Show AI credits warning if applicable
if (shouldShowFreeAiCreditsWarning.value) {
showMessage({
title: i18n.baseText('freeAi.credits.showWarning.workflow.activation.title'),
message: i18n.baseText('freeAi.credits.showWarning.workflow.activation.description'),
type: 'warning',
duration: 0,
});
}
telemetry.track('User published version from canvas', {
workflow_id: workflowsStore.workflow.id,
});
// For now, just close the modal after successful activation
modalBus.emit('close');
} else {
// Display activation error if it fails
await displayActivationError();
}
}
</script>
<template>
<Modal
max-width="500px"
max-height="85vh"
:name="WORKFLOW_PUBLISH_MODAL_KEY"
:center="true"
:show-close="true"
:event-bus="modalBus"
>
<template #header>
<N8nHeading size="xlarge">{{ i18n.baseText('workflows.publishModal.title') }}</N8nHeading>
</template>
<template #content>
<div :class="$style.content">
<N8nCallout v-if="activeCalloutId === 'noTrigger'" theme="danger" icon="status-error">
{{ i18n.baseText('workflows.publishModal.noTriggerMessage') }}
</N8nCallout>
<N8nCallout v-else-if="activeCalloutId === 'nodeIssues'" theme="danger" icon="status-error">
<strong>
{{
i18n.baseText('workflowActivator.showMessage.activeChangedNodesIssuesExistTrue.title')
}}
</strong>
<br />
{{
i18n.baseText('workflowActivator.showMessage.activeChangedNodesIssuesExistTrue.message')
}}
</N8nCallout>
<N8nCallout v-else-if="activeCalloutId === 'noChanges'" theme="warning">
{{ i18n.baseText('workflows.publishModal.noChanges') }}
</N8nCallout>
<WorkflowPublishForm
ref="publishForm"
v-model:version-name="versionName"
v-model:description="description"
:disabled="inputsDisabled"
version-name-test-id="workflow-publish-version-name-input"
description-test-id="workflow-publish-description-input"
@submit="handlePublish"
/>
<div :class="$style.actions">
<N8nButton
type="secondary"
:label="i18n.baseText('generic.cancel')"
data-test-id="workflow-publish-cancel-button"
@click="modalBus.emit('close')"
/>
<N8nButton
:disabled="isPublishDisabled"
:label="i18n.baseText('workflows.publish')"
data-test-id="workflow-publish-button"
@click="handlePublish"
/>
</div>
</div>
</template>
</Modal>
</template>
<style lang="scss" module>
.content {
display: flex;
flex-direction: column;
gap: var(--spacing--lg);
}
.actions {
display: flex;
justify-content: flex-end;
gap: var(--spacing--xs);
}
</style>

View File

@@ -0,0 +1,11 @@
import { PLACEHOLDER_EMPTY_WORKFLOW_ID } from '@/app/constants/workflows';
export const getWorkflowId = (propId: string, routeName: string | string[]) => {
let id: string | undefined = undefined;
if (propId !== PLACEHOLDER_EMPTY_WORKFLOW_ID) {
id = propId;
} else if (routeName && routeName !== 'new') {
id = routeName as string;
}
return id;
};

View File

@@ -23,12 +23,15 @@ import {
WORKFLOW_DIFF_MODAL_KEY,
WORKFLOW_EXTRACTION_NAME_MODAL_KEY,
WORKFLOW_HISTORY_VERSION_RESTORE,
WORKFLOW_HISTORY_VERSION_UNPUBLISH,
WORKFLOW_SETTINGS_MODAL_KEY,
WORKFLOW_SHARE_MODAL_KEY,
EXPERIMENT_TEMPLATE_RECO_V2_KEY,
EXPERIMENT_TEMPLATE_RECO_V3_KEY,
EXPERIMENT_TEMPLATES_DATA_QUALITY_KEY,
CONFIRM_PASSWORD_MODAL_KEY,
WORKFLOW_PUBLISH_MODAL_KEY,
WORKFLOW_HISTORY_PUBLISH_MODAL_KEY,
} from '@/app/constants';
import {
ANNOTATION_TAGS_MANAGER_MODAL_KEY,
@@ -100,6 +103,7 @@ import WhatsNewModal from '@/app/components/WhatsNewModal.vue';
import WorkflowActivationConflictingWebhookModal from '@/app/components/WorkflowActivationConflictingWebhookModal.vue';
import WorkflowExtractionNameModal from '@/app/components/WorkflowExtractionNameModal.vue';
import WorkflowHistoryVersionRestoreModal from '@/features/workflows/workflowHistory/components/WorkflowHistoryVersionRestoreModal.vue';
import WorkflowHistoryVersionUnpublishModal from '@/features/workflows/workflowHistory/components/WorkflowHistoryVersionUnpublishModal.vue';
import WorkflowSettings from '@/app/components/WorkflowSettings.vue';
import WorkflowShareModal from '@/app/components/WorkflowShareModal.ee.vue';
import WorkflowDiffModal from '@/features/workflows/workflowDiff/WorkflowDiffModal.vue';
@@ -110,6 +114,8 @@ import NodeRecommendationModalV2 from '@/experiments/templateRecoV2/components/N
import NodeRecommendationModalV3 from '@/experiments/personalizedTemplatesV3/components/NodeRecommendationModal.vue';
import NodeRecommendationModalTDQ from '@/experiments/templatesDataQuality/components/NodeRecommendationModal.vue';
import VariableModal from '@/features/settings/environments.ee/components/VariableModal.vue';
import WorkflowPublishModal from '@/app/components/MainHeader/WorkflowPublishModal.vue';
import WorkflowHistoryPublishModal from '@/features/workflows/workflowHistory/components/WorkflowHistoryPublishModal.vue';
</script>
<template>
@@ -307,6 +313,16 @@ import VariableModal from '@/features/settings/environments.ee/components/Variab
</template>
</ModalRoot>
<ModalRoot :name="WORKFLOW_HISTORY_VERSION_UNPUBLISH">
<template #default="{ modalName, data }">
<WorkflowHistoryVersionUnpublishModal
data-test-id="workflow-history-version-unpublish-modal"
:modal-name="modalName"
:data="data"
/>
</template>
</ModalRoot>
<ModalRoot :name="SETUP_CREDENTIALS_MODAL_KEY">
<template #default="{ modalName, data }">
<SetupWorkflowCredentialsModal
@@ -403,6 +419,17 @@ import VariableModal from '@/features/settings/environments.ee/components/Variab
</template>
</ModalRoot>
<ModalRoot :name="WORKFLOW_PUBLISH_MODAL_KEY">
<template #default="{ modalName, data }">
<WorkflowPublishModal :modal-name="modalName" :data="data" />
</template>
</ModalRoot>
<ModalRoot :name="WORKFLOW_HISTORY_PUBLISH_MODAL_KEY">
<template #default="{ modalName, data }">
<WorkflowHistoryPublishModal :modal-name="modalName" :data="data" />
</template>
</ModalRoot>
<!-- Dynamic modals from modules -->
<DynamicModalLoader />
</div>

View File

@@ -634,7 +634,6 @@ describe('WorkflowCard', () => {
const { queryByTestId } = renderComponent({ props: { data } });
expect(queryByTestId('workflow-card-archived')).not.toBeInTheDocument();
expect(queryByTestId('workflow-card-activator')).toBeInTheDocument();
});
it("should show 'Duplicate' action when user has read permission and can create workflows", async () => {

View File

@@ -5,6 +5,7 @@ import {
MODAL_CONFIRM,
VIEWS,
WORKFLOW_SHARE_MODAL_KEY,
IS_DRAFT_PUBLISH_ENABLED,
} from '@/app/constants';
import { PROJECT_MOVE_RESOURCE_MODAL } from '@/features/collaboration/projects/projects.constants';
import { useMessage } from '@/app/composables/useMessage';
@@ -108,7 +109,6 @@ const workflowsStore = useWorkflowsStore();
const projectsStore = useProjectsStore();
const foldersStore = useFoldersStore();
const mcpStore = useMCPStore();
const hiddenBreadcrumbsItemsAsync = ref<Promise<PathItem[]>>(new Promise(() => {}));
const cachedHiddenBreadcrumbsItems = ref<PathItem[]>([]);
@@ -271,6 +271,12 @@ const isSomeoneElsesWorkflow = computed(
props.data.homeProject?.id !== projectsStore.personalProject?.id,
);
const isDraftPublishEnabled = IS_DRAFT_PUBLISH_ENABLED;
const isWorkflowPublished = computed(() => {
return props.data.activeVersionId !== null;
});
async function onClick(event?: KeyboardEvent | PointerEvent) {
if (event?.ctrlKey || event?.metaKey) {
const route = router.resolve({
@@ -628,7 +634,7 @@ const tags = computed(
{{ locale.baseText('workflows.item.archived') }}
</N8nText>
<WorkflowActivator
v-else
v-else-if="!isDraftPublishEnabled"
class="mr-s"
:is-archived="data.isArchived"
:workflow-active="data.active"
@@ -637,6 +643,21 @@ const tags = computed(
data-test-id="workflow-card-activator"
@update:workflow-active="onWorkflowActiveToggle"
/>
<div
v-if="isDraftPublishEnabled && !data.isArchived"
:class="$style.publishIndicator"
data-test-id="workflow-card-publish-indicator"
>
<template v-if="isWorkflowPublished">
<N8nIcon icon="circle-check" size="xlarge" :class="$style.publishIndicatorColor" />
<N8nText size="small" bold :class="$style.publishIndicatorColor">
{{ locale.baseText('workflows.item.published') }}
</N8nText>
</template>
<N8nText v-else size="small" bold :class="$style.notPublishedIndicatorColor">
{{ locale.baseText('workflows.item.notPublished') }}
</N8nText>
</div>
<N8nActionToggle
:actions="actions"
@@ -733,6 +754,40 @@ const tags = computed(
}
}
.publishIndicator {
display: flex;
align-items: center;
gap: var(--spacing--4xs);
}
.publishIndicatorColor {
color: var(--color--mint-700);
:global(body[data-theme='dark']) & {
color: var(--color--mint-600);
}
@media (prefers-color-scheme: dark) {
:global(body:not([data-theme])) & {
color: var(--color--mint-600);
}
}
}
.notPublishedIndicatorColor {
color: var(--color--neutral-600);
:global(body[data-theme='dark']) & {
color: var(--color--neutral-400);
}
@media (prefers-color-scheme: dark) {
:global(body:not([data-theme])) & {
color: var(--color--neutral-400);
}
}
}
@include mixins.breakpoint('sm-and-down') {
.cardLink {
--card--padding: 0 var(--spacing--sm) var(--spacing--sm);

View File

@@ -0,0 +1,81 @@
<script setup lang="ts">
import { N8nInput, N8nInputLabel } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { useTemplateRef } from 'vue';
defineProps<{
disabled?: boolean;
versionNameTestId?: string;
descriptionTestId?: string;
}>();
const versionName = defineModel<string>('versionName', { required: true });
const description = defineModel<string>('description', { required: true });
const nameInputRef = useTemplateRef<InstanceType<typeof N8nInput>>('nameInput');
const i18n = useI18n();
const emit = defineEmits<{
submit: [];
}>();
const focusInput = () => {
// highlight the value in the input
nameInputRef.value?.select();
};
const handleEnterKey = () => {
emit('submit');
};
defineExpose({
focusInput,
});
</script>
<template>
<div :class="$style.formContainer">
<N8nInputLabel
input-name="workflow-version-name"
:label="i18n.baseText('workflows.publishModal.versionNameLabel')"
:required="true"
:class="$style.inputWrapper"
>
<N8nInput
id="workflow-version-name"
ref="nameInput"
v-model="versionName"
:disabled="disabled"
size="large"
:data-test-id="versionNameTestId"
@keydown.enter="handleEnterKey"
/>
</N8nInputLabel>
<N8nInputLabel
input-name="workflow-version-description"
:label="i18n.baseText('workflows.publishModal.descriptionPlaceholder')"
>
<N8nInput
id="workflow-version-description"
v-model="description"
type="textarea"
:rows="4"
:disabled="disabled"
size="large"
:data-test-id="descriptionTestId"
/>
</N8nInputLabel>
</div>
</template>
<style lang="scss" module>
.formContainer {
display: flex;
flex-direction: column;
gap: var(--spacing--lg);
}
.inputWrapper {
width: 100%;
}
</style>

View File

@@ -16,6 +16,8 @@ import { useI18n } from '@n8n/i18n';
import { ref } from 'vue';
import { useNpsSurveyStore } from '@/app/stores/npsSurvey.store';
import { useWorkflowSaving } from './useWorkflowSaving';
import * as workflowsApi from '@/app/api/workflows';
import { useRootStore } from '@n8n/stores/useRootStore';
export function useWorkflowActivate() {
const updatingWorkflowActivation = ref(false);
@@ -29,6 +31,7 @@ export function useWorkflowActivate() {
const toast = useToast();
const i18n = useI18n();
const npsSurveyStore = useNpsSurveyStore();
const rootStore = useRootStore();
//methods
@@ -90,10 +93,27 @@ export function useWorkflowActivate() {
return false; // Return false if there are node issues
}
await workflowHelpers.updateWorkflow(
{ workflowId: currWorkflowId, active: newActiveState },
!uiStore.stateIsDirty,
);
// Save workflow if there are unsaved changes
if (uiStore.stateIsDirty) {
await workflowHelpers.updateWorkflow({ workflowId: currWorkflowId }, false);
}
// Call activate or deactivate endpoint
let workflow;
if (newActiveState) {
workflow = await workflowsApi.activateWorkflow(rootStore.restApiContext, currWorkflowId, {
versionId: workflowsStore.workflow.versionId,
});
} else {
workflow = await workflowsApi.deactivateWorkflow(rootStore.restApiContext, currWorkflowId);
}
// Update local state
if (workflow.activeVersionId !== null) {
workflowsStore.setWorkflowActive(currWorkflowId);
} else {
workflowsStore.setWorkflowInactive(currWorkflowId);
}
} catch (error) {
const newStateName = newActiveState ? 'activated' : 'deactivated';
toast.showError(
@@ -132,9 +152,105 @@ export function useWorkflowActivate() {
return await updateWorkflowActivation(workflowId, true, telemetrySource);
};
const publishWorkflow = async (
workflowId: string,
versionId: string,
options?: { name?: string; description?: string },
) => {
updatingWorkflowActivation.value = true;
const workflow = workflowsStore.getWorkflowById(workflowId);
const hadPublishedVersion = !!workflow.activeVersion;
if (!hadPublishedVersion) {
const telemetryPayload = {
workflow_id: workflowId,
is_active: true,
previous_status: false,
ndv_input: false,
};
void useExternalHooks().run('workflowActivate.updateWorkflowActivation', telemetryPayload);
}
try {
const updatedWorkflow = await workflowsStore.publishWorkflow(workflowId, {
versionId,
name: options?.name,
description: options?.description,
});
if (!updatedWorkflow.activeVersion) {
throw new Error('Failed to publish workflow');
}
workflowsStore.setWorkflowActive(workflowId, updatedWorkflow.activeVersion);
void useExternalHooks().run('workflow.activeChangeCurrent', {
workflowId,
versionId: updatedWorkflow.activeVersion.versionId,
active: true,
});
if (!hadPublishedVersion && useStorage(LOCAL_STORAGE_ACTIVATION_FLAG).value !== 'true') {
uiStore.openModal(WORKFLOW_ACTIVE_MODAL_KEY);
}
return true;
} catch (error) {
toast.showError(
error,
i18n.baseText('workflowActivator.showError.title', {
interpolate: { newStateName: 'published' },
}) + ':',
);
return false;
} finally {
updatingWorkflowActivation.value = false;
}
};
const unpublishWorkflowFromHistory = async (workflowId: string) => {
updatingWorkflowActivation.value = true;
const workflow = workflowsStore.getWorkflowById(workflowId);
const wasPublished = !!workflow.activeVersion;
const telemetryPayload = {
workflow_id: workflowId,
is_active: false,
previous_status: wasPublished,
ndv_input: false,
};
telemetry.track('User set workflow active status', telemetryPayload);
void useExternalHooks().run('workflowActivate.updateWorkflowActivation', telemetryPayload);
try {
await workflowsStore.deactivateWorkflow(workflowId);
void useExternalHooks().run('workflow.activeChangeCurrent', {
workflowId,
active: false,
versionId: null,
});
return true;
} catch (error) {
toast.showError(
error,
i18n.baseText('workflowActivator.showError.title', {
interpolate: { newStateName: 'deactivated' },
}) + ':',
);
return false;
} finally {
updatingWorkflowActivation.value = false;
}
};
return {
activateCurrentWorkflow,
updateWorkflowActivation,
updatingWorkflowActivation,
publishWorkflow,
unpublishWorkflowFromHistory,
};
}

View File

@@ -954,6 +954,10 @@ export function useWorkflowHelpers() {
workflowsStore.setWorkflowMetadata(workflowData.meta);
workflowsStore.setWorkflowScopes(workflowData.scopes);
if (workflowData.activeVersion) {
workflowsStore.setWorkflowActiveVersion(workflowData.activeVersion);
}
if (workflowData.usedCredentials) {
workflowsStore.setUsedCredentials(workflowData.usedCredentials);
}

View File

@@ -371,7 +371,7 @@ describe('useWorkflowSaving', () => {
);
});
it('should include active=false in the request if the workflow has no activatable trigger node', async () => {
it('should not include active=false in the request if the workflow has no activatable trigger node', async () => {
const workflow = createTestWorkflow({
id: 'w1',
nodes: [createTestNode({ type: CHAT_TRIGGER_NODE_TYPE, disabled: true })],
@@ -387,10 +387,9 @@ describe('useWorkflowSaving', () => {
await saveCurrentWorkflow({ id: 'w1' });
expect(workflowsStore.updateWorkflow).toHaveBeenCalledWith(
'w1',
expect.objectContaining({ id: 'w1', active: false }),
expect.objectContaining({ id: 'w1' }),
false,
);
expect(workflowsStore.setWorkflowInactive).toHaveBeenCalled();
});
});
});

View File

@@ -10,6 +10,7 @@ import {
NON_ACTIVATABLE_TRIGGER_NODE_TYPES,
PLACEHOLDER_EMPTY_WORKFLOW_ID,
VIEWS,
IS_DRAFT_PUBLISH_ENABLED,
} from '@/app/constants';
import { useWorkflowHelpers } from '@/app/composables/useWorkflowHelpers';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
@@ -46,7 +47,6 @@ export function useWorkflowSaving({
const telemetry = useTelemetry();
const nodeHelpers = useNodeHelpers();
const templatesStore = useTemplatesStore();
const { getWorkflowDataToSave, checkConflictingWebhooks, getWorkflowProjectRole } =
useWorkflowHelpers();
@@ -140,6 +140,10 @@ export function useWorkflowSaving({
workflowId: string,
request: WorkflowDataUpdate,
): Promise<Partial<NotificationOptions> | undefined> {
if (IS_DRAFT_PUBLISH_ENABLED) {
return undefined;
}
const missingActivatableTriggerNode =
request.nodes !== undefined && !request.nodes.some(isNodeActivatable);

View File

@@ -10,4 +10,6 @@ export const enum WORKFLOW_MENU_ACTIONS {
UNARCHIVE = 'unarchive',
RENAME = 'rename',
CHANGE_OWNER = 'change-owner',
UNPUBLISH = 'unpublish',
SHARE = 'share',
}

View File

@@ -20,6 +20,7 @@ export const LOG_STREAM_MODAL_KEY = 'settingsLogStream';
export const MFA_SETUP_MODAL_KEY = 'mfaSetup';
export const PROMPT_MFA_CODE_MODAL_KEY = 'promptMfaCode';
export const WORKFLOW_HISTORY_VERSION_RESTORE = 'workflowHistoryVersionRestore';
export const WORKFLOW_HISTORY_VERSION_UNPUBLISH = 'workflowHistoryVersionUnpublish';
export const SETUP_CREDENTIALS_MODAL_KEY = 'setupCredentials';
export const NEW_ASSISTANT_SESSION_MODAL = 'newAssistantSession';
export const EXTERNAL_SECRETS_PROVIDER_MODAL_KEY = 'externalSecretsProvider';
@@ -34,3 +35,5 @@ export const CHAT_HUB_SIDE_MENU_DRAWER_MODAL_KEY = 'chatHubSideMenuDrawer';
export const EXPERIMENT_TEMPLATE_RECO_V2_KEY = 'templateRecoV2';
export const EXPERIMENT_TEMPLATE_RECO_V3_KEY = 'templateRecoV3';
export const EXPERIMENT_TEMPLATES_DATA_QUALITY_KEY = 'templatesDataQuality';
export const WORKFLOW_PUBLISH_MODAL_KEY = 'workflowPublish';
export const WORKFLOW_HISTORY_PUBLISH_MODAL_KEY = 'workflowHistoryPublish';

View File

@@ -7,3 +7,8 @@ export const MAX_WORKFLOW_NAME_LENGTH = 128;
export const DUPLICATE_POSTFFIX = ' copy';
export const NODE_OUTPUT_DEFAULT_KEY = '_NODE_OUTPUT_DEFAULT_KEY_';
export const DEFAULT_WORKFLOW_PAGE_SIZE = 50;
export const WORKFLOWS_DRAFT_PUBLISH_ENABLED_FLAG = 'WORKFLOWS_DRAFT_PUBLISH_ENABLED';
// TODO: We need to do a proper cleanup of the old functionality (non-draft-publish mode)
// and then drop this constant
export const IS_DRAFT_PUBLISH_ENABLED = true;

View File

@@ -30,7 +30,10 @@ import {
EXPERIMENT_TEMPLATE_RECO_V2_KEY,
CONFIRM_PASSWORD_MODAL_KEY,
EXPERIMENT_TEMPLATE_RECO_V3_KEY,
WORKFLOW_PUBLISH_MODAL_KEY,
EXPERIMENT_TEMPLATES_DATA_QUALITY_KEY,
WORKFLOW_HISTORY_PUBLISH_MODAL_KEY,
WORKFLOW_HISTORY_VERSION_UNPUBLISH,
} from '@/app/constants';
import {
ANNOTATION_TAGS_MANAGER_MODAL_KEY,
@@ -148,6 +151,9 @@ export const useUIStore = defineStore(STORES.UI, () => {
WORKFLOW_DIFF_MODAL_KEY,
EXPERIMENT_TEMPLATE_RECO_V3_KEY,
VARIABLE_MODAL_KEY,
WORKFLOW_PUBLISH_MODAL_KEY,
WORKFLOW_HISTORY_PUBLISH_MODAL_KEY,
WORKFLOW_HISTORY_VERSION_UNPUBLISH,
].map((modalKey) => [modalKey, { open: false }]),
),
[DELETE_USER_MODAL_KEY]: {

View File

@@ -68,7 +68,7 @@ import * as workflowsApi from '@/app/api/workflows';
import { useUIStore } from '@/app/stores/ui.store';
import { dataPinningEventBus } from '@/app/event-bus';
import { isJsonKeyObject, stringSizeInBytes, isPresent } from '@/app/utils/typesUtils';
import { makeRestApiRequest, ResponseError } from '@n8n/rest-api-client';
import { makeRestApiRequest, ResponseError, type WorkflowHistory } from '@n8n/rest-api-client';
import {
unflattenExecutionData,
findTriggerNodeToAutoSelect,
@@ -737,6 +737,10 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
workflow.value.versionId = versionId;
}
function setWorkflowActiveVersion(version: WorkflowHistory) {
workflow.value.activeVersion = deepCopy(version);
}
// replace invalid credentials in workflow
function replaceInvalidWorkflowCredentials(data: {
credentials: INodeCredentialsDetails;
@@ -874,7 +878,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
};
}
function setWorkflowActive(targetWorkflowId: string) {
function setWorkflowActive(targetWorkflowId: string, activeVersion?: WorkflowHistory) {
const index = activeWorkflows.value.indexOf(targetWorkflowId);
if (index === -1) {
activeWorkflows.value.push(targetWorkflowId);
@@ -882,12 +886,14 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
const targetWorkflow = workflowsById.value[targetWorkflowId];
if (targetWorkflow) {
targetWorkflow.active = true;
targetWorkflow.activeVersionId = targetWorkflow.versionId;
targetWorkflow.activeVersionId = activeVersion?.versionId ?? targetWorkflow.versionId;
targetWorkflow.activeVersion = activeVersion;
}
if (targetWorkflowId === workflow.value.id) {
uiStore.stateIsDirty = false;
workflow.value.active = true;
workflow.value.activeVersionId = workflow.value.versionId;
workflow.value.activeVersionId = activeVersion?.versionId ?? workflow.value.versionId;
workflow.value.activeVersion = activeVersion;
}
}
@@ -900,9 +906,12 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
if (targetWorkflow) {
targetWorkflow.active = false;
targetWorkflow.activeVersionId = null;
targetWorkflow.activeVersion = null;
}
if (targetWorkflowId === workflow.value.id) {
workflow.value.active = false;
workflow.value.activeVersionId = null;
workflow.value.activeVersion = null;
}
}
@@ -1603,6 +1612,32 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
return updatedWorkflow;
}
async function publishWorkflow(
id: string,
data: { versionId: string; name?: string; description?: string },
): Promise<IWorkflowDb> {
const updatedWorkflow = await makeRestApiRequest<IWorkflowDb>(
rootStore.restApiContext,
'POST',
`/workflows/${id}/activate`,
data as unknown as IDataObject,
);
return updatedWorkflow;
}
async function deactivateWorkflow(id: string): Promise<IWorkflowDb> {
const updatedWorkflow = await makeRestApiRequest<IWorkflowDb>(
rootStore.restApiContext,
'POST',
`/workflows/${id}/deactivate`,
);
setWorkflowInactive(id);
return updatedWorkflow;
}
// Update a single workflow setting key while preserving existing settings
async function updateWorkflowSetting<K extends keyof IWorkflowSettings>(
id: string,
@@ -1950,6 +1985,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
addNodeExecutionStartedData,
setUsedCredentials,
setWorkflowVersionId,
setWorkflowActiveVersion,
replaceInvalidWorkflowCredentials,
assignCredentialToMatchingNodes,
setWorkflows,
@@ -1989,6 +2025,8 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
getExecution,
createNewWorkflow,
updateWorkflow,
publishWorkflow,
deactivateWorkflow,
updateWorkflowSetting,
saveWorkflowDescription,
runWorkflow,

View File

@@ -219,7 +219,9 @@ export interface ExternalHooks {
};
workflow: {
activeChange: Array<ExternalHooksMethod<{ active: boolean; workflowId: string }>>;
activeChangeCurrent: Array<ExternalHooksMethod<{ workflowId: string; active: boolean }>>;
activeChangeCurrent: Array<
ExternalHooksMethod<{ workflowId: string; versionId: string | null; active: boolean }>
>;
afterUpdate: Array<ExternalHooksMethod<{ workflowData: IWorkflowDb }>>;
open: Array<ExternalHooksMethod<{ workflowId: string; workflowName: string }>>;
};

View File

@@ -8,6 +8,9 @@ export const workflowHistoryDataFactory: () => WorkflowHistory = () => ({
authors: Array.from({ length: faker.number.int({ min: 2, max: 5 }) }, faker.person.fullName).join(
', ',
),
workflowPublishHistory: [],
name: faker.lorem.word(),
description: faker.lorem.sentence(),
});
export const workflowVersionDataFactory: () => WorkflowVersion = () => ({

View File

@@ -73,7 +73,17 @@ describe('WorkflowHistoryContent', () => {
await userEvent.click(getByTestId(`action-${action}`));
expect(emitted().action).toEqual([
[{ action, id: workflowVersion.versionId, data: { formattedCreatedAt: expect.any(String) } }],
[
{
action,
id: workflowVersion.versionId,
data: {
formattedCreatedAt: expect.any(String),
versionName: workflowVersion.name,
description: workflowVersion.description,
},
},
],
]);
});

View File

@@ -1,37 +1,34 @@
<script setup lang="ts">
import { computed } from 'vue';
import { computed, ref, watch } from 'vue';
import type { IWorkflowDb, UserAction } from '@/Interface';
import type {
WorkflowVersion,
WorkflowHistoryActionTypes,
WorkflowVersionId,
} from '@n8n/rest-api-client/api/workflowHistory';
import type { WorkflowVersion } from '@n8n/rest-api-client/api/workflowHistory';
import WorkflowPreview from '@/app/components/WorkflowPreview.vue';
import WorkflowHistoryListItem from './WorkflowHistoryListItem.vue';
import { useI18n } from '@n8n/i18n';
import type { IUser } from 'n8n-workflow';
import { N8nButton, N8nIcon } from '@n8n/design-system';
import { N8nButton, N8nIcon, N8nLink, N8nText, N8nTooltip } from '@n8n/design-system';
import { IS_DRAFT_PUBLISH_ENABLED } from '@/app/constants';
import { formatTimestamp } from '@/features/workflows/workflowHistory/utils';
import type { WorkflowHistoryAction } from '@/features/workflows/workflowHistory/types';
const i18n = useI18n();
const props = defineProps<{
workflow: IWorkflowDb | null;
workflowVersion: WorkflowVersion | null;
actions: Array<UserAction<IUser>>;
isVersionActive?: boolean;
isListLoading?: boolean;
isFirstItemShown?: boolean;
}>();
const emit = defineEmits<{
action: [
value: {
action: WorkflowHistoryActionTypes[number];
id: WorkflowVersionId;
data: { formattedCreatedAt: string };
},
];
action: [value: WorkflowHistoryAction];
}>();
const isDraftPublishEnabled = IS_DRAFT_PUBLISH_ENABLED;
const workflowVersionPreview = computed<IWorkflowDb | undefined>(() => {
if (!props.workflowVersion || !props.workflow) {
return;
@@ -44,23 +41,66 @@ const workflowVersionPreview = computed<IWorkflowDb | undefined>(() => {
};
});
const actions = computed(() =>
props.isFirstItemShown
? props.actions.filter((action) => action.value !== 'restore')
: props.actions,
);
const formattedCreatedAt = computed<string>(() => {
if (!props.workflowVersion) {
return '';
}
const { date, time } = formatTimestamp(props.workflowVersion.createdAt);
return i18n.baseText('workflowHistory.item.createdAt', { interpolate: { date, time } });
});
const onAction = ({
action,
id,
data,
}: {
action: WorkflowHistoryActionTypes[number];
id: WorkflowVersionId;
data: { formattedCreatedAt: string };
}) => {
const versionNameDisplay = computed(() => {
return props.workflowVersion?.name ?? formattedCreatedAt.value;
});
const MAX_DESCRIPTION_LENGTH = 200;
const isDescriptionExpanded = ref(false);
const description = computed(() => props.workflowVersion?.description ?? '');
const isDescriptionLong = computed(() => description.value.length > MAX_DESCRIPTION_LENGTH);
const displayDescription = computed(() => {
if (!isDescriptionLong.value || isDescriptionExpanded.value) {
return description.value;
}
return description.value.substring(0, MAX_DESCRIPTION_LENGTH) + '... ';
});
const toggleDescription = () => {
isDescriptionExpanded.value = !isDescriptionExpanded.value;
};
const actions = computed(() => {
let filteredActions = props.actions;
if (props.isFirstItemShown) {
filteredActions = filteredActions.filter((action) => action.value !== 'restore');
}
if (isDraftPublishEnabled) {
if (props.isVersionActive) {
filteredActions = filteredActions.filter((action) => action.value !== 'publish');
} else {
filteredActions = filteredActions.filter((action) => action.value !== 'unpublish');
}
} else {
filteredActions = filteredActions.filter(
(action) => action.value !== 'publish' && action.value !== 'unpublish',
);
}
return filteredActions;
});
const onAction = ({ action, id, data }: WorkflowHistoryAction) => {
emit('action', { action, id, data });
};
watch(
() => props.workflowVersion,
() => {
isDescriptionExpanded.value = false;
},
);
</script>
<template>
@@ -77,12 +117,29 @@ const onAction = ({
:class="$style.card"
:index="-1"
:item="props.workflowVersion"
:is-active="false"
:is-selected="false"
:actions="actions"
@action="onAction"
>
<template #default="{ formattedCreatedAt }">
<section :class="$style.text">
<div v-if="isDraftPublishEnabled" :class="$style.descriptionBox">
<N8nTooltip :content="versionNameDisplay" v-if="versionNameDisplay">
<N8nText :class="$style.mainLine" bold color="text-dark">{{
versionNameDisplay
}}</N8nText>
</N8nTooltip>
<N8nText v-if="description" size="small" color="text-base">
{{ displayDescription }}
<N8nLink v-if="isDescriptionLong" size="small" @click="toggleDescription">
{{
isDescriptionExpanded
? i18n.baseText('generic.showLess')
: i18n.baseText('generic.showMore')
}}
</N8nLink>
</N8nText>
</div>
<section v-else :class="$style.textOld">
<p>
<span :class="$style.label">
{{ i18n.baseText('workflowHistory.content.title') }}:
@@ -135,12 +192,33 @@ const onAction = ({
width: 100%;
}
$descriptionBoxMaxWidth: 330px;
$descriptionBoxMinWidth: 228px;
.card {
padding: var(--spacing--sm) var(--spacing--lg) 0 var(--spacing--xl);
padding: var(--spacing--sm) var(--spacing--lg) 0;
border: 0;
align-items: start;
.text {
.descriptionBox {
display: flex;
flex-direction: column;
min-width: $descriptionBoxMinWidth;
max-width: $descriptionBoxMaxWidth;
gap: var(--spacing--3xs);
margin-top: var(--spacing--3xs);
padding: var(--spacing--xs);
border: var(--border-width) var(--border-style) var(--color--foreground);
border-radius: var(--radius);
background-color: var(--color--background--light-3);
.mainLine {
@include mixins.utils-ellipsis;
cursor: default;
}
}
.textOld {
display: flex;
flex-direction: column;
flex: 1 1 auto;

View File

@@ -46,7 +46,7 @@ describe('WorkflowHistoryList', () => {
props: {
items: [],
actions,
activeItem: null,
selectedItem: null,
requestNumberOfItems: 20,
lastReceivedItemsLength: 0,
evaluatedPruneDays: -1,
@@ -63,7 +63,7 @@ describe('WorkflowHistoryList', () => {
props: {
items: [],
actions,
activeItem: null,
selectedItem: null,
requestNumberOfItems: 20,
lastReceivedItemsLength: 0,
evaluatedPruneDays: -1,
@@ -84,7 +84,7 @@ describe('WorkflowHistoryList', () => {
props: {
items,
actions,
activeItem: null,
selectedItem: null,
requestNumberOfItems: 20,
lastReceivedItemsLength: 20,
evaluatedPruneDays: -1,
@@ -95,7 +95,7 @@ describe('WorkflowHistoryList', () => {
const listItems = getAllByTestId('workflow-history-list-item');
const listItem = listItems[items.length - 1];
await userEvent.click(within(listItem).getByText(/ID: /));
await userEvent.click(within(listItem).getByText(/Saved: /));
expect(emitted().preview).toEqual([
[
{
@@ -108,7 +108,7 @@ describe('WorkflowHistoryList', () => {
expect(listItems).toHaveLength(numberOfItems);
});
it('should scroll to active item', async () => {
it('should scroll to selected item', async () => {
const items = Array.from({ length: 30 }, workflowHistoryDataFactory);
const { getByTestId } = renderComponent({
@@ -116,7 +116,7 @@ describe('WorkflowHistoryList', () => {
props: {
items,
actions,
activeItem: items[0],
selectedItem: items[0],
requestNumberOfItems: 20,
lastReceivedItemsLength: 20,
evaluatedPruneDays: -1,
@@ -134,7 +134,7 @@ describe('WorkflowHistoryList', () => {
props: {
items,
actions,
activeItem: null,
selectedItem: null,
requestNumberOfItems: 20,
lastReceivedItemsLength: 20,
evaluatedPruneDays: -1,
@@ -153,7 +153,11 @@ describe('WorkflowHistoryList', () => {
{
action,
id: items[index].versionId,
data: { formattedCreatedAt: expect.any(String) },
data: {
formattedCreatedAt: expect.any(String),
versionName: items[index].name,
description: items[index].description,
},
},
],
]);
@@ -167,7 +171,7 @@ describe('WorkflowHistoryList', () => {
props: {
items,
actions,
activeItem: items[0],
selectedItem: items[0],
requestNumberOfItems: 20,
lastReceivedItemsLength: 20,
evaluatedPruneDays: 1,
@@ -186,7 +190,7 @@ describe('WorkflowHistoryList', () => {
props: {
items,
actions,
activeItem: items[0],
selectedItem: items[0],
requestNumberOfItems: 20,
lastReceivedItemsLength: 20,
evaluatedPruneDays: 1,

View File

@@ -5,41 +5,36 @@ import { useI18n } from '@n8n/i18n';
import type {
WorkflowHistory,
WorkflowVersionId,
WorkflowHistoryActionTypes,
WorkflowHistoryRequestParams,
} from '@n8n/rest-api-client/api/workflowHistory';
import WorkflowHistoryListItem from './WorkflowHistoryListItem.vue';
import type { IUser } from 'n8n-workflow';
import { I18nT } from 'vue-i18n';
import { useIntersectionObserver } from '@/app/composables/useIntersectionObserver';
import { N8nLoading } from '@n8n/design-system';
import { IS_DRAFT_PUBLISH_ENABLED } from '@/app/constants';
import type { WorkflowHistoryAction } from '@/features/workflows/workflowHistory/types';
const props = defineProps<{
items: WorkflowHistory[];
activeItem: WorkflowHistory | null;
selectedItem?: WorkflowHistory | null;
actions: Array<UserAction<IUser>>;
requestNumberOfItems: number;
lastReceivedItemsLength: number;
evaluatedPruneDays: number;
shouldUpgrade?: boolean;
isListLoading?: boolean;
activeVersionId?: string;
}>();
const emit = defineEmits<{
action: [
value: {
action: WorkflowHistoryActionTypes[number];
id: WorkflowVersionId;
data: { formattedCreatedAt: string };
},
];
action: [value: WorkflowHistoryAction];
preview: [value: { event: MouseEvent; id: WorkflowVersionId }];
loadMore: [value: WorkflowHistoryRequestParams];
upgrade: [];
}>();
const i18n = useI18n();
const listElement = ref<Element | null>(null);
const shouldAutoScroll = ref(true);
@@ -50,18 +45,29 @@ const { observe: observeForLoadMore } = useIntersectionObserver({
emit('loadMore', { take: props.requestNumberOfItems, skip: props.items.length }),
});
const getActions = (index: number) =>
index === 0 ? props.actions.filter((action) => action.value !== 'restore') : props.actions;
const getActions = (item: WorkflowHistory, index: number) => {
let filteredActions = props.actions;
const onAction = ({
action,
id,
data,
}: {
action: WorkflowHistoryActionTypes[number];
id: WorkflowVersionId;
data: { formattedCreatedAt: string };
}) => {
if (index === 0) {
filteredActions = filteredActions.filter((action) => action.value !== 'restore');
}
if (IS_DRAFT_PUBLISH_ENABLED) {
if (item.versionId === props.activeVersionId) {
filteredActions = filteredActions.filter((action) => action.value !== 'publish');
} else {
filteredActions = filteredActions.filter((action) => action.value !== 'unpublish');
}
} else {
filteredActions = filteredActions.filter(
(action) => action.value !== 'publish' && action.value !== 'unpublish',
);
}
return filteredActions;
};
const onAction = ({ action, id, data }: WorkflowHistoryAction) => {
shouldAutoScroll.value = false;
emit('action', { action, id, data });
};
@@ -74,13 +80,13 @@ const onPreview = ({ event, id }: { event: MouseEvent; id: WorkflowVersionId })
const onItemMounted = ({
index,
offsetTop,
isActive,
isSelected,
}: {
index: number;
offsetTop: number;
isActive: boolean;
isSelected: boolean;
}) => {
if (isActive && shouldAutoScroll.value) {
if (isSelected && shouldAutoScroll.value) {
shouldAutoScroll.value = false;
listElement.value?.scrollTo({ top: offsetTop, behavior: 'smooth' });
}
@@ -101,8 +107,9 @@ const onItemMounted = ({
:key="item.versionId"
:index="index"
:item="item"
:is-active="item.versionId === props.activeItem?.versionId"
:actions="getActions(index)"
:is-selected="item.versionId === props.selectedItem?.versionId"
:is-version-active="item.versionId === props.activeVersionId"
:actions="getActions(item, index)"
@action="onAction"
@preview="onPreview"
@mounted="onItemMounted"

View File

@@ -24,31 +24,6 @@ describe('WorkflowHistoryListItem', () => {
setActivePinia(pinia);
});
it('should render item with badge', async () => {
const item = workflowHistoryDataFactory();
item.authors = 'John Doe';
const { getByText, container, queryByRole, emitted } = renderComponent({
pinia,
props: {
item,
index: 0,
actions,
isActive: false,
},
});
await userEvent.hover(container.querySelector('.el-tooltip__trigger')!);
expect(queryByRole('tooltip')).not.toBeInTheDocument();
await userEvent.click(container.querySelector('p')!);
expect(emitted().preview).toEqual([
[expect.objectContaining({ id: item.versionId, event: expect.any(MouseEvent) })],
]);
expect(emitted().mounted).toEqual([[{ index: 0, isActive: false, offsetTop: 0 }]]);
expect(getByText(/Latest saved/)).toBeInTheDocument();
});
test.each(actionTypes)('should emit %s event', async (action) => {
const item = workflowHistoryDataFactory();
const authors = item.authors.split(', ');
@@ -58,7 +33,7 @@ describe('WorkflowHistoryListItem', () => {
item,
index: 2,
actions,
isActive: true,
isSelected: true,
},
});
@@ -72,10 +47,20 @@ describe('WorkflowHistoryListItem', () => {
await userEvent.click(getByTestId(`action-${action}`));
expect(emitted().action).toEqual([
[{ action, id: item.versionId, data: { formattedCreatedAt: expect.any(String) } }],
[
{
action,
id: item.versionId,
data: {
formattedCreatedAt: expect.any(String),
versionName: item.name,
description: item.description,
},
},
],
]);
expect(queryByText(/Latest saved/)).not.toBeInTheDocument();
expect(emitted().mounted).toEqual([[{ index: 2, isActive: true, offsetTop: 0 }]]);
expect(emitted().mounted).toEqual([[{ index: 2, isSelected: true, offsetTop: 0 }]]);
});
});

View File

@@ -1,6 +1,5 @@
<script setup lang="ts">
import { computed, ref, onMounted } from 'vue';
import dateformat from 'dateformat';
import type { UserAction } from '@n8n/design-system';
import type {
WorkflowHistory,
@@ -10,39 +9,46 @@ import type {
import { useI18n } from '@n8n/i18n';
import type { IUser } from 'n8n-workflow';
import { N8nActionToggle, N8nBadge, N8nTooltip } from '@n8n/design-system';
const props = defineProps<{
item: WorkflowHistory;
index: number;
actions: Array<UserAction<IUser>>;
isActive: boolean;
}>();
import { N8nActionToggle, N8nTooltip, N8nBadge } from '@n8n/design-system';
import {
getLastPublishedByUser,
formatTimestamp,
} from '@/features/workflows/workflowHistory/utils';
import { IS_DRAFT_PUBLISH_ENABLED } from '@/app/constants';
import { useUsersStore } from '@/features/settings/users/users.store';
import type { WorkflowHistoryAction } from '@/features/workflows/workflowHistory/types';
const props = withDefaults(
defineProps<{
item: WorkflowHistory;
index: number;
actions: Array<UserAction<IUser>>;
isSelected?: boolean;
isVersionActive?: boolean;
}>(),
{
isSelected: false,
isVersionActive: false,
},
);
const emit = defineEmits<{
action: [
value: {
action: WorkflowHistoryActionTypes[number];
id: WorkflowVersionId;
data: { formattedCreatedAt: string };
},
];
action: [value: WorkflowHistoryAction];
preview: [value: { event: MouseEvent; id: WorkflowVersionId }];
mounted: [value: { index: number; offsetTop: number; isActive: boolean }];
mounted: [value: { index: number; offsetTop: number; isSelected: boolean }];
}>();
const i18n = useI18n();
const usersStore = useUsersStore();
const actionsVisible = ref(false);
const itemElement = ref<HTMLElement | null>(null);
const authorElement = ref<HTMLElement | null>(null);
const isAuthorElementTruncated = ref(false);
const formattedCreatedAt = computed<string>(() => {
const currentYear = new Date().getFullYear().toString();
const [date, time] = dateformat(
props.item.createdAt,
`${props.item.createdAt.startsWith(currentYear) ? '' : 'yyyy '}mmm d"#"HH:MM:ss`,
).split('#');
const isDraftPublishEnabled = IS_DRAFT_PUBLISH_ENABLED;
const formattedCreatedAt = computed<string>(() => {
const { date, time } = formatTimestamp(props.item.createdAt);
return i18n.baseText('workflowHistory.item.createdAt', { interpolate: { date, time } });
});
@@ -60,6 +66,39 @@ const authors = computed<{ size: number; label: string }>(() => {
};
});
const versionName = computed(() => {
return props.item.name;
});
const lastPublishInfo = computed(() => {
if (!props.isVersionActive) {
return null;
}
const lastPublishedByUser = getLastPublishedByUser(props.item.workflowPublishHistory);
if (!lastPublishedByUser) {
return null;
}
return lastPublishedByUser;
});
const publishedAt = computed(() => {
if (!lastPublishInfo.value) {
return null;
}
const { date, time } = formatTimestamp(lastPublishInfo.value.createdAt);
return i18n.baseText('workflowHistory.item.createdAt', { interpolate: { date, time } });
});
const publishedByUserName = computed(() => {
const userId = lastPublishInfo.value?.userId;
if (!userId) {
return null;
}
const user = usersStore.usersById[userId];
return user?.fullName ?? user?.email ?? null;
});
const idLabel = computed<string>(() =>
i18n.baseText('workflowHistory.item.id', { interpolate: { id: props.item.versionId } }),
);
@@ -69,7 +108,11 @@ const onAction = (value: string) => {
emit('action', {
action,
id: props.item.versionId,
data: { formattedCreatedAt: formattedCreatedAt.value },
data: {
formattedCreatedAt: formattedCreatedAt.value,
versionName: versionName.value,
description: props.item.description,
},
});
};
@@ -85,7 +128,7 @@ onMounted(() => {
emit('mounted', {
index: props.index,
offsetTop: itemElement.value?.offsetTop ?? 0,
isActive: props.isActive,
isSelected: props.isSelected,
});
isAuthorElementTruncated.value =
(authorElement.value?.scrollWidth ?? 0) > (authorElement.value?.clientWidth ?? 0);
@@ -97,12 +140,28 @@ onMounted(() => {
data-test-id="workflow-history-list-item"
:class="{
[$style.item]: true,
[$style.active]: props.isActive,
[$style.selected]: props.isSelected,
[$style.actionsVisible]: actionsVisible,
}"
>
<slot :formatted-created-at="formattedCreatedAt">
<p @click="onItemClick">
<p v-if="isDraftPublishEnabled" @click="onItemClick">
<span v-if="versionName" :class="$style.mainLine">{{ versionName }}</span>
<time :datetime="item.createdAt" :class="$style.metaItem">
{{ i18n.baseText('workflowHistory.item.savedAtLabel') }} {{ formattedCreatedAt }}
</time>
<N8nTooltip placement="right-end" :disabled="authors.size < 2 && !isAuthorElementTruncated">
<template #content>{{ props.item.authors }}</template>
<span ref="authorElement" :class="$style.metaItem">{{ authors.label }}</span>
</N8nTooltip>
<time v-if="publishedAt" :datetime="item.updatedAt" :class="$style.metaItem">
{{ i18n.baseText('workflowHistory.item.publishedAtLabel') }} {{ publishedAt }}
</time>
<span v-if="publishedByUserName" :class="$style.metaItem">
{{ publishedByUserName }}
</span>
</p>
<p v-else @click="onItemClick">
<time :datetime="item.createdAt">{{ formattedCreatedAt }}</time>
<N8nTooltip placement="right-end" :disabled="authors.size < 2 && !isAuthorElementTruncated">
<template #content>{{ props.item.authors }}</template>
@@ -112,7 +171,15 @@ onMounted(() => {
</p>
</slot>
<div :class="$style.tail">
<N8nBadge v-if="props.index === 0">
<N8nBadge
v-if="isDraftPublishEnabled && props.isVersionActive"
size="medium"
:class="$style.publishedBadge"
:show-border="false"
>
{{ i18n.baseText('workflowHistory.item.active') }}
</N8nBadge>
<N8nBadge v-if="!isDraftPublishEnabled && props.index === 0">
{{ i18n.baseText('workflowHistory.item.latest') }}
</N8nBadge>
<N8nActionToggle
@@ -163,6 +230,27 @@ onMounted(() => {
margin-top: calc(var(--spacing--4xs) * -1);
font-size: var(--font-size--2xs);
}
.mainLine {
padding: 0 0 var(--spacing--5xs);
color: var(--color--text--shade-1);
font-size: var(--font-size--sm);
font-weight: var(--font-weight--bold);
}
.metaItem {
justify-self: start;
max-width: 180px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-top: calc(var(--spacing--4xs) * -1);
font-size: var(--font-size--2xs);
// Reset styles that might be inherited from time selector
padding: 0;
color: var(--color--text);
font-weight: var(--font-weight--regular);
}
}
.tail {
@@ -171,7 +259,7 @@ onMounted(() => {
justify-content: space-between;
}
&.active {
&.selected {
background-color: var(--color--background);
border-left-color: var(--color--primary);
@@ -190,4 +278,14 @@ onMounted(() => {
display: block;
padding: var(--spacing--3xs);
}
.publishedBadge {
background-color: var(--color--success);
color: var(--color--foreground--tint-2);
:global(.n8n-text) {
font-size: var(--font-size--2xs);
line-height: var(--line-height--sm);
}
}
</style>

View File

@@ -0,0 +1,155 @@
<script setup lang="ts">
import Modal from '@/app/components/Modal.vue';
import { N8nHeading, N8nButton } from '@n8n/design-system';
import WorkflowPublishForm from '@/app/components/WorkflowPublishForm.vue';
import { WORKFLOW_HISTORY_PUBLISH_MODAL_KEY } from '@/app/constants';
import { useI18n } from '@n8n/i18n';
import { createEventBus } from '@n8n/utils/event-bus';
import { useWorkflowActivate } from '@/app/composables/useWorkflowActivate';
import { useUIStore } from '@/app/stores/ui.store';
import { ref, onMounted, onBeforeUnmount, useTemplateRef } from 'vue';
import { generateVersionName } from '@/features/workflows/workflowHistory/utils';
import type { EventBus } from '@n8n/utils/event-bus';
export type WorkflowHistoryPublishModalEventBusEvents = {
publish: { versionId: string; name: string; description: string };
cancel: undefined;
};
const props = defineProps<{
modalName: string;
data: {
versionId: string;
workflowId: string;
formattedCreatedAt: string;
versionName?: string;
description?: string;
eventBus: EventBus<WorkflowHistoryPublishModalEventBusEvents>;
};
}>();
const i18n = useI18n();
const modalEventBus = createEventBus();
const workflowActivate = useWorkflowActivate();
const uiStore = useUIStore();
const publishForm = useTemplateRef<InstanceType<typeof WorkflowPublishForm>>('publishForm');
const versionName = ref('');
const description = ref('');
function onModalOpened() {
publishForm.value?.focusInput();
}
onMounted(() => {
// Populate version name from existing data or generate from version ID
if (props.data.versionName) {
versionName.value = props.data.versionName;
} else if (props.data.versionId) {
versionName.value = generateVersionName(props.data.versionId);
}
// Populate description if available
if (props.data.description) {
description.value = props.data.description;
}
modalEventBus.on('opened', onModalOpened);
});
onBeforeUnmount(() => {
modalEventBus.off('opened', onModalOpened);
});
const closeModal = () => {
uiStore.closeModal(props.modalName);
};
const onCancel = () => {
props.data.eventBus.emit('cancel');
closeModal();
};
const isPublishDisabled = ref(false);
const handlePublish = async () => {
if (versionName.value.trim().length === 0) {
return;
}
isPublishDisabled.value = true;
const success = await workflowActivate.publishWorkflow(
props.data.workflowId,
props.data.versionId,
{
name: versionName.value,
description: description.value,
},
);
isPublishDisabled.value = false;
if (success) {
props.data.eventBus.emit('publish', {
versionId: props.data.versionId,
name: versionName.value,
description: description.value,
});
closeModal();
}
};
</script>
<template>
<Modal
width="500px"
max-height="85vh"
:name="WORKFLOW_HISTORY_PUBLISH_MODAL_KEY"
:event-bus="modalEventBus"
:center="true"
:before-close="onCancel"
>
<template #header>
<N8nHeading size="xlarge">{{ i18n.baseText('workflows.publishModal.title') }}</N8nHeading>
</template>
<template #content>
<div :class="$style.content">
<WorkflowPublishForm
ref="publishForm"
v-model:version-name="versionName"
v-model:description="description"
version-name-test-id="workflow-history-publish-version-name-input"
description-test-id="workflow-history-publish-description-input"
/>
<div :class="$style.actions">
<N8nButton
type="secondary"
:label="i18n.baseText('generic.cancel')"
data-test-id="workflow-history-publish-cancel-button"
@click="onCancel"
/>
<N8nButton
:disabled="isPublishDisabled || versionName.trim().length === 0"
:label="i18n.baseText('workflows.publish')"
data-test-id="workflow-history-publish-button"
@click="handlePublish"
/>
</div>
</div>
</template>
</Modal>
</template>
<style lang="scss" module>
.content {
display: flex;
flex-direction: column;
gap: var(--spacing--lg);
}
.actions {
display: flex;
justify-content: flex-end;
gap: var(--spacing--xs);
}
</style>

View File

@@ -47,15 +47,10 @@ const closeModal = () => {
<br />
<I18nT
v-if="props.data.isWorkflowActivated"
keypath="workflowHistory.action.restore.modal.text"
keypath="workflowHistory.action.restore.modal.publishedNote"
tag="span"
scope="global"
>
<template #buttonText>
&ldquo;{{
i18n.baseText('workflowHistory.action.restore.modal.button.deactivateAndRestore')
}}&rdquo;
</template>
</I18nT>
</N8nText>
</div>

View File

@@ -0,0 +1,79 @@
<script lang="ts" setup>
import { useI18n } from '@n8n/i18n';
import Modal from '@/app/components/Modal.vue';
import { useUIStore } from '@/app/stores/ui.store';
import type { EventBus } from '@n8n/utils/event-bus';
import { N8nButton, N8nCallout, N8nHeading } from '@n8n/design-system';
export type WorkflowHistoryVersionUnpublishModalEventBusEvents = {
unpublish: undefined;
cancel: undefined;
};
const props = defineProps<{
modalName: string;
data: {
versionName?: string;
eventBus: EventBus<WorkflowHistoryVersionUnpublishModalEventBusEvents>;
};
}>();
const i18n = useI18n();
const uiStore = useUIStore();
const closeModal = () => {
uiStore.closeModal(props.modalName);
};
const onCancel = () => {
props.data.eventBus.emit('cancel');
closeModal();
};
const onUnpublish = () => {
props.data.eventBus.emit('unpublish');
// Modal will be closed by parent after API call completes
};
</script>
<template>
<Modal width="500px" :name="props.modalName" :before-close="onCancel">
<template #header>
<N8nHeading tag="h2" size="xlarge">
{{
i18n.baseText('workflowHistory.action.unpublish.modal.title', {
interpolate: { versionName: props.data.versionName || '' },
})
}}
</N8nHeading>
</template>
<template #content>
<N8nCallout theme="warning" icon="triangle-alert">
{{ i18n.baseText('workflowHistory.action.unpublish.modal.description') }}
</N8nCallout>
</template>
<template #footer>
<div :class="$style.footer">
<N8nButton size="medium" type="tertiary" @click="onCancel">
{{ i18n.baseText('generic.cancel') }}
</N8nButton>
<N8nButton size="medium" type="primary" @click="onUnpublish">
{{ i18n.baseText('workflowHistory.action.unpublish.modal.button.unpublish') }}
</N8nButton>
</div>
</template>
</Modal>
</template>
<style module lang="scss">
.footer {
display: flex;
flex-direction: row;
justify-content: flex-end;
button {
margin-left: var(--spacing--2xs);
}
}
</style>

View File

@@ -0,0 +1,11 @@
import type { WorkflowHistoryActionTypes, WorkflowVersionId } from '@n8n/rest-api-client';
export type WorkflowHistoryAction = {
action: WorkflowHistoryActionTypes[number];
id: WorkflowVersionId;
data: {
formattedCreatedAt: string;
versionName?: string | null;
description?: string | null;
};
};

Some files were not shown because too many files have changed in this diff Show More