mirror of
https://github.com/n8n-io/n8n.git
synced 2025-12-05 19:27:26 -06:00
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:
4
.github/workflows/ci-postgres-mysql.yml
vendored
4
.github/workflows/ci-postgres-mysql.yml
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 && \
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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() });
|
||||
}
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
56
packages/cli/src/commands/publish/workflow.ts
Normal file
56
packages/cli/src/commands/publish/workflow.ts
Normal 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!);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
54
packages/cli/src/commands/unpublish/workflow.ts
Normal file
54
packages/cli/src/commands/unpublish/workflow.ts
Normal 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!);
|
||||
}
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -18,7 +18,7 @@ describe('shouldSkipAuthOnOAuthCallback', () => {
|
||||
});
|
||||
|
||||
it('should return true', () => {
|
||||
expect(shouldSkipAuthOnOAuthCallback()).toBe(true);
|
||||
expect(shouldSkipAuthOnOAuthCallback()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -309,6 +309,7 @@ export = {
|
||||
{
|
||||
forceSave: true, // Skip version conflict check for public API
|
||||
publicApi: true,
|
||||
publishIfActive: true,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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)` }),
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
111
packages/cli/test/integration/commands/publish/workflow.test.ts
Normal file
111
packages/cli/test/integration/commands/publish/workflow.test.ts
Normal 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}".`);
|
||||
});
|
||||
@@ -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.`,
|
||||
);
|
||||
});
|
||||
@@ -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
|
||||
//
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
//
|
||||
|
||||
@@ -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`);
|
||||
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -10,4 +10,6 @@ export const enum WORKFLOW_MENU_ACTIONS {
|
||||
UNARCHIVE = 'unarchive',
|
||||
RENAME = 'rename',
|
||||
CHANGE_OWNER = 'change-owner',
|
||||
UNPUBLISH = 'unpublish',
|
||||
SHARE = 'share',
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }>>;
|
||||
};
|
||||
|
||||
@@ -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 = () => ({
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 }]]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
“{{
|
||||
i18n.baseText('workflowHistory.action.restore.modal.button.deactivateAndRestore')
|
||||
}}”
|
||||
</template>
|
||||
</I18nT>
|
||||
</N8nText>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
@@ -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
Reference in New Issue
Block a user