feat(editor): Add data pinning functionality (#3511)

* feat: Design system color improvements and button component redesign.

* feat: Added button focus state and unit tests.

* refactor: Aligned n8n-button usage inside of editor-ui.

* test: Updated snapshots.

* refactor: Extracted focus outline width into scss variable.

* fix: Fixed select input border-radius.

* refactor: Removed element-ui references in button.

* fix: Fixed scss variable imports.

* feat: Added color-neutral variable story.

* fix: Fixed color-secondary variable definition.

* feat: Added color-white story.

* test: Updated button snapshot.

* feat: Replaced zoom buttons with new n8n-icon-button.

* feat: Added stories for float utilities.

* chore: Updated color shades generation code for later use.

* chore: Removed color-white code.

* chore: Updated story properties for button components.

* fix: Added el-button fallback for places where el-button is not replaceable (messagebox).

* feat: Reverted to css modules. Replaced el-button with n8n-button at application level.

* test: Updated button snapshot.

* fix: Fixed element-ui locally referenced buttons (via components: {}).

* fix: Updated colors. Removed irrelevant validation. Added ElButton override component.

* test: Updated button override snapshot.

* fix: Various button adjustments and fixes.

* fix: Updated button disabled state.

* test: Updated snapshots.

* fix: Consolidated css variables changes.

* Data pinning (#3512)

* refactor: Aligned n8n-button usage inside of editor-ui.

* feat: Added edit data button on json hover.

* feat: Extracted code editor into separate form component.

* feat: Added edit data button on json hover.

* feat: Added pinData and edit mode methods.

* 🔥 Remove conflict markers

* ✏️ Update i18n keys

*  Add JSON validation

* 🗃️ Add `pinData` column to `workflow_entity`

* 📘 Tighten type

*  Make `pinData` column nullable

*  Adjust workflow endpoints for pin data

* 📘 Improve types

* ✏️ Improve wording

* Inject pindata into items flow (#3420)

*  Inject pin data - Second approach

* 🔥 Remove unneeded lint exception

* feat: Added edit data button on json hover.

* feat: Extracted code editor into separate form component.

* feat: Added edit data button on json hover.

* fix: Fixed rebase conflicts.

*  Undo button change

* 🐛 Fix runNode call

Adjust per update in bdb84130d6

* 🧪 Fix workflow tests

* 🐛 More merge conflict fixes

* feat: Added pin/unpin button and store mutations.

* feat: Size check. Various design and ux improvements.

*  Add transformer

*  Hoist pin data

*  Adjust endpoints for hoisted pin data

* 📘 Expand interface

* 🐛 Fix stray array

* 👕 Fix build

* 👕 Add lint exception

* 👕 Fix header

* 🎨 Add color secondary tints

*  Create `HeaderMessage` component

*  Adjust `InfoTip` component

*  Add `HeaderMessage` to `RunData`

* 🐛 Fix console error

* 👕 Fix lint

*  Consolidate `HeaderMessage` and `Callout`

*  Undo `InfoTip` changes

* 🔥 Remove duplicate icons

*  Simplify template

* 🎨 Change cursor for action text

* 👕 Fix lint

*  Add URL

* 🐛 Fix handler name

*  Use constant

* ♻️ Refactor per feedback

* fix: Various fixes after data pinning relocation.

* fix: Added store mutation for setting pinned data.

* feat: Added pinned state for workflow canvas node.

* fix: Fixed workflow saving.

* fix: Removed pinData hoisting (no longer necessary).

* feat: Added canPinData flag to hide for input pane and binary data. Fixed unpin and execute flow.

*  Fixes for canvas pin data (#3587)

*  Fixes for canvas pin data

* 📘 Rename type

* 🧪 Fix unrelated Public API test

* 🔥 Remove logging

* feat: Updated pinData mixin to no longer include extra fields.

*  Output same pindata for every run

* 🎨 Fix cropping

* 🔥 Remove unrelated logging

* feat: Moved edit button next to pin button.

* feat: Changed data to be inserted for empty state.

* chore: Changed invalid editor output translation.

* feat: Added error line reporting on JSON Validation.

* feat: Migrated pinData edit mode to store.

* chore: Merged duplicate node border color condition.

* feat: Moved pin data validation to mixin. Added check before closing ndv modal.

* fix: Changed pinned data size calculation to discard active node pin data.

* feat: Added support for rename and delete node with pin data.

* feat: Simplified editing state. Fixed edit mode in input panel after store migration.

* feat: Various data pinning improvements.

* fix: Fixed callout link underline.

* refactor: Added support for both string and objects for data size check.

* feat: Added disabled node check for input panel. Fixed monaco editor resizing.

* fix: Fixed edit mode footer size.

*  Fix pindata items per run

* 👕 Remove unneeded exception

* refactor: Added isValidPinData() helper method.

* refactor: Changed how string size in bytes in calculated.g

* refactor: Updated pinData mixin interface.

* refactor: Merged filter and reduce in pinDataSize calculation.

* fix: Changed code-editor to correct type.

* fix: Added insert test data message to trigger nodes.

* feat: Disabled data pinning for multiple output nodes.

* refactor: Updated ndv.input.disabled translation to include node name.

* refactor: Aligned n8n-button usage inside of editor-ui.

* feat: Added edit data button on json hover.

* feat: Extracted code editor into separate form component.

* feat: Added edit data button on json hover.

* feat: Added pinData and edit mode methods.

* 🔥 Remove conflict markers

* ✏️ Update i18n keys

*  Add JSON validation

* 🗃️ Add `pinData` column to `workflow_entity`

* 📘 Tighten type

*  Make `pinData` column nullable

*  Adjust workflow endpoints for pin data

* 📘 Improve types

* ✏️ Improve wording

* Inject pindata into items flow (#3420)

*  Inject pin data - Second approach

* 🔥 Remove unneeded lint exception

* feat: Added edit data button on json hover.

* feat: Extracted code editor into separate form component.

* feat: Added edit data button on json hover.

* fix: Fixed rebase conflicts.

*  Undo button change

* 🐛 Fix runNode call

Adjust per update in bdb84130d6

* 🧪 Fix workflow tests

* 🐛 More merge conflict fixes

* feat: Added pin/unpin button and store mutations.

* feat: Size check. Various design and ux improvements.

*  Add transformer

*  Hoist pin data

*  Adjust endpoints for hoisted pin data

* 📘 Expand interface

* 🐛 Fix stray array

* 👕 Fix build

* 🎨 Add color secondary tints

*  Create `HeaderMessage` component

*  Adjust `InfoTip` component

*  Add `HeaderMessage` to `RunData`

* 🐛 Fix console error

* 👕 Fix lint

*  Consolidate `HeaderMessage` and `Callout`

*  Undo `InfoTip` changes

* 🔥 Remove duplicate icons

*  Simplify template

* 🎨 Change cursor for action text

* 👕 Fix lint

*  Add URL

* 🐛 Fix handler name

*  Use constant

* ♻️ Refactor per feedback

* fix: Various fixes after data pinning relocation.

* fix: Added store mutation for setting pinned data.

* feat: Added pinned state for workflow canvas node.

*  Fixes for canvas pin data (#3587)

*  Fixes for canvas pin data

* 📘 Rename type

* 🧪 Fix unrelated Public API test

* 🔥 Remove logging

* feat: Updated pinData mixin to no longer include extra fields.

* fix: Removed pinData hoisting (no longer necessary).

* chore: Merged duplicate node border color condition.

*  Output same pindata for every run

* 🎨 Fix cropping

* 🐛 Fix excess closing template tag

* fix: Removed rogue template tag after merge.

* fix: Fixed code-editor resizing when moving ndv panel.

* feat: Added node duplication pin data.

*  Implement telemetry

* ♻️ Add clarifications from call

* fix: Fixed run data header height.

* feat: Removed border from pin data callout.

* feat: Added line-break before 'or insert pin data'.

* feat: Changed enterEditMode to always insert test data if there's no execution data.

* feat: Removed copy output tooltip.

* feat: Removed unpin tooltip.

* fix: Removed thumbtack icon rotation.

* fix: Removed run info from Edit Output title.

* feat: Hid edit and pin buttons when editing.

* feat: Updated monaco code-editor padding and borders.

* feat: Progress on pinData error message format

* feat: Updated copy feature to work without any selected value.

* feat: Moved save and cancel buttons. Cleared notifications on save.

* feat: Changed pin data beforeClosing confirm text.

* feat: Closing ndv when discarding or saving pindata on close.

* feat: Added split in batches node to pin data denylist.

* fix: Added missing margin-bottom to webhook node.

* feat: Moved thumbtack icon to the right, replacing the checkmark.

* fix: Hid pagination while editing.

* feat: Added pin data discovery flow.

* feat: Changed pin data discovery flow to avoid tooltip glitching.

* fix: Changed copy selection to copy all input data.

* feat: Updated pin data validation error message for unexpected single quotes.

* fix: Replaced :manual='true' prop with manual shorthand.

* fix: Removed unused variable.

* chore: Renamed translation key to node.discovery.pinData.

* refactor: Extracted isPinDataNodeType to pinData mixin.

* fix: Updated watch condition to improve performance.

* refactor: Renamed some pin data variables and methods as per review.

* fix: Added partial translation for JSON.parse pin data error messages.

* chore: Temporarily disabled failing unit test.

* 🧪 Fix data pinning workflow retrieval test

* 🔥 Remove unused imports

* 🔥 Remove leftover line

*  Skip pindata node issues on BE

*  Skip pindata node issues on FE

*  Hide `RunInfo` for pindata node

*  Hide purple banner in edit output mode

* feat: Updated data pinning discoverability flow.

* fix: Fixed paginated data pinning.

* fix: Disabled pin data in read only mode.

* 🐛 Fix runtime error with non-array

* fix: Loading pin data when opening execution.

*  Adjust stale data warning for pinned data

*  Skip auth in endpoint

*  Mark start node for pinned trigger

* ✏️ Comment on passthrough

* 🔥 Remove comment

* Final pindata metrics changes (#3673)

* 🐛 Fix `pinData` tracked as `0`

*  Add `is_pinned` to `nodesGraph`

* 📘 Extend `IWorkflowBase`

*  Handle `pinData` being `undefined`

*  Add `data_pinning_tooltip_presented`

* ♻️ Refactor to remove circular dependency

* fix: Added pin data handling when importing workflow. (#3698)

* 🔥 Remove helper from WorkflowExecute

*  Add logic for single pinned trigger

* 👕 Remove lint exception

* fix: Added pin data handling in importWorkflowExact.

* N8N-4077 data pinning discoverability part 2 (#3701)

* fix: Fixed pin data discovery tooltip position when moving canvas.

* feat: Updated data pinning discovery tooltip copy.

* Fix data pinning build (#3702)

*  Disable edit button for disabled node

*  Ensure disabled pinned nodes are passthrough

* 🐛 Fix JSON key unfurling in edit mode

*  Improve implementation

* 🐛 Fix console error

* fix: Fixed copying pinned output data. (#3715)

* Fix pinning for webhook responding with output from last node (#3719)

* fix: Fixed entering edit mode after refresh.

* fix: Fixed type error during build.

* fix: RunData import formatting.

* chore: Updated pin data types.

* fix: Added missing type to stringSizeInBytes.

Co-authored-by: Iván Ovejero <ivov.src@gmail.com>

* fix: Showing pin data without executing the node only in output pane.

* fix: Updated no data message when previous node not executed.

* feat: Added expression input and evaluation for pin data nodes without execution.

* chore: Fixed linting issues and removed remnant console.log().

* chore: Undone package-lock changes.

* fix: Removed pin data store changes.

* fix: Created a new object using vuex runExecutionData.

* fix: Fixed bug appearing when adding a new node after executing.

* fix: Fix editor-ui build

* feat: Added green node connectors when having pin data output.

* chore: Fixed linting errors.

* fix: Added pin data eventBus unsubscribe.

* fix: Added pin data color check after adding a connection.

* 🎨 Add pindata styles

Co-authored-by: Iván Ovejero <ivov.src@gmail.com>
This commit is contained in:
Alex Grozav 2022-07-20 18:50:39 +03:00 committed by GitHub
parent fb67543b2f
commit 15693b0056
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
99 changed files with 4145 additions and 1868 deletions

View file

@ -15,6 +15,7 @@ import {
ITelemetrySettings,
ITelemetryTrackProperties,
IWorkflowBase as IWorkflowBaseWorkflow,
PinData,
Workflow,
WorkflowExecuteMode,
} from 'n8n-workflow';
@ -688,6 +689,7 @@ export interface IWorkflowExecutionDataProcess {
executionMode: WorkflowExecuteMode;
executionData?: IRunExecutionData;
runData?: IRunData;
pinData?: PinData;
retryOf?: number | string;
sessionId?: string;
startNodes?: string[];

View file

@ -74,6 +74,7 @@ import {
IWorkflowBase,
LoggerProxy,
NodeHelpers,
PinData,
WebhookHttpMethod,
Workflow,
WorkflowExecuteMode,
@ -130,6 +131,7 @@ import {
WorkflowRunner,
getCredentialForUser,
getCredentialWithoutUser,
IWorkflowDb,
} from '.';
import config from '../config';
@ -157,9 +159,9 @@ import type {
} from './requests';
import { DEFAULT_EXECUTIONS_GET_ALL_LIMIT, validateEntity } from './GenericHelpers';
import { ExecutionEntity } from './databases/entities/ExecutionEntity';
import { SharedWorkflow } from './databases/entities/SharedWorkflow';
import { AUTH_COOKIE_NAME, RESPONSE_ERROR_MESSAGES } from './constants';
import { credentialsController } from './api/credentials.api';
import { workflowsController } from './api/workflows.api';
import { nodesController } from './api/nodes.api';
import { oauth2CredentialController } from './api/oauth2Credential.api';
import {
@ -168,6 +170,7 @@ import {
isUserManagementEnabled,
} from './UserManagement/UserManagementHelper';
import { loadPublicApiVersions } from './PublicApi';
import { SharedWorkflow } from './databases/entities/SharedWorkflow';
require('body-parser-xml')(bodyParser);
@ -769,74 +772,6 @@ class App {
// Workflow
// ----------------------------------------
// Creates a new workflow
this.app.post(
`/${this.restEndpoint}/workflows`,
ResponseHelper.send(async (req: WorkflowRequest.Create) => {
delete req.body.id; // delete if sent
const newWorkflow = new WorkflowEntity();
Object.assign(newWorkflow, req.body);
await validateEntity(newWorkflow);
await this.externalHooks.run('workflow.create', [newWorkflow]);
const { tags: tagIds } = req.body;
if (tagIds?.length && !config.getEnv('workflowTagsDisabled')) {
newWorkflow.tags = await Db.collections.Tag.findByIds(tagIds, {
select: ['id', 'name'],
});
}
await WorkflowHelpers.replaceInvalidCredentials(newWorkflow);
let savedWorkflow: undefined | WorkflowEntity;
await getConnection().transaction(async (transactionManager) => {
savedWorkflow = await transactionManager.save<WorkflowEntity>(newWorkflow);
const role = await Db.collections.Role.findOneOrFail({
name: 'owner',
scope: 'workflow',
});
const newSharedWorkflow = new SharedWorkflow();
Object.assign(newSharedWorkflow, {
role,
user: req.user,
workflow: savedWorkflow,
});
await transactionManager.save<SharedWorkflow>(newSharedWorkflow);
});
if (!savedWorkflow) {
LoggerProxy.error('Failed to create workflow', { userId: req.user.id });
throw new ResponseHelper.ResponseError('Failed to save workflow');
}
if (tagIds && !config.getEnv('workflowTagsDisabled')) {
savedWorkflow.tags = TagHelpers.sortByRequestOrder(savedWorkflow.tags, {
requestOrder: tagIds,
});
}
await this.externalHooks.run('workflow.afterCreate', [savedWorkflow]);
void InternalHooksManager.getInstance().onWorkflowCreated(req.user.id, newWorkflow, false);
const { id, ...rest } = savedWorkflow;
return {
id: id.toString(),
...rest,
};
}),
);
// Reads and returns workflow data from an URL
this.app.get(
`/${this.restEndpoint}/workflows/from-url`,
@ -962,50 +897,6 @@ class App {
}),
);
// Returns a specific workflow
this.app.get(
`/${this.restEndpoint}/workflows/:id`,
ResponseHelper.send(async (req: WorkflowRequest.Get) => {
const { id: workflowId } = req.params;
let relations = ['workflow', 'workflow.tags'];
if (config.getEnv('workflowTagsDisabled')) {
relations = relations.filter((relation) => relation !== 'workflow.tags');
}
const shared = await Db.collections.SharedWorkflow.findOne({
relations,
where: whereClause({
user: req.user,
entityType: 'workflow',
entityId: workflowId,
}),
});
if (!shared) {
LoggerProxy.info('User attempted to access a workflow without permissions', {
workflowId,
userId: req.user.id,
});
throw new ResponseHelper.ResponseError(
`Workflow with ID "${workflowId}" could not be found.`,
undefined,
404,
);
}
const {
workflow: { id, ...rest },
} = shared;
return {
id: id.toString(),
...rest,
};
}),
);
// Updates an existing workflow
this.app.patch(
`/${this.restEndpoint}/workflows/:id`,
@ -1204,6 +1095,7 @@ class App {
): Promise<IExecutionPushResponse> => {
const { workflowData } = req.body;
const { runData } = req.body;
const { pinData } = req.body;
const { startNodes } = req.body;
const { destinationNode } = req.body;
const executionMode = 'manual';
@ -1211,12 +1103,15 @@ class App {
const sessionId = GenericHelpers.getSessionId(req);
const pinnedTrigger = findFirstPinnedTrigger(workflowData, pinData);
// If webhooks nodes exist and are active we have to wait for till we receive a call
if (
runData === undefined ||
startNodes === undefined ||
startNodes.length === 0 ||
destinationNode === undefined
pinnedTrigger === undefined &&
(runData === undefined ||
startNodes === undefined ||
startNodes.length === 0 ||
destinationNode === undefined)
) {
const additionalData = await WorkflowExecuteAdditionalData.getBase(req.user.id);
const nodeTypes = NodeTypes();
@ -1254,11 +1149,17 @@ class App {
destinationNode,
executionMode,
runData,
pinData,
sessionId,
startNodes,
workflowData,
userId: req.user.id,
};
if (pinnedTrigger) {
data.startNodes = [pinnedTrigger.name];
}
const workflowRunner = new WorkflowRunner();
const executionId = await workflowRunner.run(data);
@ -1269,6 +1170,8 @@ class App {
),
);
this.app.use(`/${this.restEndpoint}/workflows`, workflowsController);
// Retrieves all tags, with or without usage count
this.app.get(
`/${this.restEndpoint}/tags`,
@ -2927,3 +2830,20 @@ function isOAuth(credType: ICredentialType) {
)
);
}
const TRIGGER_NODE_SUFFIXES = ['trigger', 'webhook'];
const isTrigger = (str: string) =>
TRIGGER_NODE_SUFFIXES.some((suffix) => str.toLowerCase().includes(suffix));
function findFirstPinnedTrigger(workflow: IWorkflowDb, pinData?: PinData) {
if (!pinData) return;
const firstPinnedTriggerName = Object.keys(pinData).find(isTrigger);
if (!firstPinnedTriggerName) return;
return workflow.nodes.find(
({ type, name }) => isTrigger(type) && name === firstPinnedTriggerName,
);
}

View file

@ -485,6 +485,10 @@ export async function executeWebhook(
return undefined;
}
if (workflowData.pinData) {
data.data.resultData.pinData = workflowData.pinData;
}
const returnData = WorkflowHelpers.getDataLastExecutedNodeData(data);
if (data.data.resultData.error || returnData?.error !== undefined) {
if (!didSendResponse) {

View file

@ -50,7 +50,7 @@ const ERROR_TRIGGER_TYPE = config.getEnv('nodes.errorTriggerType');
* @returns {(ITaskData | undefined)}
*/
export function getDataLastExecutedNodeData(inputData: IRun): ITaskData | undefined {
const { runData } = inputData.data.resultData;
const { runData, pinData = {} } = inputData.data.resultData;
const { lastNodeExecuted } = inputData.data.resultData;
if (lastNodeExecuted === undefined) {
@ -61,7 +61,26 @@ export function getDataLastExecutedNodeData(inputData: IRun): ITaskData | undefi
return undefined;
}
return runData[lastNodeExecuted][runData[lastNodeExecuted].length - 1];
const lastNodeRunData = runData[lastNodeExecuted][runData[lastNodeExecuted].length - 1];
let lastNodePinData = pinData[lastNodeExecuted];
if (lastNodePinData) {
if (!Array.isArray(lastNodePinData)) lastNodePinData = [lastNodePinData];
const itemsPerRun = lastNodePinData.map((item, index) => {
return { json: item, pairedItem: { item: index } };
});
return {
startTime: 0,
executionTime: 0,
data: { main: [itemsPerRun] },
source: lastNodeRunData.source,
};
}
return lastNodeRunData;
}
/**

View file

@ -310,7 +310,12 @@ export class WorkflowRunner {
// Can execute without webhook so go on
const workflowExecute = new WorkflowExecute(additionalData, data.executionMode);
workflowExecution = workflowExecute.run(workflow, undefined, data.destinationNode);
workflowExecution = workflowExecute.run(
workflow,
undefined,
data.destinationNode,
data.pinData,
);
} else {
Logger.debug(`Execution ID ${executionId} is a partial execution.`, { executionId });
// Execute only the nodes between start and destination nodes
@ -320,6 +325,7 @@ export class WorkflowRunner {
data.runData,
data.startNodes,
data.destinationNode,
data.pinData,
);
}

View file

@ -345,9 +345,22 @@ export class WorkflowRunnerProcess {
) {
// Execute all nodes
let startNode;
if (
this.data.startNodes?.length === 1 &&
Object.keys(this.data.pinData ?? {}).includes(this.data.startNodes[0])
) {
startNode = this.workflow.getNode(this.data.startNodes[0]) ?? undefined;
}
// Can execute without webhook so go on
this.workflowExecute = new WorkflowExecute(additionalData, this.data.executionMode);
return this.workflowExecute.run(this.workflow, undefined, this.data.destinationNode);
return this.workflowExecute.run(
this.workflow,
startNode,
this.data.destinationNode,
this.data.pinData,
);
}
// Execute only the nodes between start and destination nodes
this.workflowExecute = new WorkflowExecute(additionalData, this.data.executionMode);
@ -356,6 +369,7 @@ export class WorkflowRunnerProcess {
this.data.runData,
this.data.startNodes,
this.data.destinationNode,
this.data.pinData,
);
}

View file

@ -0,0 +1,134 @@
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable no-param-reassign */
/* eslint-disable import/no-cycle */
import express from 'express';
import { LoggerProxy } from 'n8n-workflow';
import { Db, ResponseHelper, whereClause, WorkflowHelpers } from '..';
import config from '../../config';
import * as TagHelpers from '../TagHelpers';
import { SharedWorkflow } from '../databases/entities/SharedWorkflow';
import { WorkflowEntity } from '../databases/entities/WorkflowEntity';
import { validateEntity } from '../GenericHelpers';
import { InternalHooksManager } from '../InternalHooksManager';
import { externalHooks } from '../Server';
import type { WorkflowRequest } from '../requests';
export const workflowsController = express.Router();
/**
* POST /workflows
*/
workflowsController.post(
'/',
ResponseHelper.send(async (req: WorkflowRequest.Create) => {
delete req.body.id; // delete if sent
const newWorkflow = new WorkflowEntity();
Object.assign(newWorkflow, req.body);
await validateEntity(newWorkflow);
await externalHooks.run('workflow.create', [newWorkflow]);
const { tags: tagIds } = req.body;
if (tagIds?.length && !config.getEnv('workflowTagsDisabled')) {
newWorkflow.tags = await Db.collections.Tag.findByIds(tagIds, {
select: ['id', 'name'],
});
}
await WorkflowHelpers.replaceInvalidCredentials(newWorkflow);
let savedWorkflow: undefined | WorkflowEntity;
await Db.transaction(async (transactionManager) => {
savedWorkflow = await transactionManager.save<WorkflowEntity>(newWorkflow);
const role = await Db.collections.Role.findOneOrFail({
name: 'owner',
scope: 'workflow',
});
const newSharedWorkflow = new SharedWorkflow();
Object.assign(newSharedWorkflow, {
role,
user: req.user,
workflow: savedWorkflow,
});
await transactionManager.save<SharedWorkflow>(newSharedWorkflow);
});
if (!savedWorkflow) {
LoggerProxy.error('Failed to create workflow', { userId: req.user.id });
throw new ResponseHelper.ResponseError('Failed to save workflow');
}
if (tagIds && !config.getEnv('workflowTagsDisabled')) {
savedWorkflow.tags = TagHelpers.sortByRequestOrder(savedWorkflow.tags, {
requestOrder: tagIds,
});
}
await externalHooks.run('workflow.afterCreate', [savedWorkflow]);
void InternalHooksManager.getInstance().onWorkflowCreated(req.user.id, newWorkflow, false);
const { id, ...rest } = savedWorkflow;
return {
id: id.toString(),
...rest,
};
}),
);
/**
* GET /workflows/:id
*/
workflowsController.get(
'/:id',
ResponseHelper.send(async (req: WorkflowRequest.Get) => {
const { id: workflowId } = req.params;
let relations = ['workflow', 'workflow.tags'];
if (config.getEnv('workflowTagsDisabled')) {
relations = relations.filter((relation) => relation !== 'workflow.tags');
}
const shared = await Db.collections.SharedWorkflow.findOne({
relations,
where: whereClause({
user: req.user,
entityType: 'workflow',
entityId: workflowId,
}),
});
if (!shared) {
LoggerProxy.info('User attempted to access a workflow without permissions', {
workflowId,
userId: req.user.id,
});
throw new ResponseHelper.ResponseError(
`Workflow with ID "${workflowId}" could not be found.`,
undefined,
404,
);
}
const {
workflow: { id, ...rest },
} = shared;
return {
id: id.toString(),
...rest,
};
}),
);

View file

@ -2,7 +2,7 @@
/* eslint-disable import/no-cycle */
import { Length } from 'class-validator';
import { IConnections, IDataObject, INode, IWorkflowSettings } from 'n8n-workflow';
import { IConnections, IDataObject, INode, IWorkflowSettings, PinData } from 'n8n-workflow';
import {
BeforeUpdate,
@ -22,7 +22,7 @@ import * as config from '../../../config';
import { DatabaseType, IWorkflowDb } from '../..';
import { TagEntity } from './TagEntity';
import { SharedWorkflow } from './SharedWorkflow';
import { objectRetriever } from '../utils/transformers';
import { objectRetriever, serializer } from '../utils/transformers';
function resolveDataType(dataType: string) {
const dbType = config.getEnv('database.type');
@ -117,6 +117,13 @@ export class WorkflowEntity implements IWorkflowDb {
@OneToMany(() => SharedWorkflow, (sharedWorkflow) => sharedWorkflow.workflow)
shared: SharedWorkflow[];
@Column({
type: config.getEnv('database.type') === 'sqlite' ? 'text' : 'json',
nullable: true,
transformer: serializer,
})
pinData: PinData;
@BeforeUpdate()
setUpdateDate() {
this.updatedAt = new Date();

View file

@ -0,0 +1,25 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import { logMigrationEnd, logMigrationStart } from '../../utils/migrationHelpers';
import config from '../../../../config';
export class IntroducePinData1654090101303 implements MigrationInterface {
name = 'IntroducePinData1654090101303';
async up(queryRunner: QueryRunner): Promise<void> {
logMigrationStart(this.name);
const tablePrefix = config.getEnv('database.tablePrefix');
await queryRunner.query(
`ALTER TABLE \`${tablePrefix}workflow_entity\` ADD \`pinData\` json`,
);
logMigrationEnd(this.name);
}
async down(queryRunner: QueryRunner): Promise<void> {
const tablePrefix = config.getEnv('database.tablePrefix');
await queryRunner.query(`ALTER TABLE \`${tablePrefix}workflow_entity\` DROP COLUMN \`pinData\``);
}
}

View file

@ -16,6 +16,7 @@ import { LowerCaseUserEmail1648740597343 } from './1648740597343-LowerCaseUserEm
import { AddUserSettings1652367743993 } from './1652367743993-AddUserSettings';
import { CommunityNodes1652254514003 } from './1652254514003-CommunityNodes';
import { AddAPIKeyColumn1652905585850 } from './1652905585850-AddAPIKeyColumn';
import { IntroducePinData1654090101303 } from './1654090101303-IntroducePinData';
export const mysqlMigrations = [
InitialMigration1588157391238,
@ -36,4 +37,5 @@ export const mysqlMigrations = [
AddUserSettings1652367743993,
CommunityNodes1652254514003,
AddAPIKeyColumn1652905585850,
IntroducePinData1654090101303,
];

View file

@ -0,0 +1,33 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import { logMigrationEnd, logMigrationStart } from '../../utils/migrationHelpers';
import config from '../../../../config';
export class IntroducePinData1654090467022 implements MigrationInterface {
name = 'IntroducePinData1654090467022';
async up(queryRunner: QueryRunner) {
logMigrationStart(this.name);
const schema = config.getEnv('database.postgresdb.schema');
const tablePrefix = config.getEnv('database.tablePrefix');
await queryRunner.query(`SET search_path TO ${schema}`);
await queryRunner.query(
`ALTER TABLE ${schema}.${tablePrefix}workflow_entity ADD "pinData" json`,
);
logMigrationEnd(this.name);
}
async down(queryRunner: QueryRunner) {
const schema = config.getEnv('database.postgresdb.schema');
const tablePrefix = config.getEnv('database.tablePrefix');
await queryRunner.query(`SET search_path TO ${schema}`);
await queryRunner.query(
`ALTER TABLE ${schema}.${tablePrefix}workflow_entity DROP COLUMN "pinData"`,
);
}
}

View file

@ -14,6 +14,7 @@ import { LowerCaseUserEmail1648740597343 } from './1648740597343-LowerCaseUserEm
import { AddUserSettings1652367743993 } from './1652367743993-AddUserSettings';
import { CommunityNodes1652254514002 } from './1652254514002-CommunityNodes';
import { AddAPIKeyColumn1652905585850 } from './1652905585850-AddAPIKeyColumn';
import { IntroducePinData1654090467022 } from './1654090467022-IntroducePinData';
export const postgresMigrations = [
InitialMigration1587669153312,
@ -32,4 +33,5 @@ export const postgresMigrations = [
AddUserSettings1652367743993,
CommunityNodes1652254514002,
AddAPIKeyColumn1652905585850,
IntroducePinData1654090467022,
];

View file

@ -0,0 +1,33 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import { logMigrationEnd, logMigrationStart } from '../../utils/migrationHelpers';
import config from '../../../../config';
export class IntroducePinData1654089251344 implements MigrationInterface {
name = 'IntroducePinData1654089251344';
async up(queryRunner: QueryRunner): Promise<void> {
logMigrationStart(this.name);
const tablePrefix = config.getEnv('database.tablePrefix');
await queryRunner.query(
`ALTER TABLE \`${tablePrefix}workflow_entity\` ADD COLUMN "pinData" text`,
);
logMigrationEnd(this.name);
}
async down(queryRunner: QueryRunner): Promise<void> {
const tablePrefix = config.getEnv('database.tablePrefix');
await queryRunner.query(`ALTER TABLE \`${tablePrefix}workflow_entity\` RENAME TO "temporary_workflow_entity"`);
await queryRunner.query(
`CREATE TABLE \`${tablePrefix}workflow_entity\` (
"id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "name" varchar(128) NOT NULL, "active" boolean NOT NULL, "nodes" text NOT NULL, "connections" text NOT NULL, "createdAt" datetime NOT NULL, "updatedAt" datetime NOT NULL, "settings" text, "staticData" text`,
);
await queryRunner.query(
`INSERT INTO \`${tablePrefix}workflow_entity\` ("id", "name", "active", "nodes", "connections", "createdAt", "updatedAt", "settings", "staticData") SELECT "id", "name", "active", "nodes", "connections", "createdAt", "updatedAt", "settings", "staticData" FROM "temporary_workflow_entity"`,
);
await queryRunner.query(`DROP TABLE "temporary_workflow_entity"`);
}
}

View file

@ -13,6 +13,7 @@ import { LowerCaseUserEmail1648740597343 } from './1648740597343-LowerCaseUserEm
import { AddUserSettings1652367743993 } from './1652367743993-AddUserSettings';
import { CommunityNodes1652254514001 } from './1652254514001-CommunityNodes'
import { AddAPIKeyColumn1652905585850 } from './1652905585850-AddAPIKeyColumn';
import { IntroducePinData1654089251344 } from './1654089251344-IntroducePinData';
const sqliteMigrations = [
InitialMigration1588102412422,
@ -30,6 +31,7 @@ const sqliteMigrations = [
AddUserSettings1652367743993,
CommunityNodes1652254514001,
AddAPIKeyColumn1652905585850,
IntroducePinData1654089251344,
];
export { sqliteMigrations };

View file

@ -18,3 +18,13 @@ export const objectRetriever: ValueTransformer = {
from: (value: string | object): object =>
typeof value === 'string' ? (JSON.parse(value) as object) : value,
};
/**
* Transformer to store object as string and retrieve string as object.
*/
export const serializer: ValueTransformer = {
to: (value: object | string): string =>
typeof value === 'object' ? JSON.stringify(value) : value,
from: (value: string | object): object =>
typeof value === 'string' ? (JSON.parse(value) as object) : value,
};

View file

@ -8,6 +8,7 @@ import {
INodeCredentialTestRequest,
IRunData,
IWorkflowSettings,
PinData,
} from 'n8n-workflow';
import { User } from './databases/entities/User';
@ -71,6 +72,7 @@ export declare namespace WorkflowRequest {
{
workflowData: IWorkflowDb;
runData: IRunData;
pinData: PinData;
startNodes?: string[];
destinationNode?: string;
}

View file

@ -38,7 +38,10 @@ beforeAll(async () => {
});
beforeEach(async () => {
await testDb.truncate(['SharedWorkflow', 'User', 'Workflow'], testDbName);
await testDb.truncate(
['SharedCredentials', 'SharedWorkflow', 'Tag', 'User', 'Workflow', 'Credentials'],
testDbName,
);
config.set('userManagement.disabled', false);
config.set('userManagement.isInstanceOwnerSetUp', true);

View file

@ -201,6 +201,7 @@ export async function truncate(collections: Array<CollectionName>, testDbName: s
const truncationPromises = collections.map((collection) => {
const tableName = toTableName(collection);
Db.collections[collection].clear();
return testDb.query(
`DELETE FROM ${tableName}; DELETE FROM sqlite_sequence WHERE name=${tableName};`,
);
@ -223,7 +224,6 @@ export async function truncate(collections: Array<CollectionName>, testDbName: s
}
return await truncateMappingTables(dbType, collections, testDb);
// return Promise.resolve([])
}
/**

View file

@ -17,6 +17,7 @@ type EndpointGroup =
| 'owner'
| 'passwordReset'
| 'credentials'
| 'workflows'
| 'publicApi'
| 'nodes';

View file

@ -53,6 +53,7 @@ import type {
TriggerTime,
} from './types';
import type { N8nApp } from '../../../src/UserManagement/Interfaces';
import { workflowsController } from '../../../src/api/workflows.api';
import { nodesController } from '../../../src/api/nodes.api';
import { randomName } from './random';
@ -96,17 +97,18 @@ export async function initTestServer({
if (routerEndpoints.length) {
const { apiRouters } = await loadPublicApiVersions(testServer.publicApiEndpoint);
const map: Record<string, express.Router | express.Router[]> = {
credentials: credentialsController,
nodes: nodesController,
publicApi: apiRouters,
const map: Record<string, express.Router | express.Router[] | any> = {
credentials: { controller: credentialsController, path: 'credentials' },
workflows: { controller: workflowsController, path: 'workflows' },
nodes: { controller: nodesController, path: 'nodes' },
publicApi: apiRouters
};
for (const group of routerEndpoints) {
if (group === 'publicApi') {
testServer.app.use(...(map[group] as express.Router[]));
} else {
testServer.app.use(`/${testServer.restEndpoint}/${group}`, map[group]);
testServer.app.use(`/${testServer.restEndpoint}/${map[group].path}`, map[group].controller);
}
}
}
@ -145,8 +147,10 @@ const classifyEndpointGroups = (endpointGroups: string[]) => {
const routerEndpoints: string[] = [];
const functionEndpoints: string[] = [];
const ROUTER_GROUP = ['credentials', 'nodes', 'workflows', 'publicApi'];
endpointGroups.forEach((group) =>
(['credentials', 'nodes', 'publicApi'].includes(group) ? routerEndpoints : functionEndpoints).push(group),
(ROUTER_GROUP.includes(group) ? routerEndpoints : functionEndpoints).push(group),
);
return [routerEndpoints, functionEndpoints];

View file

@ -0,0 +1,107 @@
import express from 'express';
import * as utils from './shared/utils';
import * as testDb from './shared/testDb';
import { WorkflowEntity } from '../../src/databases/entities/WorkflowEntity';
import type { Role } from '../../src/databases/entities/Role';
import { PinData } from 'n8n-workflow';
jest.mock('../../src/telemetry');
let app: express.Application;
let testDbName = '';
let globalOwnerRole: Role;
beforeAll(async () => {
app = await utils.initTestServer({
endpointGroups: ['workflows'],
applyAuth: true,
});
const initResult = await testDb.init();
testDbName = initResult.testDbName;
globalOwnerRole = await testDb.getGlobalOwnerRole();
utils.initTestLogger();
utils.initTestTelemetry();
});
beforeEach(async () => {
await testDb.truncate(['User', 'Workflow', 'SharedWorkflow'], testDbName);
});
afterAll(async () => {
await testDb.terminate(testDbName);
});
test('POST /workflows should store pin data for node in workflow', async () => {
const ownerShell = await testDb.createUserShell(globalOwnerRole);
const authOwnerAgent = utils.createAgent(app, { auth: true, user: ownerShell });
const workflow = makeWorkflow({ withPinData: true });
const response = await authOwnerAgent.post('/workflows').send(workflow);
expect(response.statusCode).toBe(200);
const { pinData } = response.body.data as { pinData: PinData };
expect(pinData).toMatchObject({ Spotify: [{ myKey: 'myValue' }] });
});
test('POST /workflows should set pin data to null if no pin data', async () => {
const ownerShell = await testDb.createUserShell(globalOwnerRole);
const authOwnerAgent = utils.createAgent(app, { auth: true, user: ownerShell });
const workflow = makeWorkflow({ withPinData: false });
const response = await authOwnerAgent.post('/workflows').send(workflow);
expect(response.statusCode).toBe(200);
const { pinData } = response.body.data as { pinData: PinData };
expect(pinData).toBeNull();
});
test('GET /workflows/:id should return pin data', async () => {
const ownerShell = await testDb.createUserShell(globalOwnerRole);
const authOwnerAgent = utils.createAgent(app, { auth: true, user: ownerShell });
const workflow = makeWorkflow({ withPinData: true });
const workflowCreationResponse = await authOwnerAgent.post('/workflows').send(workflow);
const { id } = workflowCreationResponse.body.data as { id: string };
const workflowRetrievalResponse = await authOwnerAgent.get(`/workflows/${id}`);
expect(workflowRetrievalResponse.statusCode).toBe(200);
const { pinData } = workflowRetrievalResponse.body.data as { pinData: PinData };
expect(pinData).toMatchObject({ Spotify: [{ myKey: 'myValue' }] });
});
function makeWorkflow({ withPinData }: { withPinData: boolean }) {
const workflow = new WorkflowEntity();
workflow.name = 'My Workflow';
workflow.active = false;
workflow.connections = {};
workflow.nodes = [
{
name: 'Spotify',
type: 'n8n-nodes-base.spotify',
parameters: { resource: 'track', operation: 'get', id: '123' },
typeVersion: 1,
position: [740, 240],
},
];
if (withPinData) {
workflow.pinData = { Spotify: [{ myKey: 'myValue' }] };
}
return workflow;
}

View file

@ -32,6 +32,7 @@ import {
LoggerProxy as Logger,
NodeApiError,
NodeOperationError,
PinData,
Workflow,
WorkflowExecuteMode,
WorkflowOperationError,
@ -59,6 +60,7 @@ export class WorkflowExecute {
startData: {},
resultData: {
runData: {},
pinData: {},
},
executionData: {
contextData: {},
@ -82,7 +84,12 @@ export class WorkflowExecute {
// PCancelable to a regular Promise and does so not allow canceling
// active executions anymore
// eslint-disable-next-line @typescript-eslint/promise-function-async
run(workflow: Workflow, startNode?: INode, destinationNode?: string): PCancelable<IRun> {
run(
workflow: Workflow,
startNode?: INode,
destinationNode?: string,
pinData?: PinData,
): PCancelable<IRun> {
// Get the nodes to start workflow execution from
startNode = startNode || workflow.getStartNode(destinationNode);
@ -121,6 +128,7 @@ export class WorkflowExecute {
},
resultData: {
runData: {},
pinData,
},
executionData: {
contextData: {},
@ -152,6 +160,7 @@ export class WorkflowExecute {
runData: IRunData,
startNodes: string[],
destinationNode: string,
pinData?: PinData,
// @ts-ignore
): PCancelable<IRun> {
let incomingNodeConnections: INodeConnections | undefined;
@ -258,6 +267,7 @@ export class WorkflowExecute {
},
resultData: {
runData,
pinData,
},
executionData: {
contextData: {},
@ -683,7 +693,13 @@ export class WorkflowExecute {
destinationNode = this.runExecutionData.startData.destinationNode;
}
const workflowIssues = workflow.checkReadyForExecution({ startNode, destinationNode });
const pinDataNodeNames = Object.keys(this.runExecutionData.resultData.pinData ?? {});
const workflowIssues = workflow.checkReadyForExecution({
startNode,
destinationNode,
pinDataNodeNames,
});
if (workflowIssues !== null) {
throw new Error(
'The workflow has issues and can for that reason not be executed. Please fix them first.',
@ -914,24 +930,37 @@ export class WorkflowExecute {
}
}
Logger.debug(`Running node "${executionNode.name}" started`, {
node: executionNode.name,
workflowId: workflow.id,
});
const runNodeData = await workflow.runNode(
executionData,
this.runExecutionData,
runIndex,
this.additionalData,
NodeExecuteFunctions,
this.mode,
);
nodeSuccessData = runNodeData.data;
const { pinData } = this.runExecutionData.resultData;
if (runNodeData.closeFunction) {
// Explanation why we do this can be found in n8n-workflow/Workflow.ts -> runNode
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
closeFunction = runNodeData.closeFunction();
if (pinData && !executionNode.disabled && pinData[executionNode.name] !== undefined) {
let nodePinData = pinData[executionNode.name];
if (!Array.isArray(nodePinData)) nodePinData = [nodePinData];
const itemsPerRun = nodePinData.map((item, index) => {
return { json: item, pairedItem: { item: index } };
});
nodeSuccessData = [itemsPerRun]; // always zeroth runIndex
} else {
Logger.debug(`Running node "${executionNode.name}" started`, {
node: executionNode.name,
workflowId: workflow.id,
});
const runNodeData = await workflow.runNode(
executionData,
this.runExecutionData,
runIndex,
this.additionalData,
NodeExecuteFunctions,
this.mode,
);
nodeSuccessData = runNodeData.data;
if (runNodeData.closeFunction) {
// Explanation why we do this can be found in n8n-workflow/Workflow.ts -> runNode
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
closeFunction = runNodeData.closeFunction();
}
}
Logger.debug(`Running node "${executionNode.name}" finished successfully`, {

View file

@ -1,122 +0,0 @@
import N8nButton from './Button.vue';
import { action } from '@storybook/addon-actions';
export default {
title: 'Atoms/Button',
component: N8nButton,
argTypes: {
label: {
control: 'text',
},
title: {
control: 'text',
},
type: {
control: 'select',
options: ['primary', 'outline', 'light', 'text', 'tertiary'],
},
size: {
control: {
type: 'select',
options: ['mini', 'small', 'medium', 'large', 'xlarge'],
},
},
loading: {
control: {
type: 'boolean',
},
},
icon: {
control: {
type: 'text',
},
},
circle: {
control: {
type: 'boolean',
},
},
fullWidth: {
type: 'boolean',
},
theme: {
type: 'select',
options: ['success', 'danger', 'warning'],
},
float: {
type: 'select',
options: ['left', 'right'],
},
},
parameters: {
design: {
type: 'figma',
url: 'https://www.figma.com/file/DxLbnIyMK8X0uLkUguFV4n/n8n-design-system_v1?node-id=5%3A1147',
},
},
};
const methods = {
onClick: action('click'),
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: {
N8nButton,
},
template: '<n8n-button v-bind="$props" @click="onClick" />',
methods,
});
export const Button = Template.bind({});
Button.args = {
label: 'Button',
};
const ManyTemplate = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: {
N8nButton,
},
template:
'<div> <n8n-button v-bind="$props" size="large" @click="onClick" /> <n8n-button v-bind="$props" size="medium" @click="onClick" /> <n8n-button v-bind="$props" size="small" @click="onClick" /> <n8n-button v-bind="$props" :loading="true" @click="onClick" /> <n8n-button v-bind="$props" :disabled="true" @click="onClick" /></div>',
methods,
});
export const Primary = ManyTemplate.bind({});
Primary.args = {
type: 'primary',
label: 'Button',
};
export const Outline = ManyTemplate.bind({});
Outline.args = {
type: 'outline',
label: 'Button',
};
export const Tertiary = ManyTemplate.bind({});
Tertiary.args = {
type: 'tertiary',
label: 'Button',
};
export const Light = ManyTemplate.bind({});
Light.args = {
type: 'light',
label: 'Button',
};
export const WithIcon = ManyTemplate.bind({});
WithIcon.args = {
label: 'Button',
icon: 'plus-circle',
};
export const Text = ManyTemplate.bind({});
Text.args = {
type: 'text',
label: 'Button',
icon: 'plus-circle',
};

View file

@ -0,0 +1,167 @@
/* tslint:disable:variable-name */
import N8nButton from './Button.vue';
import { action } from '@storybook/addon-actions';
import { StoryFn } from "@storybook/vue";
export default {
title: 'Atoms/Button',
component: N8nButton,
argTypes: {
type: {
control: 'select',
options: ['primary', 'secondary', 'tertiary', 'success', 'warning', 'danger'],
},
size: {
control: {
type: 'select',
options: ['mini', 'small', 'medium', 'large', 'xlarge'],
},
},
float: {
type: 'select',
options: ['left', 'right'],
},
},
parameters: {
design: {
type: 'figma',
url: 'https://www.figma.com/file/DxLbnIyMK8X0uLkUguFV4n/n8n-design-system_v1?node-id=5%3A1147',
},
},
};
const methods = {
onClick: action('click'),
};
const Template: StoryFn = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: {
N8nButton,
},
template: '<n8n-button v-bind="$props" @click="onClick" />',
methods,
});
export const Button = Template.bind({});
Button.args = {
label: 'Button',
};
const AllSizesTemplate: StoryFn = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: {
N8nButton,
},
template: `<div>
<n8n-button v-bind="$props" size="large" @click="onClick" />
<n8n-button v-bind="$props" size="medium" @click="onClick" />
<n8n-button v-bind="$props" size="small" @click="onClick" />
<n8n-button v-bind="$props" :loading="true" @click="onClick" />
<n8n-button v-bind="$props" :disabled="true" @click="onClick" />
</div>`,
methods,
});
const AllColorsTemplate: StoryFn = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: {
N8nButton,
},
template: `<div>
<n8n-button v-bind="$props" type="primary" @click="onClick" />
<n8n-button v-bind="$props" type="secondary" @click="onClick" />
<n8n-button v-bind="$props" type="tertiary" @click="onClick" />
<n8n-button v-bind="$props" type="success" @click="onClick" />
<n8n-button v-bind="$props" type="warning" @click="onClick" />
<n8n-button v-bind="$props" type="danger" @click="onClick" />
</div>`,
methods,
});
const AllColorsAndSizesTemplate: StoryFn = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: {
N8nButton,
},
template: `<div>
<n8n-button v-bind="$props" size="large" type="primary" @click="onClick" />
<n8n-button v-bind="$props" size="large" type="secondary" @click="onClick" />
<n8n-button v-bind="$props" size="large" type="tertiary" @click="onClick" />
<n8n-button v-bind="$props" size="large" type="success" @click="onClick" />
<n8n-button v-bind="$props" size="large" type="warning" @click="onClick" />
<n8n-button v-bind="$props" size="large" type="danger" @click="onClick" />
<br/>
<br/>
<n8n-button v-bind="$props" size="medium" type="primary" @click="onClick" />
<n8n-button v-bind="$props" size="medium" type="secondary" @click="onClick" />
<n8n-button v-bind="$props" size="medium" type="tertiary" @click="onClick" />
<n8n-button v-bind="$props" size="medium" type="success" @click="onClick" />
<n8n-button v-bind="$props" size="medium" type="warning" @click="onClick" />
<n8n-button v-bind="$props" size="medium" type="danger" @click="onClick" />
<br/>
<br/>
<n8n-button v-bind="$props" size="small" type="primary" @click="onClick" />
<n8n-button v-bind="$props" size="small" type="secondary" @click="onClick" />
<n8n-button v-bind="$props" size="small" type="tertiary" @click="onClick" />
<n8n-button v-bind="$props" size="small" type="success" @click="onClick" />
<n8n-button v-bind="$props" size="small" type="warning" @click="onClick" />
<n8n-button v-bind="$props" size="small" type="danger" @click="onClick" />
</div>`,
methods,
});
export const Primary = AllSizesTemplate.bind({});
Primary.args = {
type: 'primary',
label: 'Button',
};
export const Secondary = AllSizesTemplate.bind({});
Secondary.args = {
type: 'secondary',
label: 'Button',
};
export const Tertiary = AllSizesTemplate.bind({});
Tertiary.args = {
type: 'tertiary',
label: 'Button',
};
export const Success = AllSizesTemplate.bind({});
Success.args = {
type: 'success',
label: 'Button',
};
export const Warning = AllSizesTemplate.bind({});
Warning.args = {
type: 'warning',
label: 'Button',
};
export const Danger = AllSizesTemplate.bind({});
Danger.args = {
type: 'danger',
label: 'Button',
};
export const Outline = AllColorsAndSizesTemplate.bind({});
Outline.args = {
outline: true,
label: 'Button',
};
export const Text = AllColorsAndSizesTemplate.bind({});
Text.args = {
text: true,
label: 'Button',
};
export const WithIcon = AllSizesTemplate.bind({});
WithIcon.args = {
label: 'Button',
icon: 'plus-circle',
};

View file

@ -1,62 +1,45 @@
<template functional>
<component
:is="$options.components.ElButton"
:plain="props.type === 'outline'"
:disabled="props.disabled"
:size="props.size"
:loading="props.loading"
:title="props.title || props.label"
:class="$options.getClass(props, $style)"
:round="!props.circle && props.round"
:circle="props.circle"
:style="$options.styles(props)"
@click="(e) => listeners.click && listeners.click(e)"
<template>
<button
:class="classes"
:disabled="disabled || loading"
:aria-disabled="ariaDisabled"
:aria-busy="ariaBusy"
aria-live="polite"
v-on="$listeners"
>
<span :class="$style.icon" v-if="props.loading || props.icon">
<component
:is="$options.components.N8nSpinner"
v-if="props.loading"
:size="props.size"
<span :class="$style.icon" v-if="loading || icon">
<n8n-spinner
v-if="loading"
:size="size"
/>
<component
:is="$options.components.N8nIcon"
v-else-if="props.icon"
:icon="props.icon"
:size="props.size"
<n8n-icon
v-else-if="icon"
:icon="icon"
:size="size"
/>
</span>
<span v-if="props.label || $slots.default">
<slot>
{{ props.label }}
</slot>
<span v-if="label || $slots.default">
<slot>{{ label }}</slot>
</span>
</component>
</button>
</template>
<script lang="ts">
import Vue from 'vue';
import N8nIcon from '../N8nIcon';
import N8nSpinner from '../N8nSpinner';
import ElButton from 'element-ui/lib/button';
export default {
export default Vue.extend({
name: 'n8n-button',
props: {
label: {
type: String,
},
title: {
type: String,
},
type: {
type: String,
default: 'primary',
validator: (value: string): boolean =>
['primary', 'outline', 'light', 'text', 'tertiary'].includes(value),
},
theme: {
type: String,
validator: (value: string): boolean =>
['success', 'warning', 'danger'].includes(value),
['primary', 'secondary', 'tertiary', 'success', 'warning', 'danger'].includes(value),
},
size: {
type: String,
@ -72,13 +55,18 @@ export default {
type: Boolean,
default: false,
},
icon: {
},
round: {
outline: {
type: Boolean,
default: true,
default: false,
},
circle: {
text: {
type: Boolean,
default: false,
},
icon: {
type: [String, Array],
},
block: {
type: Boolean,
default: false,
},
@ -87,47 +75,82 @@ export default {
validator: (value: string): boolean =>
['left', 'right'].includes(value),
},
fullWidth: {
type: Boolean,
default: false,
},
transparentBackground: {
type: Boolean,
default: false,
},
},
components: {
ElButton,
N8nSpinner,
N8nIcon,
},
styles: (props: {
fullWidth?: boolean;
float?: string;
}): { float?: string; width?: string } => {
return {
...(props.float ? { float: props.float } : {}),
...(props.fullWidth ? { width: '100%' } : {}),
};
computed: {
ariaBusy(): string {
return this.loading ? 'true' : 'false';
},
ariaDisabled(): string {
return this.disabled ? 'true' : 'false';
},
classes(): string {
return `button ${this.$style['button']} ${this.$style[this.type]}` +
`${this.size ? ` ${this.$style[this.size]}` : ''}` +
`${this.outline ? ` ${this.$style['outline']}` : ''}` +
`${this.loading ? ` ${this.$style['loading']}` : ''}` +
`${this.float ? ` ${this.$style[`float-${this.float}`]}` : ''}` +
`${this.text ? ` ${this.$style['text']}` : ''}` +
`${this.disabled ? ` ${this.$style['disabled']}` : ''}` +
`${this.block ? ` ${this.$style['block']}` : ''}` +
`${this.icon || this.loading ? ` ${this.$style['icon']}` : ''}`;
},
},
getClass(props: { type: string; theme?: string, transparentBackground: boolean }, $style: any): string {
const theme = props.type === 'text' || props.type === 'tertiary'
? props.type
: `${props.type}-${props.theme || 'primary'}`;
if (props.transparentBackground) {
return `${$style[theme]} ${$style['transparent']}`;
}
return $style[theme];
},
};
});
</script>
<style lang="scss" module>
@import "../../utils";
@import '../../../theme/src/mixins/utils';
@import '../../../theme/src/common/var';
.button {
display: inline-block;
line-height: 1;
white-space: nowrap;
cursor: pointer;
border: var(--border-width-base) $button-border-color var(--border-style-base);
color: $button-font-color;
background-color: $button-background-color;
font-weight: var(--font-weight-bold);
border-radius: $button-border-radius;
padding: $button-padding-vertical $button-padding-horizontal;
font-size: $button-font-size;
-webkit-appearance: none;
text-align: center;
box-sizing: border-box;
outline: none;
margin: 0;
transition: 0.3s;
@include utils-user-select(none);
&:hover {
color: $button-hover-color;
border-color: $button-hover-border-color;
background-color: $button-hover-background-color;
}
&:focus {
border-color: $button-focus-outline-color;
outline: $focus-outline-width solid $button-focus-outline-color;
}
&:active {
color: $button-active-color;
border-color: $button-active-border-color;
background-color: $button-active-background-color;
outline: none;
}
&::-moz-focus-inner {
border: 0;
}
> i {
display: none;
}
@ -143,172 +166,258 @@ export default {
}
}
$active-shade-percent: 10%;
$color-primary-shade: lightness(
--color-primary-h,
--color-primary-s,
--color-primary-l,
-($active-shade-percent)
);
$loading-overlay-background-color: rgba(255, 255, 255, 0);
$color-success-shade: lightness(
--color-success-h,
--color-success-s,
--color-success-l,
-($active-shade-percent)
);
/**
* Colors
*/
$color-warning-shade: lightness(
--color-warning-h,
--color-warning-s,
--color-warning-l,
-($active-shade-percent)
);
$color-danger-shade: lightness(
--color-danger-h,
--color-danger-s,
--color-danger-l,
-($active-shade-percent)
);
.primary-primary {
composes: button;
}
.primary-success {
composes: button;
--button-background-color: var(--color-success);
--button-color: var(--color-text-xlight);
--button-border-color: var(--color-success);
--button-active-color: var(--color-text-xlight);
--button-active-border-color: #{$color-success-shade};
--button-active-background-color: #{$color-success-shade};
}
.primary-warning {
composes: button;
--button-background-color: var(--color-warning);
--button-color: var(--color-text-xlight);
--button-border-color: var(--color-warning);
--button-active-color: var(--color-text-xlight);
--button-active-border-color: #{$color-warning-shade};
--button-active-background-color: #{$color-warning-shade};
}
.primary-danger {
composes: button;
--button-background-color: var(--color-danger);
--button-color: var(--color-text-xlight);
--button-border-color: var(--color-danger);
--button-active-color: var(--color-text-xlight);
--button-active-border-color: #{$color-danger-shade};
--button-active-background-color: #{$color-danger-shade};
}
.outline {
--button-background-color: var(--color-foreground-xlight);
--button-disabled-background-color: var(--color-foreground-xlight);
--button-active-background-color: var(--color-foreground-xlight);
}
.outline-primary {
composes: button;
composes: outline;
.secondary {
--button-color: var(--color-primary);
--button-active-border-color: #{$color-primary-shade};
--button-active-color: #{$color-primary-shade};
}
.outline-success {
composes: button;
composes: outline;
--button-color: var(--color-success);
--button-border-color: var(--color-success);
--button-active-color: #{$color-success-shade};
--button-active-border-color: #{$color-success-shade};
}
.outline-warning {
composes: button;
composes: outline;
--button-color: var(--color-warning);
--button-border-color: var(--color-warning);
--button-active-color: #{$color-warning-shade};
--button-active-border-color: #{$color-warning-shade};
}
.outline-danger {
composes: button;
composes: outline;
--button-color: var(--color-danger);
--button-border-color: var(--color-danger);
--button-active-color: #{$color-danger-shade};
--button-active-border-color: #{$color-danger-shade};
}
.light-primary {
composes: button;
--button-color: var(--color-primary);
--button-border-color: var(--color-primary-tint-2);
--button-background-color: var(--color-primary-tint-2);
--button-active-background-color: var(--color-primary-tint-2);
--button-active-color: #{$color-primary-shade};
--button-active-border-color: #{$color-primary-shade};
}
.light-success {
composes: button;
--button-color: var(--color-success);
--button-border-color: var(--color-success-tint-1);
--button-background-color: var(--color-success-tint-1);
--button-active-background-color: var(--color-success-tint-1);
--button-active-color: #{$color-success-shade};
--button-active-border-color: #{$color-success-shade};
}
.light-warning {
composes: button;
--button-color: var(--color-warning);
--button-border-color: var(--color-warning-tint-2);
--button-background-color: var(--color-warning-tint-2);
--button-active-background-color: var(--color-warning-tint-2);
--button-active-color: #{$color-warning-shade};
--button-active-border-color: #{$color-warning-shade};
}
.light-danger {
composes: button;
--button-color: var(--color-danger);
--button-border-color: var(--color-danger-tint-1);
--button-background-color: var(--color-danger-tint-1);
--button-active-background-color: var(--color-danger-tint-1);
--button-active-color: #{$color-danger-shade};
--button-active-border-color: #{$color-danger-shade};
}
.text {
composes: button;
--button-color: var(--color-text-light);
--button-border-color: transparent;
--button-background-color: transparent;
--button-active-background-color: transparent;
--button-active-color: var(--color-primary);
--button-active-border-color: transparent;
}
.tertiary {
composes: button;
font-weight: var(--font-weight-regular) !important;
--button-color: var(--color-text-dark);
--button-border-color: var(--color-foreground-base);
--button-background-color: var(--color-background-base);
--button-border-color: var(--color-primary);
--button-background-color: var(--color-background-xlight);
--button-active-background-color: var(--color-primary-tint-2);
--button-active-color: var(--color-primary);
--button-active-border-color: var(--color-primary);
--button-disabled-border-color: var(--color-foreground-xdark);
--button-hover-background-color: var(--color-primary-tint-3);
--button-hover-color: var(--color-primary);
--button-hover-border-color: var(--color-primary);
--button-focus-outline-color: var(--color-primary-tint-1);
}
.tertiary {
font-weight: var(--font-weight-regular) !important;
--button-background-color: var(--color-background-xlight);
--button-color: var(--color-text-dark);
--button-border-color: var(--color-neutral-850);
--button-active-background-color: var(--color-primary-tint-2);
--button-active-color: var(--color-primary);
--button-active-border-color: var(--color-primary);
--button-hover-background-color: var(--color-neutral-950);
--button-hover-color: var(--color-text-dark);
--button-hover-border-color: var(--color-neutral-800);
--button-focus-outline-color: hsla(var(--color-neutral-h), var(--color-neutral-s), var(--color-neutral-l), 0.2);
}
.success {
--button-background-color: var(--color-success);
--button-color: var(--color-text-xlight);
--button-border-color: var(--color-success);
--button-active-background-color: var(--color-success-350);
--button-active-border-color: var(--color-success-350);
--button-hover-background-color: var(--color-success-450);
--button-hover-border-color: var(--color-success-450);
--button-focus-outline-color: hsla(var(--color-success-h), var(--color-success-s), var(--color-success-l), 0.33);
}
.warning {
--button-background-color: var(--color-warning);
--button-color: var(--color-text-xlight);
--button-border-color: var(--color-warning);
--button-active-background-color: var(--color-warning-500);
--button-active-border-color: var(--color-warning-500);
--button-hover-background-color: var(--color-warning-650);
--button-hover-border-color: var(--color-warning-650);
--button-focus-outline-color: hsla(var(--color-warning-h), var(--color-warning-s), var(--color-warning-l), 0.33);
}
.danger {
--button-background-color: var(--color-danger);
--button-color: var(--color-text-xlight);
--button-border-color: var(--color-danger);
--button-active-color: var(--color-text-xlight);
--button-active-background-color: var(--color-danger-600);
--button-active-border-color: var(--color-danger-600);
--button-hover-background-color: var(--color-danger-700);
--button-hover-border-color: var(--color-danger-700);
--button-focus-outline-color: hsla(var(--color-danger-h), var(--color-danger-s), var(--color-danger-l), 0.33);
}
/**
* Sizes
*/
.mini {
--button-padding-vertical: var(--spacing-4xs);
--button-padding-horizontal: var(--spacing-2xs);
--button-font-size: var(--font-size-2xs);
&.icon-button {
height: 22px;
width: 22px;
}
}
.small {
--button-padding-vertical: var(--spacing-3xs);
--button-padding-horizontal: var(--spacing-xs);
--button-font-size: var(--font-size-2xs);
&.icon-button {
height: 26px;
width: 26px;
}
}
.medium {
--button-padding-vertical: var(--spacing-2xs);
--button-padding-horizontal: var(--spacing-xs);
--button-font-size: var(--font-size-2xs);
&.icon-button {
height: 32px;
width: 32px;
}
}
.large {
&.icon-button {
height: 42px;
width: 42px;
}
}
.xlarge {
--button-padding-vertical: var(--spacing-xs);
--button-padding-horizontal: var(--spacing-s);
--button-font-size: var(--font-size-m);
&.icon-button {
height: 46px;
width: 46px;
}
}
/**
* Modifiers
*/
.outline {
--button-color: var(--color-primary);
--button-background-color: transparent;
--button-disabled-background-color: transparent;
--button-active-background-color: transparent;
&.primary {
--button-color: var(--color-primary);
--button-border-color: var(--color-primary);
--button-active-background-color: var(--color-primary);
}
&.tertiary {
--button-color: var(--color-text-dark);
}
&.success {
--button-color: var(--color-success);
--button-border-color: var(--color-success);
--button-active-background-color: var(--color-success);
}
&.warning {
--button-color: var(--color-warning);
--button-border-color: var(--color-warning);
--button-active-background-color: var(--color-warning);
}
&.danger {
--button-color: var(--color-danger);
--button-border-color: var(--color-danger);
--button-active-background-color: var(--color-danger);
}
}
.text {
--button-color: var(--color-text-light);
--button-border-color: transparent;
--button-background-color: transparent;
--button-active-color: var(--color-text-light);
--button-active-background-color: transparent;
--button-active-border-color: transparent;
--button-hover-color: var(--color-text-light);
--button-hover-background-color: transparent;
--button-hover-border-color: transparent;
&.primary {
--button-color: var(--color-primary);
--button-active-color: var(--color-primary);
--button-hover-color: var(--color-primary);
}
&.secondary {
--button-color: var(--color-primary-tint-1);
--button-active-color: var(--color-primary-tint-1);
--button-hover-color: var(--color-primary-tint-1);
}
&.success {
--button-color: var(--color-success);
--button-active-color: var(--color-success);
--button-hover-color: var(--color-success);
}
&.warning {
--button-color: var(--color-warning);
--button-active-color: var(--color-warning);
--button-hover-color: var(--color-warning);
}
&.danger {
--button-color: var(--color-danger);
--button-active-color: var(--color-danger);
--button-hover-color: var(--color-danger);
}
&:hover {
text-decoration: underline;
}
}
.loading,
.active {
position: relative;
pointer-events: none;
&:before {
pointer-events: none;
content: '';
position: absolute;
left: -1px;
top: -1px;
right: -1px;
bottom: -1px;
border-radius: inherit;
background-color: $loading-overlay-background-color;
}
}
.disabled {
&,
&:hover,
&:active,
&:focus {
cursor: not-allowed;
background-image: none;
color: $button-disabled-font-color;
background-color: $button-disabled-background-color;
border-color: $button-disabled-border-color;
}
}
.transparent {
@ -323,4 +432,16 @@ $color-danger-shade: lightness(
display: block;
}
}
.block {
width: 100%;
}
.float-left {
float: left;
}
.float-right {
float: right;
}
</style>

View file

@ -0,0 +1,63 @@
import {render} from '@testing-library/vue';
import N8nButton from "../Button.vue";
import ElButton from "../overrides/ElButton.vue";
const slots = {
default: 'Button',
};
const stubs = ['n8n-spinner', 'n8n-icon'];
describe('components', () => {
describe('N8nButton', () => {
it('should render correctly', () => {
const wrapper = render(N8nButton, {
slots,
stubs,
});
expect(wrapper.html()).toMatchSnapshot();
});
describe('props', () => {
describe('loading', () => {
it('should render loading spinner', () => {
const wrapper = render(N8nButton, {
props: {
loading: true,
},
slots,
stubs,
});
expect(wrapper.html()).toMatchSnapshot();
});
});
describe('icon', () => {
it('should render icon button', () => {
const wrapper = render(N8nButton, {
props: {
icon: 'plus-circle',
},
slots,
stubs,
});
expect(wrapper.html()).toMatchSnapshot();
});
});
});
describe('overrides', () => {
it('should render correctly', () => {
const wrapper = render(ElButton, {
props: {
icon: 'plus-circle',
type: 'secondary',
},
slots,
stubs,
});
expect(wrapper.html()).toMatchSnapshot();
});
});
});
});

View file

@ -0,0 +1,12 @@
// Vitest Snapshot v1
exports[`components > N8nButton > overrides > should render correctly 1`] = `"<button aria-disabled=\\"false\\" aria-busy=\\"false\\" aria-live=\\"polite\\" class=\\"button _button_cccce_115 _secondary_cccce_170 _medium_cccce_254 _icon_cccce_239\\" icon=\\"plus-circle\\" type=\\"secondary\\"><span class=\\"_icon_cccce_239\\"><n8n-icon-stub icon=\\"plus-circle\\" size=\\"medium\\"></n8n-icon-stub></span><span>Button</span></button>"`;
exports[`components > N8nButton > props > icon > should render icon button 1`] = `"<button aria-disabled=\\"false\\" aria-busy=\\"false\\" aria-live=\\"polite\\" class=\\"button _button_cccce_115 _primary_cccce_288 _medium_cccce_254 _icon_cccce_239\\"><span class=\\"_icon_cccce_239\\"><n8n-icon-stub icon=\\"plus-circle\\" size=\\"medium\\"></n8n-icon-stub></span><span>Button</span></button>"`;
exports[`components > N8nButton > props > loading > should render loading spinner 1`] = `"<button disabled=\\"disabled\\" aria-disabled=\\"false\\" aria-busy=\\"true\\" aria-live=\\"polite\\" class=\\"button _button_cccce_115 _primary_cccce_288 _medium_cccce_254 _loading_cccce_352 _icon_cccce_239\\"><span class=\\"_icon_cccce_239\\"><n8n-spinner-stub size=\\"medium\\" type=\\"dots\\"></n8n-spinner-stub></span><span>Button</span></button>"`;
exports[`components > N8nButton > should render correctly 1`] = `
"<button aria-disabled=\\"false\\" aria-busy=\\"false\\" aria-live=\\"polite\\" class=\\"button _button_cccce_115 _primary_cccce_288 _medium_cccce_254\\">
<!----><span>Button</span></button>"
`;

View file

@ -1,43 +0,0 @@
import { N8nComponent, N8nComponentSize } from '../component';
/** Button type */
export type ButtonType = 'primary' | 'outline' | 'light' | 'text';
/** Button themes */
export type ButtonTheme = 'success' | 'warning' | 'danger';
/** Button Component */
export declare class N8nButton extends N8nComponent {
/** Button text */
label: string;
/** Button title on hover */
title: string;
/** Color scheme */
theme: ButtonTheme;
/** Button size */
size: N8nComponentSize;
/** Button type */
type: ButtonType;
/** Determine whether it's a circular button */
circle: boolean;
/** Determine whether it's loading */
loading: boolean;
/** Disable the button */
disabled: boolean;
/** Button icon, accepts an icon name of font awesome icon component */
icon: string;
/** Full width */
fullWidth: boolean;
/** Float left or right */
float: boolean;
}

View file

@ -0,0 +1,39 @@
<template>
<n8n-button
ref="button"
v-bind="attrs"
v-on="$listeners"
>
<slot/>
</n8n-button>
</template>
<script lang="ts">
import Vue from 'vue';
import N8nButton from '../Button.vue';
const classToTypeMap = {
'btn--cancel': 'secondary',
};
export default Vue.extend({
components: {
N8nButton,
},
computed: {
attrs() {
let type = 'primary';
Object.entries(classToTypeMap).forEach(([className, mappedType]) => {
if (this.$refs.button && (this.$refs.button as Vue).$el.classList.contains(className)) {
type = mappedType;
}
});
return {
type,
...this.$attrs,
};
},
},
});
</script>

View file

@ -0,0 +1 @@
export { default as N8nElButton } from './ElButton.vue';

View file

@ -0,0 +1,107 @@
import N8nCallout from './Callout.vue';
import { StoryFn } from '@storybook/vue';
import N8nLink from '../N8nLink';
import N8nText from '../N8nText';
export default {
title: 'Atoms/Callout',
component: N8nCallout,
argTypes: {
theme: {
control: {
type: 'select',
options: ['info', 'secondary', 'success', 'warning', 'danger', 'custom'],
},
},
message: {
control: {
type: 'text',
},
},
icon: {
control: {
type: 'text',
},
},
},
parameters: {
design: {
type: 'figma',
url: 'https://www.figma.com/file/tPpJvbrnHbP8C496cYuwyW/Node-pinning?node-id=15%3A5777',
},
},
};
const template: StoryFn = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: {
N8nLink,
N8nText,
N8nCallout,
},
template: `
<n8n-callout v-bind="$props">
${args.default}
<template #actions v-if="actions">
${args.actions}
</template>
<template #trailingContent v-if="trailingContent">
${args.trailingContent}
</template>
</n8n-callout>
`,
});
export const customCallout = template.bind({});
customCallout.args = {
theme: 'custom',
icon: 'code-branch',
default: `
<n8n-text
size="small"
>
This is a callout.
</n8n-text>
`,
actions: `
<n8n-link
size="small"
>
Do something!
</n8n-link>
`,
};
export const secondaryCallout = template.bind({});
secondaryCallout.args = {
theme: 'secondary',
icon: 'thumbtack',
default: `
<n8n-text
size="small"
:bold="true"
>
This data is pinned.
</n8n-text>
`,
actions: `
<n8n-link
theme="secondary"
size="small"
:bold="true"
>
Unpin
</n8n-link>
`,
trailingContent: `
<n8n-link
theme="secondary"
size="small"
:bold="true"
:underline="true"
to="https://n8n.io"
>
Learn more
</n8n-link>
`,
};

View file

@ -1,5 +1,7 @@
/* tslint:disable:variable-name */
import N8nIconButton from './IconButton.vue';
import { action } from '@storybook/addon-actions';
import { StoryFn } from "@storybook/vue";
export default {
title: 'Atoms/Icon Button',
@ -7,31 +9,12 @@ export default {
argTypes: {
type: {
control: 'select',
options: ['primary', 'outline', 'light', 'text'],
},
title: {
control: 'text',
options: ['primary', 'secondary', 'tertiary', 'success', 'warning', 'danger'],
},
size: {
control: {
type: 'select',
options: ['small', 'medium', 'large', 'xlarge'],
},
},
loading: {
control: {
type: 'boolean',
},
},
icon: {
control: {
type: 'text',
},
},
theme: {
control: {
type: 'select',
options: ['success', 'warning', 'danger'],
options: ['mini', 'small', 'medium', 'large', 'xlarge'],
},
},
},
@ -44,7 +27,7 @@ const methods = {
onClick: action('click'),
};
const Template = (args, { argTypes }) => ({
const Template: StoryFn = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: {
N8nIconButton,
@ -59,7 +42,7 @@ Button.args = {
title: 'my title',
};
const ManyTemplate = (args, { argTypes }) => ({
const ManyTemplate: StoryFn = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: {
N8nIconButton,
@ -80,7 +63,8 @@ export const Outline = ManyTemplate.bind({});
Outline.args = {
icon: 'plus',
title: 'my title',
type: 'outline',
type: 'primary',
outline: true,
};
export const Light = ManyTemplate.bind({});

View file

@ -1,14 +1,8 @@
<template functional>
<component :is="$options.components.N8nButton"
:type="props.type"
:disabled="props.disabled"
:size="props.size"
:loading="props.loading"
:title="props.title"
:icon="props.icon"
:theme="props.theme"
@click="(e) => listeners.click && listeners.click(e)"
circle
<template>
<n8n-button
:class="`icon-button ${$style['icon-button']} ${$style[size]}`"
v-bind="$props"
v-on="$listeners"
/>
</template>
@ -23,9 +17,7 @@ export default {
props: {
type: {
type: String,
},
title: {
type: String,
default: 'primary',
},
size: {
type: String,
@ -35,16 +27,73 @@ export default {
type: Boolean,
default: false,
},
outline: {
type: Boolean,
default: false,
},
text: {
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
active: {
type: Boolean,
default: false,
},
icon: {
type: [String, Array],
required: true,
},
theme: {
float: {
type: String,
validator: (value: string): boolean =>
['left', 'right'].includes(value),
},
circle: {
type: Boolean,
default: true,
},
circle: {
type: Boolean,
default: true,
},
},
};
</script>
<style lang="scss" module>
.icon-button {
padding: 0;
display: inline-flex;
justify-content: center;
align-items: center;
}
.mini {
height: 22px;
width: 22px;
}
.small {
height: 26px;
width: 26px;
}
.medium {
height: 30px;
width: 30px;
}
.large {
height: 42px;
width: 42px;
}
.xlarge {
height: 46px;
width: 46px;
}
</style>

View file

@ -1,6 +1,6 @@
<template functional>
<component :is="$options.components.N8nRoute" :to="props.to" :newWindow="props.newWindow"
@click="listeners.click"
@click="listeners.click"
>
<span
:class="$style[`${props.underline ? `${props.theme}-underline` : props.theme}`]"
@ -42,7 +42,7 @@ export default {
type: String,
default: 'primary',
validator: (value: string): boolean =>
['primary', 'danger', 'text'].includes(value),
['primary', 'danger', 'text', 'secondary'].includes(value),
},
},
components: {
@ -60,10 +60,10 @@ export default {
&:active {
color: saturation(
--color-primary-h,
--color-primary-s,
--color-primary-l,
-(30%)
--color-primary-h,
--color-primary-s,
--color-primary-l,
-(30%)
);
}
}
@ -72,12 +72,7 @@ export default {
color: var(--color-text-base);
&:active {
color: saturation(
--color-primary-h,
--color-primary-s,
--color-primary-l,
-(30%)
);
color: saturation(--color-primary-h, --color-primary-s, --color-primary-l, -(30%));
}
}
@ -85,15 +80,23 @@ export default {
color: var(--color-danger);
&:active {
color: saturation(
--color-danger-h,
--color-danger-s,
--color-danger-l,
-(20%)
);
color: saturation(--color-danger-h, --color-danger-s, --color-danger-l, -(20%));
}
}
.secondary {
color: var(--color-secondary);
&:active {
color: saturation(--color-secondary-h, --color-secondary-s, --color-secondary-l, -(20%));
}
}
.secondary {
background-color: var(--color-secondary-tint-2);
color: var(--color-secondary);
}
.primary-underline {
composes: primary;
text-decoration: underline;
@ -104,5 +107,9 @@ export default {
text-decoration: underline;
}
.secondary-underline {
composes: secondary;
text-decoration: underline;
}
</style>

View file

@ -38,8 +38,8 @@ export default Vue.extend({
default: 'warning',
},
content: {
required: true,
type: String,
default: '',
},
fullContent: {
type: String,

View file

@ -1,4 +1,4 @@
import {fireEvent, render} from '@testing-library/vue';
import { render } from '@testing-library/vue';
import N8nNotice from "../Notice.vue";
describe('components', () => {

View file

@ -0,0 +1,107 @@
import N8nPanelCallout from './PanelCallout.vue';
import { StoryFn } from '@storybook/vue';
import N8nLink from '../N8nLink';
import N8nText from '../N8nText';
export default {
title: 'Atoms/Callout',
component: N8nPanelCallout,
argTypes: {
theme: {
control: {
type: 'select',
options: ['info', 'secondary', 'success', 'warning', 'danger', 'custom'],
},
},
message: {
control: {
type: 'text',
},
},
icon: {
control: {
type: 'text',
},
},
},
parameters: {
design: {
type: 'figma',
url: 'https://www.figma.com/file/tPpJvbrnHbP8C496cYuwyW/Node-pinning?node-id=15%3A5777',
},
},
};
const template: StoryFn = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: {
N8nLink,
N8nText,
N8nPanelCallout,
},
template: `
<n8n-panel-callout v-bind="$props">
${args.default}
<template #actions v-if="actions">
${args.actions}
</template>
<template #trailingContent v-if="trailingContent">
${args.trailingContent}
</template>
</n8n-panel-callout>
`,
});
export const customCallout = template.bind({});
customCallout.args = {
theme: 'custom',
icon: 'code-branch',
default: `
<n8n-text
size="small"
>
This is a callout.
</n8n-text>
`,
actions: `
<n8n-link
size="small"
>
Do something!
</n8n-link>
`,
};
export const secondaryCallout = template.bind({});
secondaryCallout.args = {
theme: 'secondary',
icon: 'thumbtack',
default: `
<n8n-text
size="small"
:bold="true"
>
This data is pinned.
</n8n-text>
`,
actions: `
<n8n-link
theme="secondary"
size="small"
:bold="true"
>
Unpin
</n8n-link>
`,
trailingContent: `
<n8n-link
theme="secondary"
size="small"
:bold="true"
:underline="true"
to="https://n8n.io"
>
Learn more
</n8n-link>
`,
};

View file

@ -0,0 +1,115 @@
<template>
<div :class="classes" role="alert">
<div :class="$style['message-section']">
<div :class="$style.icon">
<n8n-icon
:icon="getIcon"
:size="theme === 'secondary' ? 'medium' : 'large'"
/>
</div>
<slot />
</div>
<slot name="trailingContent" />
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import N8nIcon from '../N8nIcon';
const CALLOUT_DEFAULT_ICONS = {
info: 'info-circle',
success: 'check-circle',
warning: 'exclamation-triangle',
danger: 'times-circle',
};
export default Vue.extend({
name: 'n8n-panel-callout',
components: {
N8nIcon,
},
props: {
theme: {
type: String,
required: true,
validator: (value: string): boolean =>
['info', 'success', 'secondary', 'warning', 'danger', 'custom'].includes(value),
},
icon: {
type: String,
default: 'info-circle'
},
},
computed: {
classes(): string[] {
return [
this.$style['callout'],
this.$style[this.theme],
];
},
getIcon(): string {
if (Object.keys(CALLOUT_DEFAULT_ICONS).includes(this.theme)) {
return CALLOUT_DEFAULT_ICONS[this.theme];
}
return this.icon;
},
}
});
</script>
<style lang="scss" module>
.callout {
display: flex;
justify-content: space-between;
font-size: var(--font-size-2xs);
padding: var(--spacing-xs);
border: var(--border-width-base) var(--border-style-base);
border-radius: var(--border-radius-base);
align-items: center;
line-height: var(--font-line-height-loose);
}
.message-section {
display: flex;
}
.info, .custom {
border-color: var(--color-foreground-base);
background-color: var(--color-background-light);
color: var(--color-info);
}
.warning {
border-color: var(--color-warning-tint-1);
background-color: var(--color-warning-tint-2);
color: var(--color-warning);
}
.success {
border-color: var(--color-success-tint-1);
background-color: var(--color-success-tint-2);
color: var(--color-success);
}
.danger {
border-color: var(--color-danger-tint-1);
background-color: var(--color-danger-tint-2);
color: var(--color-danger);
}
.icon {
margin-right: var(--spacing-xs);
}
.secondary {
font-size: var(--font-size-2xs);
font-weight: var(--font-weight-bold);
color: var(--color-secondary);
background-color: var(--color-secondary-tint-2);
border-color: var(--color-secondary-tint-1);
}
</style>

View file

@ -0,0 +1,3 @@
import N8nPanelCallout from './PanelCallout.vue';
export default N8nPanelCallout;

View file

@ -39,7 +39,9 @@ import N8nActionToggle from './N8nActionToggle';
import N8nAvatar from './N8nAvatar';
import N8nBadge from './N8nBadge';
import N8nButton from './N8nButton';
import { N8nElButton } from './N8nButton/overrides';
import N8nCallout from './N8nCallout';
import N8nPanelCallout from './N8nPanelCallout';
import N8nCard from './N8nCard';
import N8nFormBox from './N8nFormBox';
import N8nFormInput from './N8nFormInput';
@ -81,7 +83,9 @@ export {
N8nAvatar,
N8nBadge,
N8nButton,
N8nElButton,
N8nCallout,
N8nPanelCallout,
N8nCard,
N8nHeading,
N8nFormBox,

View file

@ -91,6 +91,19 @@ import ColorCircles from './ColorCircles.vue';
</Story>
</Canvas>
## Neutral
<Canvas>
<Story name="neutral">
{{
template: `<color-circles :colors="['--color-neutral']" />`,
components: {
ColorCircles,
},
}}
</Story>
</Canvas>
## Text
<Canvas>

View file

@ -0,0 +1,29 @@
import { Meta, Story, Canvas } from '@storybook/addon-docs';
<Meta
title="Utilities/Float"
/>
# `.float-left`
<Canvas>
<Story name="float-left">
{{
template: `<div>
<span class="float-left">Float left</span>
</div>`,
}}
</Story>
</Canvas>
# `.float-right`
<Canvas>
<Story name="float-right">
{{
template: `<div>
<span class="float-right">Float right</span>
</div>`,
}}
</Story>
</Canvas>

View file

@ -1,260 +1,298 @@
@use 'sass:math';
@mixin theme {
--color-primary-h: 7;
--color-primary-s: 100%;
--color-primary-l: 68%;
--color-primary: hsl(
var(--color-primary-h),
var(--color-primary-s),
var(--color-primary-l)
);
--color-primary-h: 7;
--color-primary-s: 100%;
--color-primary-l: 68%;
--color-primary: hsl(
var(--color-primary-h),
var(--color-primary-s),
var(--color-primary-l)
);
--color-primary-tint-1-l: 18%;
--color-primary-tint-1: hsl(
var(--color-primary-h),
var(--color-primary-s),
var(--color-primary-tint-1-l)
);
--color-primary-tint-1-l: 18%;
--color-primary-tint-1: hsl(
var(--color-primary-h),
var(--color-primary-s),
var(--color-primary-tint-1-l)
);
--color-primary-tint-2-l: 9%;
--color-primary-tint-2: hsl(
var(--color-primary-h),
var(--color-primary-s),
var(--color-primary-tint-2-l)
);
--color-primary-tint-2-l: 9%;
--color-primary-tint-2: hsl(
var(--color-primary-h),
var(--color-primary-s),
var(--color-primary-tint-2-l)
);
--color-primary-tint-3-l: 3%;
--color-primary-tint-3: hsl(
var(--color-primary-h),
var(--color-primary-s),
var(--color-primary-tint-3-l)
);
--color-primary-tint-3-l: 3%;
--color-primary-tint-3: hsl(
var(--color-primary-h),
var(--color-primary-s),
var(--color-primary-tint-3-l)
);
--color-primary-shade-1-l: 89%;
--color-primary-shade-1: hsl(
var(--color-primary-h),
var(--color-primary-s),
var(--color-primary-shade-1-l)
);
--color-primary-shade-1-l: 89%;
--color-primary-shade-1: hsl(
var(--color-primary-h),
var(--color-primary-s),
var(--color-primary-shade-1-l)
);
--color-secondary-h: 247;
--color-secondary-s: 100%;
--color-secondary-l: 35%;
--color-secondary: hsl(
var(--color-secondary-h),
var(--color-secondary-s),
var(--color-secondary-l)
);
--color-secondary-h: 247;
--color-secondary-s: 100%;
--color-secondary-l: 35%;
--color-secondary: hsl(
var(--color-secondary-h),
var(--color-secondary-s),
var(--color-secondary-l)
);
--color-success-h: 150;
--color-success-s: 74%;
--color-success-l: 60%;
--color-success: hsl(
var(--color-success-h),
var(--color-success-s),
var(--color-success-l)
);
--color-success-h: 150;
--color-success-s: 74%;
--color-success-l: 60%;
--color-success: hsl(
var(--color-success-h),
var(--color-success-s),
var(--color-success-l)
);
--color-success-tint-1-l: 12%;
--color-success-tint-1: hsl(
var(--color-success-h),
var(--color-success-s),
var(--color-success-tint-1-l)
);
--color-success-tint-1-l: 12%;
--color-success-tint-1: hsl(
var(--color-success-h),
var(--color-success-s),
var(--color-success-tint-1-l)
);
--color-success-tint-2-l: 3%;
--color-success-tint-2: hsl(
var(--color-success-h),
var(--color-success-s),
var(--color-success-tint-2-l)
);
--color-success-tint-2-l: 3%;
--color-success-tint-2: hsl(
var(--color-success-h),
var(--color-success-s),
var(--color-success-tint-2-l)
);
--color-warning-h: 36;
--color-warning-s: 77%;
--color-warning-l: 43%;
--color-warning: hsl(
var(--color-warning-h),
var(--color-warning-s),
var(--color-warning-l)
);
--color-warning-h: 36;
--color-warning-s: 77%;
--color-warning-l: 43%;
--color-warning: hsl(
var(--color-warning-h),
var(--color-warning-s),
var(--color-warning-l)
);
--color-warning-tint-1-l: 12%;
--color-warning-tint-1: hsl(
var(--color-warning-h),
var(--color-warning-s),
var(--color-warning-tint-1-l)
);
--color-warning-tint-1-l: 12%;
--color-warning-tint-1: hsl(
var(--color-warning-h),
var(--color-warning-s),
var(--color-warning-tint-1-l)
);
--color-warning-tint-2-l: 4%;
--color-warning-tint-2: hsl(
var(--color-warning-h),
var(--color-warning-s),
var(--color-warning-tint-2-l)
);
--color-warning-tint-2-l: 4%;
--color-warning-tint-2: hsl(
var(--color-warning-h),
var(--color-warning-s),
var(--color-warning-tint-2-l)
);
--color-danger-h: 0;
--color-danger-s: 88%;
--color-danger-l: 35%;
--color-danger: hsl(
var(--color-danger-h),
var(--color-danger-s),
var(--color-danger-l)
);
--color-danger-h: 0;
--color-danger-s: 88%;
--color-danger-l: 35%;
--color-danger: hsl(
var(--color-danger-h),
var(--color-danger-s),
var(--color-danger-l)
);
--color-danger-tint-1-l: 8%;
--color-danger-tint-1: hsl(
var(--color-danger-h),
var(--color-danger-s),
var(--color-danger-tint-1-l)
);
--color-danger-tint-1-l: 8%;
--color-danger-tint-1: hsl(
var(--color-danger-h),
var(--color-danger-s),
var(--color-danger-tint-1-l)
);
--color-danger-tint-2-l: 3%;
--color-danger-tint-2: hsl(
var(--color-danger-h),
var(--color-danger-s),
var(--color-danger-tint-2-l)
);
--color-danger-tint-2-l: 3%;
--color-danger-tint-2: hsl(
var(--color-danger-h),
var(--color-danger-s),
var(--color-danger-tint-2-l)
);
--color-info-h: 220;
--color-info-s: 4%;
--color-info-l: 42%;
--color-info: hsl(
var(--color-info-h),
var(--color-info-s),
var(--color-info-l)
);
--color-info-h: 220;
--color-info-s: 4%;
--color-info-l: 42%;
--color-info: hsl(
var(--color-info-h),
var(--color-info-s),
var(--color-info-l)
);
--color-info-tint-1-l: 12%;
--color-info-tint-1: hsl(
var(--color-info-h),
var(--color-info-s),
var(--color-info-tint-1-l)
);
--color-info-tint-1-l: 12%;
--color-info-tint-1: hsl(
var(--color-info-h),
var(--color-info-s),
var(--color-info-tint-1-l)
);
--color-info-tint-2-l: 4%;
--color-info-tint-2: hsl(
var(--color-info-h),
var(--color-info-s),
var(--color-info-tint-2-l)
);
--color-info-tint-2-l: 4%;
--color-info-tint-2: hsl(
var(--color-info-h),
var(--color-info-s),
var(--color-info-tint-2-l)
);
--color-text-dark-h: 0;
--color-text-dark-s: 0%;
--color-text-dark-l: 100%;
--color-text-dark: hsl(
var(--color-text-dark-h),
var(--color-text-dark-s),
var(--color-text-dark-l)
);
--color-grey-h: 228;
--color-grey-s: 10%;
--color-grey-l: 80%;
--color-grey: hsl(
var(--color-grey-h),
var(--color-grey-s),
var(--color-grey-l)
);
--color-text-base-h: 240;
--color-text-base-s: 4%;
--color-text-base-l: 49%;
--color-text-base: hsl(
var(--color-text-base-h),
var(--color-text-base-s),
var(--color-text-base-l)
);
--color-light-grey-h: 220;
--color-light-grey-s: 20%;
--color-light-grey-l: 88%;
--color-light-grey: hsl(
var(--color-light-grey-h),
var(--color-light-grey-s),
var(--color-light-grey-l)
);
--color-text-light-h: 220;
--color-text-light-s: 4%;
--color-text-light-l: 42%;
--color-text-light: hsl(
var(--color-text-light-h),
var(--color-text-light-s),
var(--color-text-light-l)
);
--color-neutral-h: 228;
--color-neutral-s: 10%;
--color-neutral-l: 50%;
--color-neutral: hsl(
var(--color-neutral-h),
var(--color-neutral-s),
var(--color-neutral-l)
);
--color-text-lighter-h: 222;
--color-text-lighter-s: 17%;
--color-text-lighter-l: 12%;
--color-text-lighter: hsl(
var(--color-text-lighter-h),
var(--color-text-lighter-s),
var(--color-text-lighter-l)
);
--color-text-dark-h: 0;
--color-text-dark-s: 0%;
--color-text-dark-l: 100%;
--color-text-dark: hsl(
var(--color-text-dark-h),
var(--color-text-dark-s),
var(--color-text-dark-l)
);
--color-text-xlight-h: 0;
--color-text-xlight-s: 0%;
--color-text-xlight-l: 100%;
--color-text-xlight: hsl(
var(--color-text-xlight-h),
var(--color-text-xlight-s),
var(--color-text-xlight-l)
);
--color-text-base-h: 240;
--color-text-base-s: 4%;
--color-text-base-l: 49%;
--color-text-base: hsl(
var(--color-text-base-h),
var(--color-text-base-s),
var(--color-text-base-l)
);
--color-foreground-base-h: 220;
--color-foreground-base-s: 20%;
--color-foreground-base-l: 12%;
--color-foreground-base: hsl(
var(--color-foreground-base-h),
var(--color-foreground-base-s),
var(--color-foreground-base-l)
);
--color-text-light-h: 220;
--color-text-light-s: 4%;
--color-text-light-l: 42%;
--color-text-light: hsl(
var(--color-text-light-h),
var(--color-text-light-s),
var(--color-text-light-l)
);
--color-foreground-light-h: 0;
--color-foreground-light-s: 0%;
--color-foreground-light-l: 7%;
--color-foreground-light: hsl(
var(--color-foreground-light-h),
var(--color-foreground-light-s),
var(--color-foreground-light-l)
);
--color-text-lighter-h: 222;
--color-text-lighter-s: 17%;
--color-text-lighter-l: 12%;
--color-text-lighter: hsl(
var(--color-text-lighter-h),
var(--color-text-lighter-s),
var(--color-text-lighter-l)
);
--color-foreground-xlight-h: 0;
--color-foreground-xlight-s: 0%;
--color-foreground-xlight-l: 0%;
--color-foreground-xlight: hsl(
var(--color-foreground-xlight-h),
var(--color-foreground-xlight-s),
var(--color-foreground-xlight-l)
);
--color-text-xlight-h: 0;
--color-text-xlight-s: 0%;
--color-text-xlight-l: 100%;
--color-text-xlight: hsl(
var(--color-text-xlight-h),
var(--color-text-xlight-s),
var(--color-text-xlight-l)
);
--color-background-dark-h: 0;
--color-background-dark-s: 0%;
--color-background-dark-l: 100%;
--color-background-dark: hsl(
var(--color-background-dark-h),
var(--color-background-dark-s),
var(--color-background-dark-l)
);
--color-foreground-base-h: 220;
--color-foreground-base-s: 20%;
--color-foreground-base-l: 12%;
--color-foreground-base: hsl(
var(--color-foreground-base-h),
var(--color-foreground-base-s),
var(--color-foreground-base-l)
);
--color-background-base-h: 252;
--color-background-base-s: 71%;
--color-background-base-l: 99%;
--color-background-base: hsl(
var(--color-background-base-h),
var(--color-background-base-s),
var(--color-background-base-l)
);
--color-foreground-light-h: 0;
--color-foreground-light-s: 0%;
--color-foreground-light-l: 7%;
--color-foreground-light: hsl(
var(--color-foreground-light-h),
var(--color-foreground-light-s),
var(--color-foreground-light-l)
);
--color-background-light-h: 220;
--color-background-light-s: 27%;
--color-background-light-l: 98%;
--color-background-light: hsl(
var(--color-background-light-h),
var(--color-background-light-s),
var(--color-background-light-l)
);
--color-foreground-xlight-h: 0;
--color-foreground-xlight-s: 0%;
--color-foreground-xlight-l: 0%;
--color-foreground-xlight: hsl(
var(--color-foreground-xlight-h),
var(--color-foreground-xlight-s),
var(--color-foreground-xlight-l)
);
--color-background-lighter-h: 220;
--color-background-lighter-s: 30%;
--color-background-lighter-l: 96%;
--color-background-lighter: hsl(
var(--color-background-lighter-h),
var(--color-background-lighter-s),
var(--color-background-lighter-l)
);
--color-background-dark-h: 0;
--color-background-dark-s: 0%;
--color-background-dark-l: 100%;
--color-background-dark: hsl(
var(--color-background-dark-h),
var(--color-background-dark-s),
var(--color-background-dark-l)
);
--color-background-xlight-h: 240;
--color-background-xlight-s: 4%;
--color-background-xlight-l: 19%;
--color-background-xlight: hsl(
var(--color-background-xlight-h),
var(--color-background-xlight-s),
var(--color-background-xlight-l)
);
--color-background-base-h: 252;
--color-background-base-s: 71%;
--color-background-base-l: 99%;
--color-background-base: hsl(
var(--color-background-base-h),
var(--color-background-base-s),
var(--color-background-base-l)
);
--color-background-light-h: 220;
--color-background-light-s: 27%;
--color-background-light-l: 98%;
--color-background-light: hsl(
var(--color-background-light-h),
var(--color-background-light-s),
var(--color-background-light-l)
);
--color-background-lighter-h: 220;
--color-background-lighter-s: 30%;
--color-background-lighter-l: 96%;
--color-background-lighter: hsl(
var(--color-background-lighter-h),
var(--color-background-lighter-s),
var(--color-background-lighter-l)
);
--color-background-xlight-h: 240;
--color-background-xlight-s: 4%;
--color-background-xlight-l: 19%;
--color-background-xlight: hsl(
var(--color-background-xlight-h),
var(--color-background-xlight-s),
var(--color-background-xlight-l)
);
// Generated Color Shades from 50 to 950
// Not yet used in design system
@each $color in ('neutral', 'success', 'warning', 'danger') {
@each $shade in (50, 100, 150, 200, 250, 300, 350, 400, 450, 500, 550, 600, 650, 700, 750, 800, 850, 900, 950) {
--color-#{$color}-#{$shade}-l: #{math.div($shade, 10)}#{'%'};
--color-#{$color}-#{$shade}: hsl(var(--color-#{$color}-h), var(--color-#{$color}-s), var(--color-#{$color}-#{$shade}-l));
}
}
}
body.theme-dark {
@include theme;
@include theme;
}

View file

@ -1,3 +1,5 @@
@use 'sass:math';
@mixin theme {
--color-primary-h: 6.9;
--color-primary-s: 100%;
@ -86,6 +88,14 @@
var(--color-secondary-tint-2-l)
);
--color-secondary-tint-3-h: 247;
--color-secondary-tint-3-s: 49%;
--color-secondary-tint-3-l: 95%;
--color-secondary-tint-3: hsl(
var(--color-secondary-tint-3-h),
var(--color-secondary-tint-3-s),
var(--color-secondary-tint-3-l)
);
--color-success-h: 150.4;
--color-success-s: 60%;
@ -190,6 +200,33 @@
var(--color-info-tint-2-l)
);
--color-grey-h: 228;
--color-grey-s: 10%;
--color-grey-l: 80%;
--color-grey: hsl(
var(--color-grey-h),
var(--color-grey-s),
var(--color-grey-l)
);
--color-light-grey-h: 220;
--color-light-grey-s: 20%;
--color-light-grey-l: 88%;
--color-light-grey: hsl(
var(--color-light-grey-h),
var(--color-light-grey-s),
var(--color-light-grey-l)
);
--color-neutral-h: 228;
--color-neutral-s: 10%;
--color-neutral-l: 50%;
--color-neutral: hsl(
var(--color-neutral-h),
var(--color-neutral-s),
var(--color-neutral-l)
);
--color-text-dark-h: 0;
--color-text-dark-s: 0%;
--color-text-dark-l: 33.3%;
@ -372,6 +409,19 @@
var(--color-sticky-default-border-l)
);
// Generated Color Shades from 50 to 950
// Not yet used in design system
@each $color in ('neutral', 'success', 'warning', 'danger') {
@each $shade in (50, 100, 150, 200, 250, 300, 350, 400, 450, 500, 550, 600, 650, 700, 750, 800, 850, 900, 950) {
--color-#{$color}-#{$shade}-l: #{math.div($shade, 10)}#{'%'};
--color-#{$color}-#{$shade}: hsl(
var(--color-#{$color}-h),
var(--color-#{$color}-s),
var(--color-#{$color}-#{$shade}-l)
);
}
}
--border-radius-xlarge: 12px;
--border-radius-large: 8px;
--border-radius-base: 4px;
@ -418,5 +468,5 @@
}
:root {
@include theme;
@include theme;
}

View file

@ -1,129 +0,0 @@
@charset "UTF-8";
@use 'mixins/mixins';
@use 'mixins/utils';
@use 'mixins/function';
@use 'common/var';
$disabled-border-color: var(--color-foreground-base);
$loading-overlay-background-color: rgba(255, 255, 255, 0.35);
@include mixins.b(button) {
display: inline-block;
line-height: 1;
white-space: nowrap;
cursor: pointer;
border: var(--border-width-base) var.$button-border-color
var(--border-style-base);
color: var.$button-font-color;
background-color: var.$button-background-color;
font-weight: var(--font-weight-bold);
border-radius: var.$button-border-radius;
padding: var.$button-padding-vertical var.$button-padding-horizontal;
font-size: var.$button-font-size;
-webkit-appearance: none;
text-align: center;
box-sizing: border-box;
outline: none;
margin: 0;
transition: 0.1s;
@include utils.utils-user-select(none);
&:active {
color: var.$button-active-color;
border-color: var.$button-active-border-color;
background-color: var.$button-active-background-color;
outline: none;
}
&::-moz-focus-inner {
border: 0;
}
@include mixins.when(loading) {
position: relative;
pointer-events: none;
&:before {
pointer-events: none;
content: '';
position: absolute;
left: -1px;
top: -1px;
right: -1px;
bottom: -1px;
border-radius: inherit;
background-color: $loading-overlay-background-color;
}
}
@include mixins.when(disabled) {
&,
&:hover,
&:active,
&:focus {
cursor: not-allowed;
background-image: none;
color: var.$button-disabled-font-color;
background-color: var.$button-disabled-background-color;
border-color: var.$button-disabled-border-color;
}
}
@include mixins.when(round) {
--button-border-radius: 20px;
}
@include mixins.when(circle) {
--button-padding-vertical: var(--spacing-xs);
--button-padding-horizontal: var(--spacing-xs);
--button-border-radius: 50%;
}
@include mixins.m(mini) {
--button-padding-vertical: var(--spacing-4xs);
--button-padding-horizontal: var(--spacing-2xs);
--button-font-size: var(--font-size-2xs);
@include mixins.when(circle) {
--button-padding-vertical: var(--spacing-4xs);
--button-padding-horizontal: var(--spacing-4xs);
}
}
@include mixins.m(small) {
--button-padding-vertical: var(--spacing-3xs);
--button-padding-horizontal: var(--spacing-xs);
--button-font-size: var(--font-size-2xs);
@include mixins.when(circle) {
--button-padding-vertical: var(--spacing-3xs);
--button-padding-horizontal: var(--spacing-3xs);
}
}
@include mixins.m(medium) {
--button-padding-vertical: var(--spacing-2xs);
--button-padding-horizontal: var(--spacing-xs);
--button-font-size: var(--font-size-2xs);
@include mixins.when(circle) {
--button-padding-vertical: var(--spacing-2xs);
--button-padding-horizontal: var(--spacing-2xs);
}
}
@include mixins.m(xlarge) {
--button-padding-vertical: var(--spacing-xs);
--button-padding-horizontal: var(--spacing-s);
--button-font-size: var(--font-size-m);
@include mixins.when(circle) {
--button-padding-vertical: var(--spacing-xs);
--button-padding-horizontal: var(--spacing-xs);
}
}
}

View file

@ -1,6 +1,5 @@
@use "mixins/mixins";
@use "./common/var";
@use "button";
@use "button-group";
@include mixins.b(calendar) {

View file

@ -3,380 +3,376 @@
@use "./common/var";
@include mixins.b(color-predefine) {
display: flex;
font-size: 12px;
margin-top: 8px;
width: 280px;
@include mixins.e(colors) {
display: flex;
font-size: 12px;
margin-top: 8px;
width: 280px;
flex: 1;
flex-wrap: wrap;
}
@include mixins.e(colors) {
display: flex;
flex: 1;
flex-wrap: wrap;
@include mixins.e(color-selector) {
margin: 0 0 8px 8px;
width: 20px;
height: 20px;
border-radius: 4px;
cursor: pointer;
&:nth-child(10n + 1) {
margin-left: 0;
}
@include mixins.e(color-selector) {
margin: 0 0 8px 8px;
width: 20px;
height: 20px;
border-radius: 4px;
cursor: pointer;
&:nth-child(10n + 1) {
margin-left: 0;
}
&.selected {
box-shadow: 0 0 3px 2px var(--color-primary);
}
> div {
display: flex;
height: 100%;
border-radius: 3px;
}
@include mixins.when(alpha) {
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAwAAAAMCAIAAADZF8uwAAAAGUlEQVQYV2M4gwH+YwCGIasIUwhT25BVBADtzYNYrHvv4gAAAABJRU5ErkJggg==);
}
&.selected {
box-shadow: 0 0 3px 2px var(--color-primary);
}
> div {
display: flex;
height: 100%;
border-radius: 3px;
}
@include mixins.when(alpha) {
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAwAAAAMCAIAAADZF8uwAAAAGUlEQVQYV2M4gwH+YwCGIasIUwhT25BVBADtzYNYrHvv4gAAAABJRU5ErkJggg==);
}
}
}
@include mixins.b(color-hue-slider) {
position: relative;
box-sizing: border-box;
width: 280px;
height: 12px;
background-color: #f00;
padding: 0 2px;
@include mixins.e(bar) {
position: relative;
background: linear-gradient(
to right,
#f00 0%,
#ff0 17%,
#0f0 33%,
#0ff 50%,
#00f 67%,
#f0f 83%,
#f00 100%
);
height: 100%;
}
@include mixins.e(thumb) {
position: absolute;
cursor: pointer;
box-sizing: border-box;
width: 280px;
height: 12px;
background-color: #f00;
padding: 0 2px;
left: 0;
top: 0;
width: 4px;
height: 100%;
border-radius: 1px;
background: #fff;
border: 1px solid #f0f0f0;
box-shadow: 0 0 2px rgba(0, 0, 0, 0.6);
z-index: 1;
}
@include mixins.e(bar) {
position: relative;
background: linear-gradient(
to right,
#f00 0%,
#ff0 17%,
#0f0 33%,
#0ff 50%,
#00f 67%,
#f0f 83%,
#f00 100%
);
height: 100%;
@include mixins.when(vertical) {
width: 12px;
height: 180px;
padding: 2px 0;
.el-color-hue-slider__bar {
background: linear-gradient(
to bottom,
#f00 0%,
#ff0 17%,
#0f0 33%,
#0ff 50%,
#00f 67%,
#f0f 83%,
#f00 100%
);
}
@include mixins.e(thumb) {
position: absolute;
cursor: pointer;
box-sizing: border-box;
left: 0;
top: 0;
width: 4px;
height: 100%;
border-radius: 1px;
background: #fff;
border: 1px solid #f0f0f0;
box-shadow: 0 0 2px rgba(0, 0, 0, 0.6);
z-index: 1;
}
@include mixins.when(vertical) {
width: 12px;
height: 180px;
padding: 2px 0;
.el-color-hue-slider__bar {
background: linear-gradient(
to bottom,
#f00 0%,
#ff0 17%,
#0f0 33%,
#0ff 50%,
#00f 67%,
#f0f 83%,
#f00 100%
);
}
.el-color-hue-slider__thumb {
left: 0;
top: 0;
width: 100%;
height: 4px;
}
.el-color-hue-slider__thumb {
left: 0;
top: 0;
width: 100%;
height: 4px;
}
}
}
@include mixins.b(color-svpanel) {
position: relative;
width: 280px;
height: 180px;
position: relative;
width: 280px;
height: 180px;
@include mixins.e(('white', 'black')) {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
@include mixins.e('white') {
background: linear-gradient(to right, #fff, rgba(255, 255, 255, 0));
}
@include mixins.e('black') {
background: linear-gradient(to top, #000, rgba(0, 0, 0, 0));
}
@include mixins.e(cursor) {
position: absolute;
> div {
cursor: head;
width: 4px;
height: 4px;
box-shadow: 0 0 0 1.5px #fff, inset 0 0 1px 1px rgba(0, 0, 0, 0.3),
0 0 1px 2px rgba(0, 0, 0, 0.4);
border-radius: 50%;
transform: translate(-2px, -2px);
}
@include mixins.e(('white', 'black')) {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
@include mixins.e('white') {
background: linear-gradient(to right, #fff, rgba(255, 255, 255, 0));
}
@include mixins.e('black') {
background: linear-gradient(to top, #000, rgba(0, 0, 0, 0));
}
@include mixins.e(cursor) {
position: absolute;
> div {
cursor: head;
width: 4px;
height: 4px;
box-shadow: 0 0 0 1.5px #fff, inset 0 0 1px 1px rgba(0, 0, 0, 0.3),
0 0 1px 2px rgba(0, 0, 0, 0.4);
border-radius: 50%;
transform: translate(-2px, -2px);
}
}
}
@include mixins.b(color-alpha-slider) {
position: relative;
box-sizing: border-box;
width: 280px;
height: 12px;
background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAwAAAAMCAIAAADZF8uwAAAAGUlEQVQYV2M4gwH+YwCGIasIUwhT25BVBADtzYNYrHvv4gAAAABJRU5ErkJggg==);
@include mixins.e(bar) {
position: relative;
background: linear-gradient(
to right,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 1) 100%
);
height: 100%;
}
@include mixins.e(thumb) {
position: absolute;
cursor: pointer;
box-sizing: border-box;
width: 280px;
height: 12px;
background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAwAAAAMCAIAAADZF8uwAAAAGUlEQVQYV2M4gwH+YwCGIasIUwhT25BVBADtzYNYrHvv4gAAAABJRU5ErkJggg==);
left: 0;
top: 0;
width: 4px;
height: 100%;
border-radius: 1px;
background: #fff;
border: 1px solid #f0f0f0;
box-shadow: 0 0 2px rgba(0, 0, 0, 0.6);
z-index: 1;
}
@include mixins.e(bar) {
position: relative;
background: linear-gradient(
to right,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 1) 100%
);
height: 100%;
@include mixins.when(vertical) {
width: 20px;
height: 180px;
.el-color-alpha-slider__bar {
background: linear-gradient(
to bottom,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 1) 100%
);
}
@include mixins.e(thumb) {
position: absolute;
cursor: pointer;
box-sizing: border-box;
left: 0;
top: 0;
width: 4px;
height: 100%;
border-radius: 1px;
background: #fff;
border: 1px solid #f0f0f0;
box-shadow: 0 0 2px rgba(0, 0, 0, 0.6);
z-index: 1;
}
@include mixins.when(vertical) {
width: 20px;
height: 180px;
.el-color-alpha-slider__bar {
background: linear-gradient(
to bottom,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 1) 100%
);
}
.el-color-alpha-slider__thumb {
left: 0;
top: 0;
width: 100%;
height: 4px;
}
.el-color-alpha-slider__thumb {
left: 0;
top: 0;
width: 100%;
height: 4px;
}
}
}
@include mixins.b(color-dropdown) {
width: 300px;
width: 300px;
@include mixins.e(main-wrapper) {
margin-bottom: 6px;
@include mixins.e(main-wrapper) {
margin-bottom: 6px;
&::after {
content: '';
display: table;
clear: both;
}
&::after {
content: '';
display: table;
clear: both;
}
}
@include mixins.e(btns) {
margin-top: 6px;
text-align: right;
}
@include mixins.e(btns) {
margin-top: 6px;
text-align: right;
}
@include mixins.e(value) {
float: left;
line-height: 26px;
font-size: 12px;
color: var.$color-black;
width: 160px;
}
@include mixins.e(value) {
float: left;
line-height: 26px;
font-size: 12px;
color: var.$color-black;
width: 160px;
}
@include mixins.e(btn) {
@include button.button-round();
@include button.button-small();
@include button.button-just-primary();
}
@include mixins.e(btn) {
@include button.button-small();
@include button.button-just-primary();
}
@include mixins.e(link-btn) {
@include button.button-round();
@include button.button-outline();
@include button.button-small();
margin-right: var(--spacing-2xs);
}
@include mixins.e(link-btn) {
@include button.button-small();
margin-right: var(--spacing-2xs);
}
}
@include mixins.b(color-picker) {
display: inline-block;
position: relative;
line-height: normal;
height: 40px;
@include mixins.when(disabled) {
.el-color-picker__trigger {
cursor: not-allowed;
}
}
@include mixins.m(medium) {
height: 36px;
.el-color-picker__trigger {
height: 36px;
width: 36px;
}
.el-color-picker__mask {
height: 34px;
width: 34px;
}
}
@include mixins.m(small) {
height: 32px;
.el-color-picker__trigger {
height: 32px;
width: 32px;
}
.el-color-picker__mask {
height: 30px;
width: 30px;
}
.el-color-picker__icon,
.el-color-picker__empty {
transform: translate3d(-50%, -50%, 0) scale(0.8);
}
}
@include mixins.m(mini) {
height: 28px;
.el-color-picker__trigger {
height: 28px;
width: 28px;
}
.el-color-picker__mask {
height: 26px;
width: 26px;
}
.el-color-picker__icon,
.el-color-picker__empty {
transform: translate3d(-50%, -50%, 0) scale(0.8);
}
}
@include mixins.e(mask) {
height: 38px;
width: 38px;
border-radius: 4px;
position: absolute;
top: 1px;
left: 1px;
z-index: 1;
cursor: not-allowed;
background-color: rgba(255, 255, 255, 0.7);
}
@include mixins.e(trigger) {
display: inline-block;
position: relative;
line-height: normal;
box-sizing: border-box;
height: 40px;
width: 40px;
padding: 4px;
border: 1px solid #e6e6e6;
border-radius: 4px;
font-size: 0;
position: relative;
cursor: pointer;
}
@include mixins.when(disabled) {
.el-color-picker__trigger {
cursor: not-allowed;
}
@include mixins.e(color) {
position: relative;
display: block;
box-sizing: border-box;
border: 1px solid #999;
border-radius: var(--border-radius-small);
width: 100%;
height: 100%;
text-align: center;
@include mixins.when(alpha) {
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAwAAAAMCAIAAADZF8uwAAAAGUlEQVQYV2M4gwH+YwCGIasIUwhT25BVBADtzYNYrHvv4gAAAABJRU5ErkJggg==);
}
}
@include mixins.m(medium) {
height: 36px;
@include mixins.e(color-inner) {
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
}
.el-color-picker__trigger {
height: 36px;
width: 36px;
}
@include mixins.e(empty) {
font-size: 12px;
color: #999;
position: absolute;
top: 50%;
left: 50%;
transform: translate3d(-50%, -50%, 0);
}
.el-color-picker__mask {
height: 34px;
width: 34px;
}
}
@include mixins.e(icon) {
display: inline-block;
position: absolute;
width: 100%;
top: 50%;
left: 50%;
transform: translate3d(-50%, -50%, 0);
color: var.$color-white;
text-align: center;
font-size: 12px;
}
@include mixins.m(small) {
height: 32px;
.el-color-picker__trigger {
height: 32px;
width: 32px;
}
.el-color-picker__mask {
height: 30px;
width: 30px;
}
.el-color-picker__icon,
.el-color-picker__empty {
transform: translate3d(-50%, -50%, 0) scale(0.8);
}
}
@include mixins.m(mini) {
height: 28px;
.el-color-picker__trigger {
height: 28px;
width: 28px;
}
.el-color-picker__mask {
height: 26px;
width: 26px;
}
.el-color-picker__icon,
.el-color-picker__empty {
transform: translate3d(-50%, -50%, 0) scale(0.8);
}
}
@include mixins.e(mask) {
height: 38px;
width: 38px;
border-radius: 4px;
position: absolute;
top: 1px;
left: 1px;
z-index: 1;
cursor: not-allowed;
background-color: rgba(255, 255, 255, 0.7);
}
@include mixins.e(trigger) {
display: inline-block;
box-sizing: border-box;
height: 40px;
width: 40px;
padding: 4px;
border: 1px solid #e6e6e6;
border-radius: 4px;
font-size: 0;
position: relative;
cursor: pointer;
}
@include mixins.e(color) {
position: relative;
display: block;
box-sizing: border-box;
border: 1px solid #999;
border-radius: var(--border-radius-small);
width: 100%;
height: 100%;
text-align: center;
@include mixins.when(alpha) {
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAwAAAAMCAIAAADZF8uwAAAAGUlEQVQYV2M4gwH+YwCGIasIUwhT25BVBADtzYNYrHvv4gAAAABJRU5ErkJggg==);
}
}
@include mixins.e(color-inner) {
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
}
@include mixins.e(empty) {
font-size: 12px;
color: #999;
position: absolute;
top: 50%;
left: 50%;
transform: translate3d(-50%, -50%, 0);
}
@include mixins.e(icon) {
display: inline-block;
position: absolute;
width: 100%;
top: 50%;
left: 50%;
transform: translate3d(-50%, -50%, 0);
color: var.$color-white;
text-align: center;
font-size: 12px;
}
@include mixins.e(panel) {
position: absolute;
z-index: 10;
padding: 6px;
box-sizing: content-box;
background-color: var.$color-white;
border: 1px solid var(--border-color-light);
border-radius: var(--border-radius-base);
box-shadow: var.$dropdown-menu-box-shadow;
}
@include mixins.e(panel) {
position: absolute;
z-index: 10;
padding: 6px;
box-sizing: content-box;
background-color: var.$color-white;
border: 1px solid var(--border-color-light);
border-radius: var(--border-radius-base);
box-shadow: var.$dropdown-menu-box-shadow;
}
}

View file

@ -11,7 +11,7 @@ $all-transition: all 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
$fade-transition: opacity 300ms cubic-bezier(0.23, 1, 0.32, 1);
$fade-linear-transition: opacity 200ms linear;
$md-fade-transition: transform 300ms cubic-bezier(0.23, 1, 0.32, 1),
opacity 300ms cubic-bezier(0.23, 1, 0.32, 1);
opacity 300ms cubic-bezier(0.23, 1, 0.32, 1);
$border-transition-base: border-color 0.2s cubic-bezier(0.645, 0.045, 0.355, 1);
$color-transition-base: color 0.2s cubic-bezier(0.645, 0.045, 0.355, 1);
@ -21,190 +21,190 @@ $color-white: #ffffff;
$color-black: #000000;
$color-primary-light-1: function.lightness(
--color-primary-h,
--color-primary-s,
--color-primary-l,
4%
--color-primary-h,
--color-primary-s,
--color-primary-l,
4%
);
$color-primary-light-2: function.lightness(
--color-primary-h,
--color-primary-s,
--color-primary-l,
8%
--color-primary-h,
--color-primary-s,
--color-primary-l,
8%
);
$color-primary-light-3: function.lightness(
--color-primary-h,
--color-primary-s,
--color-primary-l,
12%
--color-primary-h,
--color-primary-s,
--color-primary-l,
12%
);
$color-primary-light-4: function.lightness(
--color-primary-h,
--color-primary-s,
--color-primary-l,
16%
--color-primary-h,
--color-primary-s,
--color-primary-l,
16%
);
$color-primary-light-5: function.lightness(
--color-primary-h,
--color-primary-s,
--color-primary-l,
20%
--color-primary-h,
--color-primary-s,
--color-primary-l,
20%
);
$color-primary-light-6: function.lightness(
--color-primary-h,
--color-primary-s,
--color-primary-l,
24%
--color-primary-h,
--color-primary-s,
--color-primary-l,
24%
);
$color-primary-light-7: function.lightness(
--color-primary-h,
--color-primary-s,
--color-primary-l,
28%
--color-primary-h,
--color-primary-s,
--color-primary-l,
28%
);
$color-primary-light-8: function.lightness(
--color-primary-h,
--color-primary-s,
--color-primary-l,
32%
--color-primary-h,
--color-primary-s,
--color-primary-l,
32%
);
$color-primary-light-9: function.lightness(
--color-primary-h,
--color-primary-s,
--color-primary-l,
36%
--color-primary-h,
--color-primary-s,
--color-primary-l,
36%
);
$color-primary-light: function.lightness(
--color-primary-h,
--color-primary-s,
--color-primary-l,
30%
--color-primary-h,
--color-primary-s,
--color-primary-l,
30%
);
$color-primary-lighter: function.lightness(
--color-primary-h,
--color-primary-s,
--color-primary-l,
34%
--color-primary-h,
--color-primary-s,
--color-primary-l,
34%
);
$color-primary-shade-1: function.lightness(
--color-primary-h,
--color-primary-s,
--color-primary-l,
-(10)
--color-primary-h,
--color-primary-s,
--color-primary-l,
-(10)
);
$color-success-light-1: function.lightness(
--color-success-h,
--color-success-s,
--color-success-l,
4%
--color-success-h,
--color-success-s,
--color-success-l,
4%
);
$color-success-light-3: function.lightness(
--color-success-h,
--color-success-s,
--color-success-l,
12%
--color-success-h,
--color-success-s,
--color-success-l,
12%
);
$color-success-light-5: function.lightness(
--color-success-h,
--color-success-s,
--color-success-l,
20%
--color-success-h,
--color-success-s,
--color-success-l,
20%
);
$color-success-light: function.lightness(
--color-success-h,
--color-success-s,
--color-success-l,
41%
--color-success-h,
--color-success-s,
--color-success-l,
41%
);
$color-success-lighter: function.lightness(
--color-success-h,
--color-success-s,
--color-success-l,
48%
--color-success-h,
--color-success-s,
--color-success-l,
48%
);
$color-warning-light-1: function.lightness(
--color-warning-h,
--color-warning-s,
--color-warning-l,
4%
--color-warning-h,
--color-warning-s,
--color-warning-l,
4%
);
$color-warning-light-3: function.lightness(
--color-warning-h,
--color-warning-s,
--color-warning-l,
12%
--color-warning-h,
--color-warning-s,
--color-warning-l,
12%
);
$color-warning-light-5: function.lightness(
--color-warning-h,
--color-warning-s,
--color-warning-l,
20%
--color-warning-h,
--color-warning-s,
--color-warning-l,
20%
);
$color-warning-light: function.lightness(
--color-warning-h,
--color-warning-s,
--color-warning-l,
34%
--color-warning-h,
--color-warning-s,
--color-warning-l,
34%
);
$color-warning-lighter: function.lightness(
--color-warning-h,
--color-warning-s,
--color-warning-l,
40%
--color-warning-h,
--color-warning-s,
--color-warning-l,
40%
);
$color-danger-light-1: function.lightness(
--color-danger-h,
--color-danger-s,
--color-danger-l,
4%
--color-danger-h,
--color-danger-s,
--color-danger-l,
4%
);
$color-danger-light-3: function.lightness(
--color-danger-h,
--color-danger-s,
--color-danger-l,
12%
--color-danger-h,
--color-danger-s,
--color-danger-l,
12%
);
$color-danger-light-5: function.lightness(
--color-danger-h,
--color-danger-s,
--color-danger-l,
20%
--color-danger-h,
--color-danger-s,
--color-danger-l,
20%
);
$color-danger-light: function.lightness(
--color-danger-h,
--color-danger-s,
--color-danger-l,
24%
--color-danger-h,
--color-danger-s,
--color-danger-l,
24%
);
$color-danger-lighter: var(--color-danger-tint-2);
$color-info-light-1: function.lightness(
--color-info-h,
--color-info-s,
--color-info-l,
4%
--color-info-h,
--color-info-s,
--color-info-l,
4%
);
$color-info-light-3: function.lightness(
--color-info-h,
--color-info-s,
--color-info-l,
12%
--color-info-h,
--color-info-s,
--color-info-l,
12%
);
$color-info-light-5: function.lightness(
--color-info-h,
--color-info-s,
--color-info-l,
20%
--color-info-h,
--color-info-s,
--color-info-l,
20%
);
$color-info-lighter: function.lightness(
--color-info-h,
--color-info-s,
--color-info-l,
39%
--color-info-h,
--color-info-s,
--color-info-l,
39%
);
// Background
@ -222,7 +222,12 @@ $border-color-hover: var(--color-text-lighter);
/// borderRadius|1|Radius|0
$border-radius-circle: 100%;
// Box-shadow
/* Outline
-------------------------- */
$focus-outline-width: 2px;
/* Box shadow
-------------------------- */
/// boxShadow|1|Shadow|1
$box-shadow-base: 0 2px 4px rgba(0, 0, 0, 0.12), 0 0 6px rgba(0, 0, 0, 0.04);
// boxShadow|1|Shadow|1
@ -666,14 +671,30 @@ $button-mini-padding-horizontal: 15px;
$button-font-color: var(--button-color, var(--color-text-xlight));
$button-background-color: var(--button-background-color, var(--color-primary));
$button-active-color: var(--button-active-color, var(--color-text-xlight));
$button-active-border-color: var(
--button-active-border-color,
var(--color-primary-shade-1)
--button-active-border-color,
var(--color-primary-shade-1)
);
$button-active-background-color: var(
--button-active-background-color,
var(--color-primary-shade-1)
--button-active-background-color,
var(--color-primary-shade-1)
);
$button-focus-outline-color: var(
--button-focus-outline-color,
var(--color-primary-tint-1)
);
$button-hover-color: var(--button-hover-color, var(--color-text-xlight));
$button-hover-border-color: var(
--button-hover-border-color,
var(--color-primary-shade-1)
);
$button-hover-background-color: var(
--button-hover-background-color,
var(--color-primary-shade-1)
);
/// color||Color|0
@ -684,14 +705,17 @@ $button-default-background-color: $color-white;
$button-default-border-color: var(--border-color-base);
/// color||Color|0
$button-disabled-font-color: var(--color-text-light);
/// color||Color|0
$button-disabled-background-color: var(
--button-disabled-background-color,
var(--color-foreground-base)
$button-disabled-font-color: var(
--button-disabled-color,
var(--color-text-dark)
);
/// color||Color|0
$button-disabled-border-color: var(--button-disabled-border-color, var(--border-color-base));
$button-disabled-background-color: var(
--button-disabled-background-color,
var(--color-light-grey)
);
/// color||Color|0
$button-disabled-border-color: var(--button-disabled-border-color, var(--color-light-grey));
/// color||Color|0
$button-primary-border-color: var(--color-primary);
@ -799,7 +823,7 @@ $pagination-hover-color: var(--color-primary);
/* Popup
-------------------------- */
/// color||Color|0
$popup-modal-background-color: hsla(247,14%, 30%, 0.75);
$popup-modal-background-color: hsla(247, 14%, 30%, 0.75);
/// opacity||Other|1
$popup-modal-opacity: 0.65;
@ -893,10 +917,10 @@ $slider-main-background-color: var(--color-primary);
/// color||Color|0
$slider-runway-background-color: var(--border-color-base);
$slider-button-hover-color: function.saturation(
--color-primary-h,
--color-primary-s,
--color-primary-l,
8%
--color-primary-h,
--color-primary-s,
--color-primary-l,
8%
);
$slider-stop-background-color: $color-white;
$slider-disable-color: var(--color-text-lighter);
@ -974,16 +998,16 @@ $loading-fullscreen-spinner-size: 50px;
/* Scrollbar
--------------------------*/
$scrollbar-background-color: hsla(
var(#{--color-text-light-h}),
var(#{--color-text-light-s}),
var(#{--color-text-light-l}),
0.3
var(#{--color-text-light-h}),
var(#{--color-text-light-s}),
var(#{--color-text-light-l}),
0.3
);
$scrollbar-hover-background-color: hsla(
var(#{--color-text-light-h}),
var(#{--color-text-light-s}),
var(#{--color-text-light-l}),
0.5
var(#{--color-text-light-h}),
var(#{--color-text-light-s}),
var(#{--color-text-light-l}),
0.5
);
/* Carousel
@ -1128,67 +1152,67 @@ $lg: 1200px;
$xl: 1920px;
$breakpoints: (
'xs': (
max-width: $sm - 1,
),
'sm': (
min-width: $sm,
),
'md': (
min-width: $md,
),
'lg': (
min-width: $lg,
),
'xl': (
min-width: $xl,
),
'xs': (
max-width: $sm - 1,
),
'sm': (
min-width: $sm,
),
'md': (
min-width: $md,
),
'lg': (
min-width: $lg,
),
'xl': (
min-width: $xl,
),
);
$breakpoints-spec: (
'xs-only': (
max-width: $sm - 1,
),
'sm-and-up': (
min-width: $sm,
),
'sm-only': (
'xs-only': (
max-width: $sm - 1,
),
'sm-and-up': (
min-width: $sm,
),
'sm-only': (
min-width: #{$sm},
)
and
(
max-width: #{$md - 1},
),
'sm-and-down': (
max-width: $md - 1,
),
'md-and-up': (
min-width: $md,
),
'md-only': (
'sm-and-down': (
max-width: $md - 1,
),
'md-and-up': (
min-width: $md,
),
'md-only': (
min-width: #{$md},
)
and
(
max-width: #{$lg - 1},
),
'md-and-down': (
max-width: $lg - 1,
),
'lg-and-up': (
min-width: $lg,
),
'lg-only': (
'md-and-down': (
max-width: $lg - 1,
),
'lg-and-up': (
min-width: $lg,
),
'lg-only': (
min-width: #{$lg},
)
and
(
max-width: #{$xl - 1},
),
'lg-and-down': (
max-width: $xl - 1,
),
'xl-only': (
min-width: $xl,
),
'lg-and-down': (
max-width: $xl - 1,
),
'xl-only': (
min-width: $xl,
),
);

View file

@ -1,6 +1,5 @@
@use "mixins/mixins";
@use "./common/var";
@use "button";
@use "./popper";
@include mixins.b(dropdown) {
@ -35,7 +34,7 @@
background: rgba(var.$color-white, 0.5);
}
&.el-button--default::before {
&.button::before {
background: hsla(
var(#{--button-default-border-color-h}),
var(#{--button-default-border-color-s}),

View file

@ -23,7 +23,6 @@
@use "./switch.scss";
@use "./select.scss";
@use "./skeleton.scss";
@use "./button.scss";
// @use "./button-group.scss";
@use "./table.scss";
@use "./table-column.scss";
@ -82,4 +81,4 @@
// @use "./avatar.scss";
@use "./drawer.scss";
// @use "./popconfirm.scss";
@use "./utilities.scss";
@use "./utilities/index.scss";

View file

@ -2,7 +2,6 @@
@use "mixins/button";
@use "./common/var";
@use "common/popup";
@use "button" as buttons;
@use "input";
@include mixins.b(message-box) {
@ -151,15 +150,13 @@
margin-left: 10px;
}
& .btn--confirm {
@include button.button-just-primary();
@include button.button-medium();
}
& .btn--cancel {
@include button.button-outline();
@include button.button-medium();
}
//& .btn--confirm {
// @include button.button-just-primary();
// @include button.button-medium();
//}
//
//& .btn--cancel {
//}
}
@include mixins.e(btns-reverse) {

View file

@ -2,7 +2,6 @@
@use "mixins/utils";
@use "./common/var";
@use "input";
@use "button";
@use "checkbox";
@use "checkbox-group";
@ -15,7 +14,7 @@
padding: 0 30px;
}
@include mixins.e(button) {
.button {
display: block;
margin: 0 auto;
padding: 10px;

View file

@ -0,0 +1,7 @@
.float-left {
float: left !important;
}
.float-right {
float: right !important;
}

View file

@ -0,0 +1,2 @@
@import 'float';
@import 'spacing';

View file

@ -21,6 +21,7 @@ import {
ITelemetrySettings,
IWorkflowSettings as IWorkflowSettingsWorkflow,
WorkflowExecuteMode,
PinData,
PublicInstalledPackage,
} from 'n8n-workflow';
@ -150,6 +151,7 @@ export interface INodeUi extends INode {
notes?: string;
issues?: INodeIssues;
name: string;
pinData?: IDataObject;
}
export interface INodeTypesMaxCount {
@ -216,6 +218,7 @@ export interface IStartRunData {
startNodes?: string[];
destinationNode?: string;
runData?: IRunData;
pinData?: PinData;
}
export interface IRunDataUi {
@ -250,6 +253,7 @@ export interface IWorkflowData {
connections: IConnections;
settings?: IWorkflowSettings;
tags?: string[];
pinData?: PinData;
}
export interface IWorkflowDataUpdate {
@ -260,6 +264,7 @@ export interface IWorkflowDataUpdate {
settings?: IWorkflowSettings;
active?: boolean;
tags?: ITag[] | string[]; // string[] when store or requested, ITag[] from API response
pinData?: PinData;
}
export interface IWorkflowTemplate {
@ -282,6 +287,7 @@ export interface IWorkflowDb {
connections: IConnections;
settings?: IWorkflowSettings;
tags?: ITag[] | string[]; // string[] when store or requested, ITag[] from API response
pinData?: PinData;
}
// Identical to cli.Interfaces.ts
@ -907,6 +913,10 @@ export interface IUiState {
};
output: {
displayMode: IRunDataDisplayMode;
editMode: {
enabled: boolean;
value: string;
};
};
focusedMappableInput: string;
mappingTelemetry: {[key: string]: string | number | boolean};

View file

@ -27,11 +27,10 @@
v-if="buttonLabel"
:label="buttonLoading && buttonLoadingLabel ? buttonLoadingLabel : buttonLabel"
:title="buttonTitle"
:theme="theme"
:type="theme"
:loading="buttonLoading"
size="small"
type="outline"
:transparentBackground="true"
outline
@click.stop="onClick"
/>
</div>

View file

@ -8,15 +8,12 @@
:before-close="closeDialog"
>
<div class="text-editor-wrapper ignore-key-press">
<div ref="code" class="text-editor" @keydown.stop></div>
<code-editor :value="value" :autocomplete="loadAutocompleteData" @input="$emit('valueChanged', $event)" />
</div>
</el-dialog>
</template>
<script lang="ts">
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
import {DateTime} from 'luxon';
import { genericHelpers } from '@/components/mixins/genericHelpers';
import { workflowHelpers } from '@/components/mixins/workflowHelpers';
@ -34,106 +31,19 @@ import {
import {
PLACEHOLDER_FILLED_AT_EXECUTION_TIME,
} from '@/constants';
import { CodeEditor } from './forms';
export default mixins(
genericHelpers,
workflowHelpers,
).extend({
name: 'CodeEdit',
components: {
CodeEditor,
},
props: ['codeAutocomplete', 'parameter', 'path', 'type', 'value'],
data() {
return {
monacoInstance: null as monaco.editor.IStandaloneCodeEditor | null,
monacoLibrary: null as monaco.IDisposable | null,
};
},
mounted() {
setTimeout(this.loadEditor);
},
destroyed() {
if (this.monacoLibrary) {
this.monacoLibrary.dispose();
}
},
methods: {
closeDialog() {
// Handle the close externally as the visible parameter is an external prop
// and is so not allowed to be changed here.
this.$emit('closeDialog');
return false;
},
createSimpleRepresentation(inputData: object | null | undefined | boolean | string | number | boolean[] | string[] | number[] | object[]): object | null | undefined | boolean | string | number | boolean[] | string[] | number[] | object[] {
if (inputData === null || inputData === undefined) {
return inputData;
} else if (typeof inputData === 'string') {
return '';
} else if (typeof inputData === 'boolean') {
return true;
} else if (typeof inputData === 'number') {
return 1;
} else if (Array.isArray(inputData)) {
return inputData.map(value => this.createSimpleRepresentation(value));
} else if (typeof inputData === 'object') {
const returnData: { [key: string]: object } = {};
Object.keys(inputData).forEach(key => {
// @ts-ignore
returnData[key] = this.createSimpleRepresentation(inputData[key]);
});
return returnData;
}
return inputData;
},
loadEditor() {
if (!this.$refs.code) return;
this.monacoInstance = monaco.editor.create(this.$refs.code as HTMLElement, {
automaticLayout: true,
value: this.value,
language: this.type === 'code' ? 'javascript' : 'json',
tabSize: 2,
wordBasedSuggestions: false,
readOnly: this.isReadOnly,
minimap: {
enabled: false,
},
});
this.monacoInstance.onDidChangeModelContent(() => {
const model = this.monacoInstance!.getModel();
if (model) {
this.$emit('valueChanged', model.getValue());
}
});
monaco.editor.defineTheme('n8nCustomTheme', {
base: 'vs',
inherit: true,
rules: [],
colors: {
'editor.background': '#f5f2f0',
},
});
monaco.editor.setTheme('n8nCustomTheme');
if (this.type === 'code') {
// As wordBasedSuggestions: false does not have any effect does it however seem
// to remove all all suggestions from the editor if I do this
monaco.languages.typescript.javascriptDefaults.setCompilerOptions({
allowNonTsExtensions: true,
});
this.loadAutocompleteData();
} else if (this.type === 'json') {
monaco.languages.json.jsonDefaults.setDiagnosticsOptions({
validate: true,
});
}
},
loadAutocompleteData(): void {
loadAutocompleteData(): string[] {
if (['function', 'functionItem'].includes(this.codeAutocomplete)) {
const itemIndex = 0;
const inputName = 'main';
@ -149,8 +59,6 @@ export default mixins(
destinationIndex: 0,
};
const autocompleteData: string[] = [];
const executionData = this.$store.getters.getWorkflowExecution as IExecutionResponse | null;
let runExecutionData: IRunExecutionData;
@ -262,17 +170,44 @@ export default mixins(
}
}
this.monacoLibrary = monaco.languages.typescript.javascriptDefaults.addExtraLib(
autoCompleteItems.join('\n'),
);
return autoCompleteItems;
}
return [];
},
closeDialog() {
// Handle the close externally as the visible parameter is an external prop
// and is so not allowed to be changed here.
this.$emit('closeDialog');
return false;
},
createSimpleRepresentation(inputData: object | null | undefined | boolean | string | number | boolean[] | string[] | number[] | object[]): object | null | undefined | boolean | string | number | boolean[] | string[] | number[] | object[] {
if (inputData === null || inputData === undefined) {
return inputData;
} else if (typeof inputData === 'string') {
return '';
} else if (typeof inputData === 'boolean') {
return true;
} else if (typeof inputData === 'number') {
return 1;
} else if (Array.isArray(inputData)) {
return inputData.map(value => this.createSimpleRepresentation(value));
} else if (typeof inputData === 'object') {
const returnData: { [key: string]: object } = {};
Object.keys(inputData).forEach(key => {
// @ts-ignore
returnData[key] = this.createSimpleRepresentation(inputData[key]);
});
return returnData;
}
return inputData;
},
},
});
</script>
<style scoped>
.text-editor {
min-height: 30rem;
}

View file

@ -11,7 +11,7 @@
<n8n-button
v-if="parameter.options.length === 1"
type="tertiary"
fullWidth
block
@click="optionSelected(parameter.options[0].name)"
:label="getPlaceholderText"
/>
@ -194,6 +194,10 @@ export default mixins(
.param-options {
margin-top: var(--spacing-xs);
.button {
--button-background-color: var(--color-background-base);
}
}
.no-items-exist {

View file

@ -24,10 +24,10 @@
<div :class="$style.credActions">
<n8n-icon-button
v-if="currentCredential"
size="small"
:title="$locale.baseText('credentialEdit.credentialEdit.delete')"
icon="trash"
type="text"
size="medium"
type="tertiary"
:disabled="isSaving"
:loading="isDeleting"
@click="deleteCredential"
@ -884,6 +884,8 @@ export default mixins(showMessage, nodeHelpers).extend({
}
.credActions {
display: flex;
align-items: flex-start;
margin-right: var(--spacing-xl);
> * {
margin-left: var(--spacing-2xs);

View file

@ -31,7 +31,7 @@
<template v-slot:footer="{ close }">
<div :class="$style.footer">
<n8n-button @click="save" :loading="isSaving" :label="$locale.baseText('duplicateWorkflowDialog.save')" float="right" />
<n8n-button type="outline" @click="close" :disabled="isSaving" :label="$locale.baseText('duplicateWorkflowDialog.cancel')" float="right" />
<n8n-button type="secondary" @click="close" :disabled="isSaving" :label="$locale.baseText('duplicateWorkflowDialog.cancel')" float="right" />
</div>
</template>
</Modal>

View file

@ -100,16 +100,14 @@
</n8n-tooltip>
<el-dropdown trigger="click" @command="handleRetryClick">
<span class="retry-button">
<n8n-icon-button
v-if="scope.row.stoppedAt !== undefined && !scope.row.finished && scope.row.retryOf === undefined && scope.row.retrySuccessId === undefined && !scope.row.waitTill"
type="light"
:theme="scope.row.stoppedAt === null ? 'warning': 'danger'"
size="mini"
:title="$locale.baseText('executionsList.retryExecution')"
icon="redo"
/>
</span>
<n8n-icon-button
v-if="scope.row.stoppedAt !== undefined && !scope.row.finished && scope.row.retryOf === undefined && scope.row.retrySuccessId === undefined && !scope.row.waitTill"
:type="scope.row.stoppedAt === null ? 'warning': 'danger'"
class="ml-3xs"
size="mini"
:title="$locale.baseText('executionsList.retryExecution')"
icon="redo"
/>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item :command="{command: 'currentlySaved', row: scope.row}">
{{ $locale.baseText('executionsList.retryWithCurrentlySavedWorkflow') }}
@ -763,10 +761,6 @@ export default mixins(
text-align: center;
}
.retry-button {
margin-left: 5px;
}
.selection-options {
height: 2em;
}

View file

@ -82,7 +82,7 @@
<n8n-button
v-if="parameter.options.length === 1"
type="tertiary"
fullWidth
block
@click="optionSelected(parameter.options[0].name)"
:label="getPlaceholderText"
/>
@ -286,6 +286,13 @@ export default mixins(genericHelpers)
</script>
<style scoped lang="scss">
::v-deep {
.button {
--button-background-color: var(--color-background-base);
--button-border-color: var(--color-foreground-base);
}
}
.fixed-collection-parameter {
padding-left: var(--spacing-s);
}

View file

@ -7,7 +7,7 @@
@mouseleave="showTooltip = false"
>
<div :class="$style.tooltip">
<n8n-tooltip placement="top" :manual="true" :value="showTooltip">
<n8n-tooltip placement="top" manual :value="showTooltip">
<div slot="content" v-text="nodeType.displayName"></div>
<span />
</n8n-tooltip>

View file

@ -37,7 +37,7 @@
<n8n-text tag="div" :bold="true" color="text-dark" size="large">{{ $locale.baseText('ndv.input.noOutputData.title') }}</n8n-text>
<n8n-tooltip v-if="!readOnly" :manual="true" :value="showDraggableHint && showDraggableHintWithDelay">
<div slot="content" v-html="$locale.baseText('dataMapping.dragFromPreviousHint', { interpolate: { name: focusedMappableInput } })"></div>
<NodeExecuteButton type="outline" :transparent="true" :nodeName="currentNodeName" :label="$locale.baseText('ndv.input.noOutputData.executePrevious')" @execute="onNodeExecute" telemetrySource="inputs" />
<NodeExecuteButton type="secondary" :transparent="true" :nodeName="currentNodeName" :label="$locale.baseText('ndv.input.noOutputData.executePrevious')" @execute="onNodeExecute" telemetrySource="inputs" />
</n8n-tooltip>
<n8n-text v-if="!readOnly" tag="div" size="small">
{{ $locale.baseText('ndv.input.noOutputData.hint') }}

View file

@ -134,9 +134,10 @@ export default mixins(genericHelpers)
</script>
<style scoped lang="scss">
.duplicate-parameter-item ~.add-item-wrapper {
margin-top: var(--spacing-xs);
.duplicate-parameter-item {
~ .add-item-wrapper {
margin-top: var(--spacing-xs);
}
}
.delete-item {
@ -154,25 +155,31 @@ export default mixins(genericHelpers)
}
}
::v-deep .duplicate-parameter-item {
position: relative;
::v-deep {
.button {
--button-background-color: var(--color-background-base);
--button-border-color: var(--color-foreground-base);
}
.multi > .delete-item{
top: 0.1em;
.duplicate-parameter-item {
position: relative;
.multi > .delete-item{
top: 0.1em;
}
}
.duplicate-parameter-input-item {
margin: 0.5em 0 0.25em 2em;
}
.duplicate-parameter-item + .duplicate-parameter-item {
.collection-parameter-wrapper {
border-top: 1px dashed #999;
margin-top: var(--spacing-xs);
}
}
}
::v-deep .duplicate-parameter-input-item {
margin: 0.5em 0 0.25em 2em;
}
::v-deep .duplicate-parameter-item + .duplicate-parameter-item {
.collection-parameter-wrapper {
border-top: 1px dashed #999;
margin-top: var(--spacing-xs);
}
}
.no-items-exist {
margin: var(--spacing-xs) 0;
}

View file

@ -16,6 +16,10 @@
<font-awesome-icon icon="clock" />
</n8n-tooltip>
</div>
<span v-else-if="hasPinData" class="node-pin-data-icon">
<font-awesome-icon icon="thumbtack" />
<span v-if="workflowDataItems > 1" class="items-count"> {{ workflowDataItems }}</span>
</span>
<span v-else-if="workflowDataItems" class="data-count">
<font-awesome-icon icon="check" />
<span v-if="workflowDataItems > 1" class="items-count"> {{ workflowDataItems }}</span>
@ -27,10 +31,22 @@
</div>
<div class="node-trigger-tooltip__wrapper">
<n8n-tooltip placement="top" :manual="true" :value="showTriggerNodeTooltip" popper-class="node-trigger-tooltip__wrapper--item">
<n8n-tooltip placement="top" manual :value="showTriggerNodeTooltip" popper-class="node-trigger-tooltip__wrapper--item">
<div slot="content" v-text="getTriggerNodeTooltip"></div>
<span />
</n8n-tooltip>
<n8n-tooltip
v-if="isTriggerNode"
placement="top"
manual
:value="pinDataDiscoveryTooltipVisible"
popper-class="node-trigger-tooltip__wrapper--item"
>
<template #content>
{{ $locale.baseText('node.discovery.pinData.canvas') }}
</template>
<span />
</n8n-tooltip>
</div>
<NodeIcon class="node-icon" :nodeType="nodeType" :size="40" :shrink="false" :disabled="this.data.disabled"/>
@ -75,11 +91,12 @@
<script lang="ts">
import Vue from 'vue';
import { CUSTOM_API_CALL_KEY, WAIT_TIME_UNLIMITED } from '@/constants';
import {CUSTOM_API_CALL_KEY, LOCAL_STORAGE_PIN_DATA_DISCOVERY_CANVAS_FLAG, WAIT_TIME_UNLIMITED} from '@/constants';
import { externalHooks } from '@/components/mixins/externalHooks';
import { nodeBase } from '@/components/mixins/nodeBase';
import { nodeHelpers } from '@/components/mixins/nodeHelpers';
import { workflowHelpers } from '@/components/mixins/workflowHelpers';
import { pinData } from '@/components/mixins/pinData';
import {
INodeTypeDescription,
@ -95,7 +112,13 @@ import { get } from 'lodash';
import { getStyleTokenValue, getTriggerNodeServiceName } from './helpers';
import { INodeUi, XYPosition } from '@/Interface';
export default mixins(externalHooks, nodeBase, nodeHelpers, workflowHelpers).extend({
export default mixins(
externalHooks,
nodeBase,
nodeHelpers,
workflowHelpers,
pinData,
).extend({
name: 'Node',
components: {
NodeIcon,
@ -105,6 +128,7 @@ export default mixins(externalHooks, nodeBase, nodeHelpers, workflowHelpers).ext
return this.$store.getters.getWorkflowResultDataByNodeName(this.data.name);
},
hasIssues (): boolean {
if (this.hasPinData) return false;
if (this.data.issues !== undefined && Object.keys(this.data.issues).length) {
return true;
}
@ -257,11 +281,9 @@ export default mixins(externalHooks, nodeBase, nodeHelpers, workflowHelpers).ext
else if (!this.isExecuting) {
if (this.hasIssues) {
borderColor = getStyleTokenValue('--color-danger');
}
else if (this.waiting) {
} else if (this.waiting || this.hasPinData) {
borderColor = getStyleTokenValue('--color-secondary');
}
else if (this.workflowDataItems) {
} else if (this.workflowDataItems) {
borderColor = getStyleTokenValue('--color-success');
}
}
@ -286,7 +308,7 @@ export default mixins(externalHooks, nodeBase, nodeHelpers, workflowHelpers).ext
!this.isPollingTypeNode &&
!this.isNodeDisabled &&
this.workflowRunning &&
this.workflowDataItems === 0 &&
this.workflowDataItems === 0 &&
this.isSingleActiveTriggerNode &&
!this.isTriggerNodeTooltipEmpty &&
!this.hasIssues &&
@ -306,6 +328,13 @@ export default mixins(externalHooks, nodeBase, nodeHelpers, workflowHelpers).ext
this.showTriggerNodeTooltip = this.shouldShowTriggerTooltip;
}, 200);
}
if (this.pinDataDiscoveryTooltipVisible) {
this.pinDataDiscoveryTooltipVisible = false;
setTimeout(() => {
this.pinDataDiscoveryTooltipVisible = true;
}, 200);
}
},
shouldShowTriggerTooltip(shouldShowTriggerTooltip) {
if (shouldShowTriggerTooltip) {
@ -320,6 +349,14 @@ export default mixins(externalHooks, nodeBase, nodeHelpers, workflowHelpers).ext
this.$emit('run', {name: this.data.name, data: newValue, waiting: !!this.waiting});
},
},
created() {
const hasSeenPinDataTooltip = localStorage.getItem(LOCAL_STORAGE_PIN_DATA_DISCOVERY_CANVAS_FLAG);
if (!hasSeenPinDataTooltip) {
this.unwatchWorkflowDataItems = this.$watch('workflowDataItems', (dataItemsCount: number) => {
this.showPinDataDiscoveryTooltip(dataItemsCount);
});
}
},
mounted() {
this.setSubtitle();
setTimeout(() => {
@ -331,10 +368,22 @@ export default mixins(externalHooks, nodeBase, nodeHelpers, workflowHelpers).ext
isTouchActive: false,
nodeSubtitle: '',
showTriggerNodeTooltip: false,
pinDataDiscoveryTooltipVisible: false,
dragging: false,
unwatchWorkflowDataItems: () => {},
};
},
methods: {
showPinDataDiscoveryTooltip(dataItemsCount: number): void {
if (!this.isTriggerNode) { return; }
if (dataItemsCount > 0) {
localStorage.setItem(LOCAL_STORAGE_PIN_DATA_DISCOVERY_CANVAS_FLAG, 'true');
this.pinDataDiscoveryTooltipVisible = true;
this.unwatchWorkflowDataItems();
}
},
setSubtitle() {
const nodeSubtitle = this.getNodeSubtitle(this.data, this.nodeType, this.getWorkflow()) || '';
@ -368,6 +417,7 @@ export default mixins(externalHooks, nodeBase, nodeHelpers, workflowHelpers).ext
setNodeActive () {
this.$store.commit('setActiveNode', this.data.name);
this.pinDataDiscoveryTooltipVisible = false;
},
touchStart () {
if (this.isTouchDevice === true && this.isMacOs === false && this.isTouchActive === false) {
@ -497,6 +547,15 @@ export default mixins(externalHooks, nodeBase, nodeHelpers, workflowHelpers).ext
}
}
.node-pin-data-icon {
color: var(--color-secondary);
margin-right: 2px;
svg {
height: 0.85rem;
}
}
.waiting {
color: var(--color-secondary);
}

View file

@ -12,7 +12,7 @@
placement="bottom-start"
:value="showTriggerWaitingWarning"
:disabled="!showTriggerWaitingWarning"
:manual="true"
manual
>
<div slot="content" :class="$style.triggerWarning">
{{ $locale.baseText('ndv.backToCanvas.waitingForTriggerWarning') }}
@ -68,6 +68,7 @@
:runIndex="outputRun"
:linkedRuns="linked"
:sessionId="sessionId"
:isReadOnly="readOnly"
@linkRun="onLinkRunToOutput"
@unlinkRun="() => onUnlinkRun('output')"
@runChange="onRunOutputIndexChange"
@ -123,15 +124,20 @@ import TriggerPanel from './TriggerPanel.vue';
import { mapGetters } from 'vuex';
import {
BASE_NODE_SURVEY_URL,
CRON_NODE_TYPE,
ERROR_TRIGGER_NODE_TYPE,
START_NODE_TYPE,
STICKY_NODE_TYPE,
} from '@/constants';
import { editor } from 'monaco-editor';
import { workflowActivate } from './mixins/workflowActivate';
import { pinData } from "@/components/mixins/pinData";
import { dataPinningEventBus } from '../event-bus/data-pinning-event-bus';
export default mixins(externalHooks, nodeHelpers, workflowHelpers, workflowActivate).extend({
export default mixins(
externalHooks,
nodeHelpers,
workflowHelpers,
workflowActivate,
pinData,
).extend({
name: 'NodeDetailsView',
components: {
NodeSettings,
@ -158,10 +164,18 @@ export default mixins(externalHooks, nodeHelpers, workflowHelpers, workflowActiv
triggerWaitingWarningEnabled: false,
isDragging: false,
mainPanelPosition: 0,
pinDataDiscoveryTooltipVisible: false,
};
},
mounted() {
this.$store.commit('ui/setNDVSessionId');
dataPinningEventBus.$on('data-pinning-discovery', ({ isTooltipVisible }: { isTooltipVisible: boolean }) => {
this.pinDataDiscoveryTooltipVisible = isTooltipVisible;
});
},
destroyed () {
dataPinningEventBus.$off('data-pinning-discovery');
},
computed: {
...mapGetters(['executionWaitingForWebhook']),
@ -315,6 +329,9 @@ export default mixins(externalHooks, nodeHelpers, workflowHelpers, workflowActiv
}
return `${BASE_NODE_SURVEY_URL}${this.activeNodeType.name}`;
},
outputPanelEditMode(): { enabled: boolean; value: string; } {
return this.$store.getters['ui/outputPanelEditMode'];
},
},
watch: {
activeNode(node, oldNode) {
@ -331,28 +348,31 @@ export default mixins(externalHooks, nodeHelpers, workflowHelpers, workflowActiv
});
setTimeout(() => {
const outogingConnections = this.$store.getters.outgoingConnectionsByNodeName(
this.activeNode.name,
) as INodeConnections;
if (this.activeNode) {
const outogingConnections = this.$store.getters.outgoingConnectionsByNodeName(
this.activeNode.name,
) as INodeConnections;
this.$telemetry.track('User opened node modal', {
node_type: this.activeNodeType ? this.activeNodeType.name : '',
workflow_id: this.$store.getters.workflowId,
session_id: this.sessionId,
parameters_pane_position: this.mainPanelPosition,
input_first_connector_runs: this.maxInputRun,
output_first_connector_runs: this.maxOutputRun,
selected_view_inputs: this.isTriggerNode
? 'trigger'
: this.$store.getters['ui/inputPanelDispalyMode'],
selected_view_outputs: this.$store.getters['ui/outputPanelDispalyMode'],
input_connectors: this.parentNodes.length,
output_connectors:
outogingConnections && outogingConnections.main && outogingConnections.main.length,
input_displayed_run_index: this.inputRun,
output_displayed_run_index: this.outputRun,
});
}, 0); // wait for display mode to be set correctly
this.$telemetry.track('User opened node modal', {
node_type: this.activeNodeType ? this.activeNodeType.name : '',
workflow_id: this.$store.getters.workflowId,
session_id: this.sessionId,
parameters_pane_position: this.mainPanelPosition,
input_first_connector_runs: this.maxInputRun,
output_first_connector_runs: this.maxOutputRun,
selected_view_inputs: this.isTriggerNode
? 'trigger'
: this.$store.getters['ui/inputPanelDispalyMode'],
selected_view_outputs: this.$store.getters['ui/outputPanelDispalyMode'],
input_connectors: this.parentNodes.length,
output_connectors:
outogingConnections && outogingConnections.main && outogingConnections.main.length,
input_displayed_run_index: this.inputRun,
output_displayed_run_index: this.outputRun,
data_pinning_tooltip_presented: this.pinDataDiscoveryTooltipVisible,
});
}
}, 2000); // wait for RunData to mount and present pindata discovery tooltip
}
if (window.top && !this.isActiveStickyNode) {
window.top.postMessage(JSON.stringify({ command: node ? 'openNDV' : 'closeNDV' }), '*');
@ -440,10 +460,43 @@ export default mixins(externalHooks, nodeHelpers, workflowHelpers, workflowActiv
nodeTypeSelected(nodeTypeName: string) {
this.$emit('nodeTypeSelected', nodeTypeName);
},
close() {
async close() {
if (this.isDragging) {
return;
}
if (this.outputPanelEditMode.enabled) {
const shouldPinDataBeforeClosing = await this.confirmMessage(
'',
this.$locale.baseText('ndv.pinData.beforeClosing.title'),
null,
this.$locale.baseText('ndv.pinData.beforeClosing.confirm'),
this.$locale.baseText('ndv.pinData.beforeClosing.cancel'),
);
if (shouldPinDataBeforeClosing) {
const { value } = this.outputPanelEditMode;
if (!this.isValidPinDataSize(value)) {
dataPinningEventBus.$emit(
'data-pinning-error', { errorType: 'data-too-large', source: 'on-ndv-close-modal' },
);
return;
}
if (!this.isValidPinDataJSON(value)) {
dataPinningEventBus.$emit(
'data-pinning-error', { errorType: 'invalid-json', source: 'on-ndv-close-modal' },
);
return;
}
this.$store.commit('pinData', { node: this.activeNode, data: JSON.parse(value) });
}
this.$store.commit('ui/setOutputPanelEditModeEnabled', false);
}
this.$externalHooks().run('dataDisplay.nodeEditingFinished');
this.$telemetry.track('User closed node modal', {
node_type: this.activeNodeType ? this.activeNodeType.name : '',

View file

@ -4,7 +4,7 @@
<div>
<n8n-button
:loading="nodeRunning && !isListeningForEvents"
:disabled="!!disabledHint"
:disabled="disabled || !!disabledHint"
:label="buttonLabel"
:type="type"
:size="size"
@ -21,14 +21,21 @@ import { INodeUi } from '@/Interface';
import { INodeTypeDescription } from 'n8n-workflow';
import mixins from 'vue-typed-mixins';
import { workflowRun } from './mixins/workflowRun';
import { pinData } from './mixins/pinData';
import { dataPinningEventBus } from '@/event-bus/data-pinning-event-bus';
export default mixins(
workflowRun,
pinData,
).extend({
props: {
nodeName: {
type: String,
},
disabled: {
type: Boolean,
default: false,
},
label: {
type: String,
},
@ -156,14 +163,31 @@ export default mixins(
});
},
onClick() {
async onClick() {
if (this.isListeningForEvents) {
this.stopWaitingForWebhook();
}
else {
this.$telemetry.track('User clicked execute node button', { node_type: this.nodeName, workflow_id: this.$store.getters.workflowId, source: this.telemetrySource });
this.runWorkflow(this.nodeName, 'RunData.ExecuteNodeButton');
this.$emit('execute');
} else {
let shouldUnpinAndExecute = false;
if (this.hasPinData) {
shouldUnpinAndExecute = await this.confirmMessage(
this.$locale.baseText('ndv.pinData.unpinAndExecute.description'),
this.$locale.baseText('ndv.pinData.unpinAndExecute.title'),
null,
this.$locale.baseText('ndv.pinData.unpinAndExecute.confirm'),
this.$locale.baseText('ndv.pinData.unpinAndExecute.cancel'),
);
if (shouldUnpinAndExecute) {
dataPinningEventBus.$emit('data-unpinning', { source: 'unpin-and-execute-modal' });
this.$store.commit('unpinData', { node: this.node });
}
}
if (!this.hasPinData || shouldUnpinAndExecute) {
this.$telemetry.track('User clicked execute node button', { node_type: this.nodeName, workflow_id: this.$store.getters.workflowId, source: this.telemetrySource });
this.runWorkflow(this.nodeName, 'RunData.ExecuteNodeButton');
this.$emit('execute');
}
}
},
},

View file

@ -6,7 +6,13 @@
<div
v-if="!isReadOnly"
>
<NodeExecuteButton v-if="node && nodeValid" :nodeName="node.name" @execute="onNodeExecute" size="small" telemetrySource="parameters"/>
<NodeExecuteButton
:nodeName="node.name"
:disabled="outputPanelEditMode.enabled"
size="small"
telemetrySource="parameters"
@execute="onNodeExecute"
/>
</div>
</div>
<NodeSettingsTabs v-if="node && nodeValid" v-model="openPanel" :nodeType="nodeType" :sessionId="sessionId" />
@ -198,6 +204,9 @@ export default mixins(
return this.nodeType.properties;
},
outputPanelEditMode(): { enabled: boolean; value: string; } {
return this.$store.getters['ui/outputPanelEditMode'];
},
isCommunityNode(): boolean {
return isCommunityPackageName(this.node.type);
},

View file

@ -12,7 +12,7 @@
>{{ $locale.baseText('ndv.title.renameNode') }}</n8n-text>
<n8n-input ref="input" size="small" v-model="newName" />
<div :class="$style.editButtons">
<n8n-button type="outline" size="small" @click="editName = false" :label="$locale.baseText('ndv.title.cancel')" />
<n8n-button type="secondary" size="small" @click="editName = false" :label="$locale.baseText('ndv.title.cancel')" />
<n8n-button type="primary" size="small" @click="onRename" :label="$locale.baseText('ndv.title.rename')" />
</div>
</div>

View file

@ -9,15 +9,23 @@
:isExecuting="isNodeRunning"
:executingMessage="$locale.baseText('ndv.output.executing')"
:sessionId="sessionId"
:isReadOnly="isReadOnly"
paneType="output"
@runChange="onRunIndexChange"
@linkRun="onLinkRun"
@unlinkRun="onUnlinkRun"
ref="runData"
>
<template v-slot:header>
<div :class="$style.titleSection">
<span :class="$style.title">{{ $locale.baseText('ndv.output') }}</span>
<RunInfo v-if="runsCount === 1" :taskData="runTaskData" />
<span :class="$style.title">
{{ $locale.baseText(outputPanelEditMode.enabled ? 'ndv.output.edit' : 'ndv.output') }}
</span>
<RunInfo
v-if="!hasPinData && runsCount === 1"
v-show="!outputPanelEditMode.enabled"
:taskData="runTaskData"
/>
<n8n-info-tip
theme="warning"
@ -26,7 +34,9 @@
v-if="hasNodeRun && staleData"
>
<template>
<span v-html="$locale.baseText('ndv.output.staleDataWarning')"></span>
<span v-html="$locale.baseText(
hasPinData ? 'ndv.output.staleDataWarning.pinData' : 'ndv.output.staleDataWarning.regular'
)"></span>
</template>
</n8n-info-tip>
</div>
@ -34,7 +44,20 @@
<template v-slot:node-not-run>
<n8n-text v-if="workflowRunning && !isTriggerNode">{{ $locale.baseText('ndv.output.waitingToRun') }}</n8n-text>
<n8n-text v-if="!workflowRunning && (isScheduleTrigger || !isTriggerNode)">{{ $locale.baseText('ndv.output.runNodeHint') }}</n8n-text>
<n8n-text v-if="!workflowRunning">
{{ $locale.baseText('ndv.output.runNodeHint') }}
<span @click="insertTestData" v-if="canPinData">
<br>
{{ $locale.baseText('generic.or') }}
<n8n-text
tag="a"
size="medium"
color="primary"
>
{{ $locale.baseText('ndv.output.insertTestData') }}
</n8n-text>
</span>
</n8n-text>
</template>
<template v-slot:no-output-data>
@ -46,7 +69,7 @@
</n8n-text>
</template>
<template #run-info v-if="runsCount > 1">
<template #run-info v-if="!hasPinData && runsCount > 1">
<RunInfo :taskData="runTaskData" />
</template>
</RunData>
@ -56,16 +79,25 @@
import { IExecutionResponse, INodeUi } from '@/Interface';
import { INodeTypeDescription, IRunData, IRunExecutionData, ITaskData } from 'n8n-workflow';
import Vue from 'vue';
import RunData from './RunData.vue';
import RunData, { EnterEditModeArgs } from './RunData.vue';
import RunInfo from './RunInfo.vue';
import { pinData } from "@/components/mixins/pinData";
import mixins from 'vue-typed-mixins';
export default Vue.extend({
type RunData = Vue & { enterEditMode: (args: EnterEditModeArgs) => void };
export default mixins(
pinData,
).extend({
name: 'OutputPanel',
components: { RunData, RunInfo },
props: {
runIndex: {
type: Number,
},
isReadOnly: {
type: Boolean,
},
linkedRuns: {
type: Boolean,
},
@ -162,8 +194,29 @@ export default Vue.extend({
const runAt = this.runTaskData.startTime;
return updatedAt > runAt;
},
outputPanelEditMode(): { enabled: boolean; value: string; } {
return this.$store.getters['ui/outputPanelEditMode'];
},
canPinData(): boolean {
return this.isPinDataNodeType && !this.isReadOnly;
},
},
methods: {
insertTestData() {
if (this.$refs.runData) {
(this.$refs.runData as RunData).enterEditMode({
origin: 'insertTestDataLink',
});
this.$telemetry.track('User clicked ndv link', {
workflow_id: this.$store.getters.workflowId,
session_id: this.sessionId,
node_type: this.node.type,
pane: 'output',
type: 'insert-test-data',
});
}
},
onLinkRun() {
this.$emit('linkRun');
},

View file

@ -1,20 +1,111 @@
<template>
<div :class="$style.container">
<n8n-panel-callout
v-if="canPinData && hasPinData && !editMode.enabled"
theme="secondary"
icon="thumbtack"
:class="$style['pinned-data-callout']"
>
{{ $locale.baseText('runData.pindata.thisDataIsPinned') }}
<span class="ml-4xs" v-if="!isReadOnly">
<n8n-link
theme="secondary"
size="small"
underline
bold
@click="onTogglePinData({ source: 'banner-link' })"
>
{{ $locale.baseText('runData.pindata.unpin') }}
</n8n-link>
</span>
<template #trailingContent>
<n8n-link
:to="dataPinningDocsUrl"
size="small"
theme="secondary"
bold
underline
@click="onClickDataPinningDocsLink"
>
{{ $locale.baseText('runData.pindata.learnMore') }}
</n8n-link>
</template>
</n8n-panel-callout>
<BinaryDataDisplay :windowVisible="binaryDataDisplayVisible" :displayData="binaryDataDisplayData" @close="closeBinaryDataDisplay"/>
<div :class="$style.header">
<slot name="header"></slot>
<div v-show="!hasRunError && hasNodeRun && ((jsonData && jsonData.length > 0) || (binaryData && binaryData.length > 0))" @click.stop :class="$style.displayModes">
<div v-show="!hasRunError" @click.stop :class="$style.displayModes">
<n8n-radio-buttons
v-show="hasNodeRun && ((jsonData && jsonData.length > 0) || (binaryData && binaryData.length > 0)) && !editMode.enabled"
:value="displayMode"
:options="buttons"
@input="onDisplayModeChange"
/>
<n8n-icon-button
v-if="canPinData && !isReadOnly"
v-show="!editMode.enabled"
:title="$locale.baseText('runData.editOutput')"
:circle="false"
:disabled="node.disabled"
class="ml-2xs"
icon="pencil-alt"
type="tertiary"
@click="enterEditMode({ origin: 'editIconButton' })"
/>
<n8n-tooltip
placement="bottom-end"
v-if="canPinData && (jsonData && jsonData.length > 0)"
v-show="!editMode.enabled"
:value="pinDataDiscoveryTooltipVisible"
:manual="isControlledPinDataTooltip"
>
<template #content v-if="!isControlledPinDataTooltip">
<div :class="$style['tooltip-container']">
<strong>{{ $locale.baseText('ndv.pinData.pin.title') }}</strong>
<n8n-text size="small" tag="p">
{{ $locale.baseText('ndv.pinData.pin.description') }}
<n8n-link :to="dataPinningDocsUrl" size="small">
{{ $locale.baseText('ndv.pinData.pin.link') }}
</n8n-link>
</n8n-text>
</div>
</template>
<template #content v-else>
<div :class="$style['tooltip-container']">
{{ $locale.baseText('node.discovery.pinData.ndv') }}
</div>
</template>
<n8n-icon-button
:class="`ml-2xs ${$style['pin-data-button']} ${hasPinData ? $style['pin-data-button-active'] : ''}`"
type="tertiary"
active
icon="thumbtack"
:disabled="editMode.enabled || (inputData.length === 0 && !hasPinData) || isReadOnly"
@click="onTogglePinData({ source: 'pin-icon-click' })"
/>
</n8n-tooltip>
<div :class="$style['edit-mode-actions']" v-show="editMode.enabled">
<n8n-button
type="tertiary"
:label="$locale.baseText('runData.editor.cancel')"
@click="onClickCancelEdit"
/>
<n8n-button
class="ml-2xs"
type="primary"
:label="$locale.baseText('runData.editor.save')"
@click="onClickSaveEdit"
/>
</div>
</div>
</div>
<div :class="$style.runSelector" v-if="maxRunIndex > 0" >
<div :class="$style.runSelector" v-if="maxRunIndex > 0" v-show="!editMode.enabled">
<n8n-select size="small" :value="runIndex" @input="onRunIndexChange" @click.stop>
<template slot="prepend">{{ $locale.baseText('ndv.output.run') }}</template>
<n8n-option v-for="option in (maxRunIndex + 1)" :label="getRunLabel(option)" :value="option - 1" :key="option"></n8n-option>
@ -22,8 +113,8 @@
<n8n-tooltip placement="right" v-if="canLinkRuns" :content="$locale.baseText(linkedRuns ? 'runData.unlinking.hint': 'runData.linking.hint')">
<n8n-icon-button v-if="linkedRuns" icon="unlink" type="text" size="small" @click="unlinkRun" />
<n8n-icon-button v-else icon="link" type="text" size="small" @click="linkRun" />
<n8n-icon-button v-if="linkedRuns" icon="unlink" text size="small" @click="unlinkRun" />
<n8n-icon-button v-else icon="link" text size="small" @click="linkRun" />
</n8n-tooltip>
<slot name="run-info"></slot>
@ -33,28 +124,40 @@
<n8n-tabs :value="currentOutputIndex" @input="onBranchChange" :options="branches" />
</div>
<div v-else-if="hasNodeRun && dataCount > 0 && maxRunIndex === 0" :class="$style.itemsCount">
<div v-else-if="hasNodeRun && dataCount > 0 && maxRunIndex === 0" v-show="!editMode.enabled" :class="$style.itemsCount">
<n8n-text>
{{ dataCount }} {{ $locale.baseText('ndv.output.items', {adjustToNumber: dataCount}) }}
</n8n-text>
</div>
<div :class="$style.dataContainer" ref="dataContainer">
<div v-if="hasNodeRun && !hasRunError && displayMode === 'json' && state.path !== deselectedPlaceholder" :class="$style.copyButton">
<el-dropdown trigger="click" @command="handleCopyClick">
<div
:class="[$style['data-container'], copyDropdownOpen ? $style['copy-dropdown-open'] : '']"
ref="dataContainer"
>
<div v-if="hasNodeRun && !hasRunError && displayMode === 'json'" v-show="!editMode.enabled" :class="$style['actions-group']">
<el-dropdown
trigger="click"
@command="handleCopyClick"
@visible-change="copyDropdownOpen = $event"
>
<span class="el-dropdown-link">
<n8n-icon-button :title="$locale.baseText('runData.copyToClipboard')" icon="copy" />
<n8n-icon-button
:title="$locale.baseText('runData.copyToClipboard')"
icon="copy"
type="tertiary"
:circle="false"
/>
</span>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item :command="{command: 'itemPath'}">
<el-dropdown-item :command="{command: 'value'}">
{{ $locale.baseText('runData.copyValue') }}
</el-dropdown-item>
<el-dropdown-item :command="{command: 'itemPath'}" divided>
{{ $locale.baseText('runData.copyItemPath') }}
</el-dropdown-item>
<el-dropdown-item :command="{command: 'parameterPath'}">
{{ $locale.baseText('runData.copyParameterPath') }}
</el-dropdown-item>
<el-dropdown-item :command="{command: 'value'}">
{{ $locale.baseText('runData.copyValue') }}
</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
</div>
@ -64,10 +167,38 @@
<n8n-text>{{ executingMessage }}</n8n-text>
</div>
<div v-else-if="editMode.enabled" :class="$style['edit-mode']">
<div :class="$style['edit-mode-body']">
<code-editor
:value="editMode.value"
:options="{ scrollBeyondLastLine: false }"
type="json"
@input="$store.commit('ui/setOutputPanelEditModeValue', $event)"
/>
</div>
<div :class="$style['edit-mode-footer']">
<n8n-info-tip :bold="false" :class="$style['edit-mode-footer-infotip']">
{{ $locale.baseText('runData.editor.copyDataInfo') }}
<n8n-link :to="dataPinningDocsUrl" size="small">
{{ $locale.baseText('generic.learnMore') }}
</n8n-link>
</n8n-info-tip>
</div>
</div>
<div v-else-if="!hasNodeRun" :class="$style.center">
<slot name="node-not-run"></slot>
</div>
<div v-else-if="paneType === 'input' && node.disabled" :class="$style.center">
<n8n-text>
{{ $locale.baseText('ndv.input.disabled', { interpolate: { nodeName: node.name } }) }}
<n8n-link @click="enableNode">
{{ $locale.baseText('ndv.input.disabled.cta') }}
</n8n-link>
</n8n-text>
</div>
<div v-else-if="hasNodeRun && hasRunError" :class="$style.errorDisplay">
<NodeErrorView :error="workflowRunData[node.name][runIndex].error" />
</div>
@ -87,7 +218,7 @@
<n8n-text align="center" tag="div"><span v-html="$locale.baseText('ndv.output.tooMuchData.message', { interpolate: {size: dataSizeInMB }})"></span></n8n-text>
<n8n-button
type="outline"
outline
:label="$locale.baseText('ndv.output.tooMuchData.showDataAnyway')"
@click="showTooMuchData"
/>
@ -110,7 +241,7 @@
<vue-json-pretty
:data="jsonData"
:deep="10"
v-model="state.path"
v-model="selectedOutput.path"
:showLine="true"
:showLength="true"
selectableType="single"
@ -159,7 +290,7 @@
<div :class="$style.binaryButtonContainer">
<n8n-button size="small" :label="$locale.baseText('runData.showBinaryData')" class="binary-data-show-data-button" @click="displayBinaryData(index, key)" />
<n8n-button v-if="isDownloadable(index, key)" size="small" type="outline" :label="$locale.baseText('runData.downloadBinaryData')" class="binary-data-show-data-button" @click="downloadBinaryData(index, key)" />
<n8n-button v-if="isDownloadable(index, key)" size="small" type="secondary" :label="$locale.baseText('runData.downloadBinaryData')" class="binary-data-show-data-button" @click="downloadBinaryData(index, key)" />
</div>
</div>
</div>
@ -167,7 +298,7 @@
</div>
</div>
</div>
<div :class="$style.pagination" v-if="hasNodeRun && !hasRunError && dataCount > pageSize">
<div :class="$style.pagination" v-if="hasNodeRun && !hasRunError && dataCount > pageSize" v-show="!editMode.enabled">
<el-pagination
background
:hide-on-single-page="true"
@ -212,7 +343,7 @@ import {
INodeTypeDescription,
IRunData,
IRunExecutionData,
ITaskData,
PinData,
} from 'n8n-workflow';
import {
@ -225,8 +356,12 @@ import {
} from '@/Interface';
import {
DATA_PINNING_DOCS_URL,
LOCAL_STORAGE_PIN_DATA_DISCOVERY_NDV_FLAG,
LOCAL_STORAGE_PIN_DATA_DISCOVERY_CANVAS_FLAG,
MAX_DISPLAY_DATA_SIZE,
MAX_DISPLAY_ITEMS_AUTO_ALL,
TEST_PIN_DATA,
} from '@/constants';
import BinaryDataDisplay from '@/components/BinaryDataDisplay.vue';
@ -237,20 +372,29 @@ import { copyPaste } from '@/components/mixins/copyPaste';
import { externalHooks } from "@/components/mixins/externalHooks";
import { genericHelpers } from '@/components/mixins/genericHelpers';
import { nodeHelpers } from '@/components/mixins/nodeHelpers';
import { pinData } from '@/components/mixins/pinData';
import mixins from 'vue-typed-mixins';
import { saveAs } from 'file-saver';
import { CodeEditor } from "@/components/forms";
import { dataPinningEventBus } from '../event-bus/data-pinning-event-bus';
import { stringSizeInBytes } from './helpers';
import RunDataTable from './RunDataTable.vue';
// A path that does not exist so that nothing is selected by default
const deselectedPlaceholder = '_!^&*';
export type EnterEditModeArgs = {
origin: 'editIconButton' | 'insertTestDataLink',
};
export default mixins(
copyPaste,
externalHooks,
genericHelpers,
nodeHelpers,
pinData,
)
.extend({
name: 'RunData',
@ -259,6 +403,7 @@ export default mixins(
NodeErrorView,
VueJsonPretty,
WarningTooltip,
CodeEditor,
RunDataTable,
},
props: {
@ -309,7 +454,7 @@ export default mixins(
binaryDataPreviewActive: false,
dataSize: 0,
deselectedPlaceholder,
state: {
selectedOutput: {
value: '' as object | number | string,
path: deselectedPlaceholder,
},
@ -323,15 +468,38 @@ export default mixins(
currentPage: 1,
pageSize: 10,
pageSizes: [10, 25, 50, 100],
copyDropdownOpen: false,
eventBus: dataPinningEventBus,
pinDataDiscoveryTooltipVisible: false,
isControlledPinDataTooltip: false,
};
},
mounted() {
this.init();
if (this.paneType === 'output') {
this.eventBus.$on('data-pinning-error', this.onDataPinningError);
this.eventBus.$on('data-unpinning', this.onDataUnpinning);
const hasSeenPinDataTooltip = localStorage.getItem(LOCAL_STORAGE_PIN_DATA_DISCOVERY_NDV_FLAG);
if (!hasSeenPinDataTooltip) {
this.showPinDataDiscoveryTooltip(this.jsonData);
}
}
},
destroyed() {
this.hidePinDataDiscoveryTooltip();
this.eventBus.$off('data-pinning-error', this.onDataPinningError);
this.eventBus.$off('data-unpinning', this.onDataUnpinning);
},
computed: {
activeNode(): INodeUi {
return this.$store.getters.activeNode;
},
dataPinningDocsUrl(): string {
return DATA_PINNING_DOCS_URL;
},
displayMode(): IRunDataDisplayMode {
return this.$store.getters['ui/getPanelDisplayMode'](this.paneType);
},
@ -344,6 +512,14 @@ export default mixins(
}
return null;
},
isTriggerNode (): boolean {
return !!(this.nodeType && this.nodeType.group.includes('trigger'));
},
canPinData (): boolean {
return this.paneType === 'output' &&
this.isPinDataNodeType &&
!(this.binaryData && this.binaryData.length > 0);
},
buttons(): Array<{label: string, value: string}> {
const defaults = [
{ label: this.$locale.baseText('runData.table'), value: 'table'},
@ -358,7 +534,7 @@ export default mixins(
return defaults;
},
hasNodeRun(): boolean {
return Boolean(!this.isExecuting && this.node && this.workflowRunData && this.workflowRunData.hasOwnProperty(this.node.name));
return Boolean(!this.isExecuting && this.node && (this.workflowRunData && this.workflowRunData.hasOwnProperty(this.node.name) || this.hasPinData));
},
hasRunError(): boolean {
return Boolean(this.node && this.workflowRunData && this.workflowRunData[this.node.name] && this.workflowRunData[this.node.name][this.runIndex] && this.workflowRunData[this.node.name][this.runIndex].error);
@ -423,12 +599,32 @@ export default mixins(
return 0;
},
inputData (): INodeExecutionData[] {
let inputData = this.getNodeInputData(this.node, this.runIndex, this.currentOutputIndex);
rawInputData (): INodeExecutionData[] {
let inputData: INodeExecutionData[] = [];
if (this.node) {
inputData = this.getNodeInputData(this.node, this.runIndex, this.currentOutputIndex);
}
if (inputData.length === 0 || !Array.isArray(inputData)) {
return [];
}
return inputData;
},
inputData (): INodeExecutionData[] {
let inputData = this.rawInputData;
if (this.node && this.pinData) {
inputData = Array.isArray(this.pinData)
? this.pinData.map((value) => ({
json: value,
}))
: [{
json: this.pinData,
}];
}
const offset = this.pageSize * (this.currentPage - 1);
inputData = inputData.slice(offset, offset + this.pageSize);
@ -480,8 +676,201 @@ export default mixins(
}
return branches;
},
editMode(): { enabled: boolean; value: string; } {
return this.paneType === 'output'
? this.$store.getters['ui/outputPanelEditMode']
: { enabled: false, value: '' };
},
},
methods: {
onClickDataPinningDocsLink() {
this.$telemetry.track('User clicked ndv link', {
workflow_id: this.$store.getters.workflowId,
session_id: this.sessionId,
node_type: this.activeNode.type,
pane: 'output',
type: 'data-pinning-docs',
});
},
showPinDataDiscoveryTooltip(value: IDataObject[]) {
if (!this.isTriggerNode) {
return;
}
if (value && value.length > 0) {
this.pinDataDiscoveryComplete();
setTimeout(() => {
this.isControlledPinDataTooltip = true;
this.pinDataDiscoveryTooltipVisible = true;
this.eventBus.$emit('data-pinning-discovery', { isTooltipVisible: true });
}, 500); // Wait for NDV to open
}
},
hidePinDataDiscoveryTooltip() {
if (this.pinDataDiscoveryTooltipVisible) {
this.isControlledPinDataTooltip = false;
this.pinDataDiscoveryTooltipVisible = false;
this.eventBus.$emit('data-pinning-discovery', { isTooltipVisible: false });
}
},
pinDataDiscoveryComplete() {
localStorage.setItem(LOCAL_STORAGE_PIN_DATA_DISCOVERY_NDV_FLAG, 'true');
localStorage.setItem(LOCAL_STORAGE_PIN_DATA_DISCOVERY_CANVAS_FLAG, 'true');
},
enterEditMode({ origin }: EnterEditModeArgs) {
const inputData = this.pinData ? this.pinData : this.convertToJson(this.rawInputData);
const data = inputData.length > 0
? inputData
: TEST_PIN_DATA;
this.$store.commit('ui/setOutputPanelEditModeEnabled', true);
this.$store.commit('ui/setOutputPanelEditModeValue', JSON.stringify(data, null, 2));
this.$telemetry.track('User opened ndv edit state', {
node_type: this.activeNode.type,
click_type: origin === 'editIconButton' ? 'button' : 'link',
session_id: this.sessionId,
run_index: this.runIndex,
is_output_present: this.hasNodeRun || this.hasPinData,
view: !this.hasNodeRun && !this.hasPinData ? 'undefined' : this.displayMode,
is_data_pinned: this.hasPinData,
});
},
onClickCancelEdit() {
this.$store.commit('ui/setOutputPanelEditModeEnabled', false);
this.$store.commit('ui/setOutputPanelEditModeValue', '');
this.onExitEditMode({ type: 'cancel' });
},
onClickSaveEdit() {
const { value } = this.editMode;
this.clearAllStickyNotifications();
if (!this.isValidPinDataSize(value)) {
this.onDataPinningError({ errorType: 'data-too-large', source: 'save-edit' });
return;
}
if (!this.isValidPinDataJSON(value)) {
this.onDataPinningError({ errorType: 'invalid-json', source: 'save-edit' });
return;
}
this.$store.commit('ui/setOutputPanelEditModeEnabled', false);
this.$store.commit('pinData', { node: this.node, data: this.removeJsonKeys(value) });
this.onDataPinningSuccess({ source: 'save-edit' });
this.onExitEditMode({ type: 'save' });
},
removeJsonKeys(value: string) {
const parsed = JSON.parse(value);
return Array.isArray(parsed)
? parsed.map(item => this.isJsonKeyObject(item) ? item.json : item)
: parsed;
},
isJsonKeyObject(item: unknown): item is { json: unknown } {
if (!this.isObjectLiteral(item)) return false;
const keys = Object.keys(item);
return keys.length === 1 && keys[0] === 'json';
},
onExitEditMode({ type }: { type: 'save' | 'cancel' }) {
this.$telemetry.track('User closed ndv edit state', {
node_type: this.activeNode.type,
session_id: this.sessionId,
run_index: this.runIndex,
view: this.displayMode,
type,
});
},
onDataUnpinning(
{ source }: { source: 'banner-link' | 'pin-icon-click' | 'unpin-and-execute-modal' },
) {
this.$telemetry.track('User unpinned ndv data', {
node_type: this.activeNode.type,
session_id: this.sessionId,
run_index: this.runIndex,
source,
data_size: stringSizeInBytes(this.pinData),
});
},
onDataPinningSuccess({ source }: { source: 'pin-icon-click' | 'save-edit' }) {
this.$telemetry.track('Ndv data pinning success', {
pinning_source: source,
node_type: this.activeNode.type,
session_id: this.sessionId,
data_size: stringSizeInBytes(this.pinData),
view: this.displayMode,
run_index: this.runIndex,
});
},
onDataPinningError(
{ errorType, source }: {
errorType: 'data-too-large' | 'invalid-json',
source: 'on-ndv-close-modal' | 'pin-icon-click' | 'save-edit'
},
) {
this.$telemetry.track('Ndv data pinning failure', {
pinning_source: source,
node_type: this.activeNode.type,
session_id: this.sessionId,
data_size: stringSizeInBytes(this.pinData),
view: this.displayMode,
run_index: this.runIndex,
error_type: errorType,
});
},
async onTogglePinData(
{ source }: { source: 'banner-link' | 'pin-icon-click' | 'unpin-and-execute-modal' },
) {
if (source === 'pin-icon-click') {
this.$telemetry.track('User clicked pin data icon', {
node_type: this.activeNode.type,
session_id: this.sessionId,
run_index: this.runIndex,
view: !this.hasNodeRun && !this.hasPinData ? 'none' : this.displayMode,
});
}
this.updateNodeParameterIssues(this.node);
if (this.hasPinData) {
this.onDataUnpinning({ source });
this.$store.commit('unpinData', { node: this.node });
return;
}
const data = this.convertToJson(this.rawInputData);
if (!this.isValidPinDataSize(data)) {
this.onDataPinningError({ errorType: 'data-too-large', source: 'pin-icon-click' });
return;
}
this.onDataPinningSuccess({ source: 'save-edit' });
this.$store.commit('pinData', { node: this.node, data });
if (this.maxRunIndex > 0) {
this.$showToast({
title: this.$locale.baseText('ndv.pinData.pin.multipleRuns.title', {
interpolate: {
index: `${this.runIndex}`,
},
}),
message: this.$locale.baseText('ndv.pinData.pin.multipleRuns.description'),
type: 'success',
duration: 2000,
});
}
this.hidePinDataDiscoveryTooltip();
this.pinDataDiscoveryComplete();
},
switchToBinary() {
this.onDisplayModeChange('binary');
},
@ -690,7 +1079,7 @@ export default mixins(
this.updateNodesExecutionIssues();
},
dataItemClicked (path: string, data: object | number | string) {
this.state.value = data;
this.selectedOutput.value = data;
},
isDownloadable (index: number, key: string): boolean {
const binaryDataItem: IBinaryData = this.binaryData[index][key];
@ -771,15 +1160,34 @@ export default mixins(
return '["' + allParts.join('"]["') + '"]';
},
handleCopyClick (commandData: { command: string }) {
const newPath = this.convertPath(this.state.path);
const isNotSelected = this.selectedOutput.path === deselectedPlaceholder;
const selectedPath = isNotSelected ? '[""]' : this.selectedOutput.path;
let selectedValue = this.selectedOutput.value;
if (isNotSelected) {
if (this.hasPinData) {
selectedValue = this.pinData as object;
} else {
selectedValue = this.convertToJson(this.getNodeInputData(this.node, this.runIndex, this.currentOutputIndex));
}
}
const newPath = this.convertPath(selectedPath);
let value: string;
if (commandData.command === 'value') {
if (typeof this.state.value === 'object') {
value = JSON.stringify(this.state.value, null, 2);
if (typeof selectedValue === 'object') {
value = JSON.stringify(selectedValue, null, 2);
} else {
value = this.state.value.toString();
value = selectedValue.toString();
}
this.$showToast({
title: this.$locale.baseText('runData.copyValue.toast'),
message: '',
type: 'success',
duration: 2000,
});
} else {
let startPath = '';
let path = '';
@ -788,9 +1196,23 @@ export default mixins(
const index = pathParts[0].slice(1);
path = pathParts.slice(1).join(']');
startPath = `$item(${index}).$node["${this.node!.name}"].json`;
this.$showToast({
title: this.$locale.baseText('runData.copyItemPath.toast'),
message: '',
type: 'success',
duration: 2000,
});
} else if (commandData.command === 'parameterPath') {
path = newPath.split(']').slice(1).join(']');
startPath = `$node["${this.node!.name}"].json`;
this.$showToast({
title: this.$locale.baseText('runData.copyParameterPath.toast'),
message: '',
type: 'success',
duration: 2000,
});
}
if (!path.startsWith('[') && !path.startsWith('.') && path) {
path += '.';
@ -798,6 +1220,23 @@ export default mixins(
value = `{{ ${startPath + path} }}`;
}
const copyType = {
value: 'selection',
itemPath: 'item_path',
parameterPath: 'parameter_path',
}[commandData.command];
this.$telemetry.track('User copied ndv data', {
node_type: this.activeNode.type,
session_id: this.sessionId,
run_index: this.runIndex,
view: this.displayMode,
copy_type: copyType,
workflow_id: this.$store.getters.workflowId,
pane: 'output',
in_execution_log: this.isReadOnly,
});
this.copyToClipboard(value);
},
refreshDataSize () {
@ -820,13 +1259,30 @@ export default mixins(
onRunIndexChange(run: number) {
this.$emit('runChange', run);
},
enableNode() {
if (this.node) {
const updateInformation = {
name: this.node.name,
properties: {
disabled: !this.node.disabled,
},
};
this.$store.commit('updateNodeProperties', updateInformation);
}
},
},
watch: {
node() {
this.init();
},
jsonData () {
jsonData (value: IDataObject[]) {
this.refreshDataSize();
const hasSeenPinDataTooltip = localStorage.getItem(LOCAL_STORAGE_PIN_DATA_DISCOVERY_NDV_FLAG);
if (!hasSeenPinDataTooltip) {
this.showPinDataDiscoveryTooltip(value);
}
},
binaryData (newData: IBinaryKeyData[], prevData: IBinaryKeyData[]) {
if (newData.length && !prevData.length && this.displayMode !== 'binary') {
@ -869,22 +1325,36 @@ export default mixins(
flex-direction: column;
}
.pinned-data-callout {
border-radius: inherit;
border-bottom-right-radius: 0;
border-top: 0;
border-left: 0;
border-right: 0;
}
.header {
display: flex;
align-items: center;
margin-bottom: var(--spacing-s);
padding: var(--spacing-s) var(--spacing-s) 0 var(--spacing-s);
position: relative;
height: 30px;
> *:first-child {
flex-grow: 1;
}
}
.dataContainer {
.data-container {
position: relative;
height: 100%;
&:hover,
&.copy-dropdown-open {
.actions-group {
opacity: 1;
}
}
}
.dataDisplay {
@ -921,7 +1391,7 @@ export default mixins(
}
.runSelector {
max-width: 200px;
max-width: 210px;
margin-left: var(--spacing-s);
margin-bottom: var(--spacing-s);
display: flex;
@ -931,12 +1401,13 @@ export default mixins(
}
}
.copyButton {
height: 30px;
top: 12px;
right: 24px;
.actions-group {
position: absolute;
z-index: 10;
top: 12px;
right: var(--spacing-l);
opacity: 0;
transition: opacity 0.3s ease;
}
.pagination {
@ -1021,6 +1492,27 @@ export default mixins(
flex-grow: 1;
}
.tooltip-container {
max-width: 240px;
}
.pin-data-button {
svg {
transition: transform 0.3s ease;
}
}
.pin-data-button-active {
&,
&:hover,
&:focus,
&:active {
border-color: var(--color-primary);
color: var(--color-primary);
background: var(--color-primary-tint-2);
}
}
.spinner {
* {
color: var(--color-primary);
@ -1033,6 +1525,43 @@ export default mixins(
margin-bottom: var(--spacing-s);
}
.edit-mode {
height: calc(100% - var(--spacing-s));
display: flex;
flex-direction: column;
justify-content: flex-end;
align-items: flex-end;
padding-left: var(--spacing-s);
padding-right: var(--spacing-s);
}
.edit-mode-body {
flex: 1 1 auto;
width: 100%;
height: 100%;
overflow: hidden;
}
.edit-mode-footer {
display: flex;
width: 100%;
justify-content: space-between;
align-items: center;
padding-top: var(--spacing-s);
}
.edit-mode-footer-infotip {
display: flex;
flex: 1;
width: 100%;
}
.edit-mode-actions {
display: flex;
justify-content: flex-end;
align-items: center;
margin-left: var(--spacing-s);
}
</style>
<style lang="scss">
@ -1075,5 +1604,4 @@ export default mixins(
.vjs-tree .vjs-tree__content.has-line {
border-left: 1px dotted var(--color-json-line);
}
</style>

View file

@ -49,6 +49,7 @@ export default Vue.extend({
<style lang="scss" module>
.container {
width: 65px;
display: inline-flex;
}
.saved {

View file

@ -44,15 +44,15 @@
<template slot-scope="scope">
<transition name="fade" mode="out-in">
<div class="ops" v-if="scope.row.create">
<n8n-button :label="$locale.baseText('tagsTable.cancel')" @click.stop="cancel" type="outline" :disabled="isSaving" />
<n8n-button :label="$locale.baseText('tagsTable.cancel')" @click.stop="cancel" type="secondary" :disabled="isSaving" />
<n8n-button :label="$locale.baseText('tagsTable.createTag')" @click.stop="apply" :loading="isSaving" />
</div>
<div class="ops" v-else-if="scope.row.update">
<n8n-button :label="$locale.baseText('tagsTable.cancel')" @click.stop="cancel" type="outline" :disabled="isSaving" />
<n8n-button :label="$locale.baseText('tagsTable.cancel')" @click.stop="cancel" type="secondary" :disabled="isSaving" />
<n8n-button :label="$locale.baseText('tagsTable.saveChanges')" @click.stop="apply" :loading="isSaving" />
</div>
<div class="ops" v-else-if="scope.row.delete">
<n8n-button :label="$locale.baseText('tagsTable.cancel')" @click.stop="cancel" type="outline" :disabled="isSaving" />
<n8n-button :label="$locale.baseText('tagsTable.cancel')" @click.stop="cancel" type="secondary" :disabled="isSaving" />
<n8n-button :label="$locale.baseText('tagsTable.deleteTag')" @click.stop="apply" :loading="isSaving" />
</div>
<div class="ops main" v-else-if="!scope.row.disable">

View file

@ -29,7 +29,7 @@
<div :class="$style.buttonContainer" v-if="useWorkflowButton">
<n8n-button
v-if="useWorkflowButton"
type="outline"
outline
label="Use workflow"
@click.stop="onUseWorkflowClick"
/>

View file

@ -10,8 +10,8 @@
<n8n-text tag="div" size="large" color="text-dark" class="mb-2xs" bold>{{
$locale.baseText('ndv.trigger.webhookNode.listening')
}}</n8n-text>
<div :class="$style.shake">
<n8n-text class="mb-xs">
<div :class="[$style.shake, 'mb-xs']">
<n8n-text>
{{
$locale.baseText('ndv.trigger.webhookNode.requestHint', {
interpolate: { type: this.webhookHttpMethod },
@ -33,8 +33,8 @@
<n8n-text tag="div" size="large" color="text-dark" class="mb-2xs" bold>{{
$locale.baseText('ndv.trigger.webhookBasedNode.listening')
}}</n8n-text>
<div :class="$style.shake">
<n8n-text class="mb-xs" tag="div">
<div :class="[$style.shake, 'mb-xs']">
<n8n-text tag="div">
{{
$locale.baseText('ndv.trigger.webhookBasedNode.serviceHint', {
interpolate: { service: serviceName },

View file

@ -20,9 +20,11 @@ import {
GenericValue,
IContextObject,
IDataObject,
INodeExecutionData,
IRunData,
IRunExecutionData,
IWorkflowDataProxyAdditionalKeys,
PinData,
Workflow,
WorkflowDataProxy,
} from 'n8n-workflow';
@ -248,19 +250,20 @@ export default mixins(
},
/**
* Returns the data the a node does output
* Get the node's output using runData
*
* @param {IRunData} runData The data of the run to get the data of
* @param {string} nodeName The name of the node to get the data of
* @param {IRunData} runData The data of the run to get the data of
* @param {string} filterText Filter text for parameters
* @param {number} [itemIndex=0] The index of the item
* @param {number} [runIndex=0] The index of the run
* @param {string} [inputName='main'] The name of the input
* @param {number} [outputIndex=0] The index of the output
* @param {boolean} [useShort=false] Use short notation $json vs. $node[NodeName].json
* @returns
* @memberof Workflow
*/
getNodeOutputData (runData: IRunData, nodeName: string, filterText: string, itemIndex = 0, runIndex = 0, inputName = 'main', outputIndex = 0, useShort = false): IVariableSelectorOption[] | null {
getNodeRunDataOutput(nodeName: string, runData: IRunData, filterText: string, itemIndex = 0, runIndex = 0, inputName = 'main', outputIndex = 0, useShort = false): IVariableSelectorOption[] | null {
if (!runData.hasOwnProperty(nodeName)) {
// No data found for node
return null;
@ -297,6 +300,32 @@ export default mixins(
const outputData = runData[nodeName][runIndex].data![inputName][outputIndex]![itemIndex];
return this.getNodeOutput(nodeName, outputData, filterText, useShort);
},
/**
* Get the node's output using pinData
*
* @param {string} nodeName The name of the node to get the data of
* @param {PinData[string]} pinData The node's pin data
* @param {string} filterText Filter text for parameters
* @param {boolean} [useShort=false] Use short notation $json vs. $node[NodeName].json
*/
getNodePinDataOutput(nodeName: string, pinData: PinData[string], filterText: string, useShort = false): IVariableSelectorOption[] | null {
const outputData = pinData.map((data) => ({ json: data } as INodeExecutionData))[0];
return this.getNodeOutput(nodeName, outputData, filterText, useShort);
},
/**
* Returns the node's output data
*
* @param {string} nodeName The name of the node to get the data of
* @param {INodeExecutionData} outputData The data of the run to get the data of
* @param {string} filterText Filter text for parameters
* @param {boolean} [useShort=false] Use short notation
*/
getNodeOutput (nodeName: string, outputData: INodeExecutionData, filterText: string, useShort = false): IVariableSelectorOption[] | null {
const returnData: IVariableSelectorOption[] = [];
// Get json data
@ -491,7 +520,7 @@ export default mixins(
}
}
let tempOutputData;
let tempOutputData: IVariableSelectorOption[] | null | undefined;
if (parentNode.length) {
// If the node has an input node add the input data
@ -502,7 +531,50 @@ export default mixins(
const nodeConnection = this.workflow.getNodeConnectionIndexes(activeNode.name, parentNode[0], 'main');
const outputIndex = nodeConnection === undefined ? 0: nodeConnection.sourceIndex;
tempOutputData = this.getNodeOutputData(runData, parentNode[0], filterText, itemIndex, 0, 'main', outputIndex, true) as IVariableSelectorOption[];
tempOutputData = this.getNodeRunDataOutput(parentNode[0], runData, filterText, itemIndex, 0, 'main', outputIndex, true) as IVariableSelectorOption[];
const pinDataOptions: IVariableSelectorOption[] = [
{
name: 'JSON',
options: [],
},
];
parentNode.forEach((parentNodeName) => {
const pinData = this.$store.getters['pinDataByNodeName'](parentNodeName);
if (pinData) {
const output = this.getNodePinDataOutput(parentNodeName, pinData, filterText, true);
pinDataOptions[0].options = pinDataOptions[0].options!.concat(
output && output[0].options ? output[0].options : [],
);
}
});
if (pinDataOptions[0].options!.length > 0) {
if (tempOutputData) {
const jsonTempOutputData = tempOutputData.find((tempData) => tempData.name === 'JSON');
if (jsonTempOutputData) {
if (!jsonTempOutputData.options) {
jsonTempOutputData.options = [];
}
(pinDataOptions[0].options || []).forEach((pinDataOption) => {
const existingOptionIndex = jsonTempOutputData.options!.findIndex((option) => option.name === pinDataOption.name);
if (existingOptionIndex !== -1) {
jsonTempOutputData.options![existingOptionIndex] = pinDataOption;
} else {
jsonTempOutputData.options!.push(pinDataOption);
}
});
} else {
tempOutputData.push(pinDataOptions[0]);
}
} else {
tempOutputData = pinDataOptions;
}
}
if (tempOutputData) {
if (JSON.stringify(tempOutputData).length < 102400) {
@ -602,7 +674,11 @@ export default mixins(
if (upstreamNodes.includes(nodeName)) {
// If the node is an upstream node add also the output data which can be referenced
tempOutputData = this.getNodeOutputData(runData, nodeName, filterText, itemIndex);
const pinData = this.$store.getters['pinDataByNodeName'](nodeName);
tempOutputData = pinData
? this.getNodePinDataOutput(nodeName, pinData, filterText)
: this.getNodeRunDataOutput(nodeName, runData, filterText, itemIndex);
if (tempOutputData) {
nodeOptions.push(
{

View file

@ -0,0 +1,136 @@
<template>
<div ref="code" class="text-editor" @keydown.stop />
</template>
<script lang="ts">
import Vue from 'vue';
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
export default Vue.extend({
props: {
type: {
type: String,
default: 'code',
},
readonly: {
type: Boolean,
default: false,
},
value: {
type: String,
default: '',
},
autocomplete: {
type: Function,
},
options: {
type: Object,
default: () => ({}),
},
},
data() {
return {
monacoInstance: null as monaco.editor.IStandaloneCodeEditor | null,
monacoLibrary: null as monaco.IDisposable | null,
};
},
methods: {
loadEditor() {
if (!this.$refs.code) return;
this.monacoInstance = monaco.editor.create(this.$refs.code as HTMLElement, {
automaticLayout: true,
value: this.value,
language: this.type === 'code' ? 'javascript' : 'json',
tabSize: 2,
wordBasedSuggestions: false,
readOnly: this.readonly,
padding: {
top: 16,
},
minimap: {
enabled: false,
},
...this.options,
});
this.monacoInstance.onDidChangeModelContent(() => {
const model = this.monacoInstance!.getModel();
if (model) {
this.$emit('input', model.getValue());
}
});
monaco.editor.defineTheme('n8nCustomTheme', {
base: 'vs',
inherit: true,
rules: [],
colors: {
'editor.background': '#f5f2f0',
},
});
monaco.editor.setTheme('n8nCustomTheme');
if (this.type === 'code') {
// As wordBasedSuggestions: false does not have any effect does it however seem
// to remove all all suggestions from the editor if I do this
monaco.languages.typescript.javascriptDefaults.setCompilerOptions({
allowNonTsExtensions: true,
});
if (this.autocomplete) {
this.monacoLibrary = monaco.languages.typescript.javascriptDefaults.addExtraLib(
this.autocomplete().join('\n'),
);
}
} else if (this.type === 'json') {
monaco.languages.json.jsonDefaults.setDiagnosticsOptions({
validate: true,
});
}
},
handleResize() {
if (this.monacoInstance) {
// Workaround to force Monaco to recompute its boundaries
this.monacoInstance.layout({} as unknown as undefined);
}
},
},
mounted() {
setTimeout(this.loadEditor);
window.addEventListener('resize', this.handleResize);
},
destroyed() {
if (this.monacoLibrary) {
this.monacoLibrary.dispose();
}
window.removeEventListener('resize', this.handleResize);
},
});
</script>
<style lang="scss" scoped>
.text-editor {
width: 100%;
height: 100%;
flex: 1 1 auto;
border: 1px solid var(--color-foreground-base);
border-radius: var(--border-radius-base);
}
::v-deep {
.monaco-editor {
.slider {
border-radius: var(--border-radius-base);
}
&,
&-background,
.inputarea.ime-input,
.margin {
border-radius: var(--border-radius-base);
background-color: var(--color-background-xlight) !important;
}
}
}
</style>

View file

@ -0,0 +1 @@
export { default as CodeEditor } from './CodeEditor.vue';

View file

@ -1,7 +1,7 @@
import { CORE_NODES_CATEGORY, ERROR_TRIGGER_NODE_TYPE, MAPPING_PARAMS, TEMPLATES_NODES_FILTER } from '@/constants';
import { INodeUi, ITemplatesNode } from '@/Interface';
import dateformat from 'dateformat';
import { INodeTypeDescription } from 'n8n-workflow';
import {IDataObject, INodeTypeDescription} from 'n8n-workflow';
const KEYWORDS_TO_FILTER = ['API', 'OAuth1', 'OAuth2'];
const SI_SYMBOL = ['', 'k', 'M', 'G', 'T', 'P', 'E'];
@ -71,6 +71,12 @@ export function isNumber(value: unknown): value is number {
return typeof value === 'number';
}
export function stringSizeInBytes(input: string | IDataObject | IDataObject[] | undefined): number {
if (input === undefined) return 0;
return new Blob([typeof input === 'string' ? input : JSON.stringify(input)]).size;
}
export function isCommunityPackageName(packageName: string): boolean {
COMMUNITY_PACKAGE_NAME_REGEX.lastIndex = 0;
// Community packages names start with <@username/>n8n-nodes- not followed by word 'base'

View file

@ -47,10 +47,14 @@ export const nodeHelpers = mixins(
return Object.keys(node.parameters).includes('nodeCredentialType');
},
isObjectLiteral(maybeObject: unknown): maybeObject is { [key: string]: string } {
return typeof maybeObject === 'object' && maybeObject !== null && !Array.isArray(maybeObject);
},
isCustomApiCallSelected (nodeValues: INodeParameters): boolean {
const { parameters } = nodeValues;
if (!isObjectLiteral(parameters)) return false;
if (!this.isObjectLiteral(parameters)) return false;
return (
parameters.resource !== undefined && parameters.resource.includes(CUSTOM_API_CALL_KEY) ||
@ -73,11 +77,13 @@ export const nodeHelpers = mixins(
// Returns all the issues of the node
getNodeIssues (nodeType: INodeTypeDescription | null, node: INodeUi, ignoreIssues?: string[]): INodeIssues | null {
const pinDataNodeNames = Object.keys(this.$store.getters.pinData || {});
let nodeIssues: INodeIssues | null = null;
ignoreIssues = ignoreIssues || [];
if (node.disabled === true) {
// Ignore issues on disabled nodes
if (node.disabled === true || pinDataNodeNames.includes(node.name)) {
// Ignore issues on disabled and pindata nodes
return null;
}
@ -510,7 +516,3 @@ declare namespace HttpRequestNode {
};
}
}
function isObjectLiteral(maybeObject: unknown): maybeObject is { [key: string]: string } {
return typeof maybeObject === 'object' && maybeObject !== null && !Array.isArray(maybeObject);
}

View file

@ -0,0 +1,85 @@
import Vue from 'vue';
import { INodeUi } from "@/Interface";
import {IDataObject, PinData} from "n8n-workflow";
import {stringSizeInBytes} from "@/components/helpers";
import {MAX_WORKFLOW_PINNED_DATA_SIZE, PIN_DATA_NODE_TYPES_DENYLIST} from "@/constants";
interface PinDataContext {
node: INodeUi;
$showError(error: Error, title: string): void;
}
export const pinData = (Vue as Vue.VueConstructor<Vue & PinDataContext>).extend({
computed: {
pinData (): PinData[string] | undefined {
return this.node ? this.$store.getters['pinDataByNodeName'](this.node!.name) : undefined;
},
hasPinData (): boolean {
return !!this.node && typeof this.pinData !== 'undefined';
},
isPinDataNodeType(): boolean {
return !!this.node && !PIN_DATA_NODE_TYPES_DENYLIST.includes(this.node.type);
},
},
methods: {
isValidPinDataJSON(data: string): boolean {
try {
JSON.parse(data);
return true;
} catch (error) {
const title = this.$locale.baseText('runData.editOutputInvalid');
const toRemove = new RegExp(/JSON\.parse:|of the JSON data/, 'g');
const message = error.message.replace(toRemove, '').trim();
const positionMatchRegEx = /at position (\d+)/;
const positionMatch = error.message.match(positionMatchRegEx);
error.message = message.charAt(0).toUpperCase() + message.slice(1);
error.message = error.message.replace(
'Unexpected token \' in JSON',
this.$locale.baseText('runData.editOutputInvalid.singleQuote'),
);
if (positionMatch) {
const position = parseInt(positionMatch[1], 10);
const lineBreaksUpToPosition = (data.slice(0, position).match(/\n/g) || []).length;
error.message = error.message.replace(positionMatchRegEx,
this.$locale.baseText('runData.editOutputInvalid.atPosition', {
interpolate: {
position: `${position}`,
},
}),
);
error.message = `${
this.$locale.baseText('runData.editOutputInvalid.onLine', {
interpolate: {
line: `${lineBreaksUpToPosition + 1}`,
},
})
} ${error.message}`;
}
this.$showError(error, title);
return false;
}
},
isValidPinDataSize(data: string | object): boolean {
if (typeof data === 'object') data = JSON.stringify(data);
if (this.$store.getters['pinDataSize'] + stringSizeInBytes(data) > MAX_WORKFLOW_PINNED_DATA_SIZE) {
this.$showError(
new Error(this.$locale.baseText('ndv.pinData.error.tooLarge.description')),
this.$locale.baseText('ndv.pinData.error.tooLarge.title'),
);
return false;
}
return true;
},
},
});

View file

@ -30,6 +30,7 @@ import {
IExecuteData,
INodeConnection,
IWebhookDescription,
PinData,
} from 'n8n-workflow';
import {
@ -69,7 +70,7 @@ export const workflowHelpers = mixins(
source: null,
} as IExecuteData;
if (parentNode.length) {
if (parentNode.length) {
// Add the input data to be able to also resolve the short expression format
// which does not use the node name
const parentNodeName = parentNode[0];
@ -78,6 +79,7 @@ export const workflowHelpers = mixins(
if (workflowRunData === null) {
return executeData;
}
if (!workflowRunData[parentNodeName] ||
workflowRunData[parentNodeName].length <= runIndex ||
!workflowRunData[parentNodeName][runIndex].hasOwnProperty('data') ||
@ -108,7 +110,7 @@ export const workflowHelpers = mixins(
},
// Returns connectionInputData to be able to execute an expression.
connectionInputData (parentNode: string[], currentNode: string, inputName: string, runIndex: number, nodeConnection: INodeConnection = { sourceIndex: 0, destinationIndex: 0 }): INodeExecutionData[] | null {
let connectionInputData = null;
let connectionInputData: INodeExecutionData[] | null = null;
const executeData = this.executeData(parentNode, currentNode, inputName, runIndex);
if (parentNode.length) {
if (!Object.keys(executeData.data).length || executeData.data[inputName].length <= nodeConnection.sourceIndex) {
@ -131,6 +133,35 @@ export const workflowHelpers = mixins(
}
}
const parentPinData = parentNode.reduce((acc: INodeExecutionData[], parentNodeName, index) => {
const pinData = this.$store.getters['pinDataByNodeName'](parentNodeName);
if (pinData) {
acc.push({
json: pinData[0],
pairedItem: {
item: index,
input: 1,
},
});
}
return acc;
}, []);
if (parentPinData.length > 0) {
if (connectionInputData && connectionInputData.length > 0) {
parentPinData.forEach((parentPinDataEntry) => {
connectionInputData![0].json = {
...connectionInputData![0].json,
...parentPinDataEntry.json,
};
});
} else {
connectionInputData = parentPinData;
}
}
return connectionInputData;
},
@ -328,6 +359,7 @@ export const workflowHelpers = mixins(
const data: IWorkflowData = {
name: this.$store.getters.workflowName,
nodes,
pinData: this.$store.getters.pinData,
connections: workflowConnections,
active: this.$store.getters.isActive,
settings: this.$store.getters.workflowSettings,
@ -473,7 +505,10 @@ export const workflowHelpers = mixins(
const workflowRunData = this.$store.getters.getWorkflowRunData as IRunData | null;
let runIndexParent = 0;
if (workflowRunData !== null && parentNode.length) {
runIndexParent = workflowRunData[parentNode[0]].length -1;
const firstParentWithWorkflowRunData = parentNode.find((parentNodeName) => workflowRunData[parentNodeName]);
if (firstParentWithWorkflowRunData) {
runIndexParent = workflowRunData[firstParentWithWorkflowRunData].length - 1;
}
}
const nodeConnection = workflow.getNodeConnectionIndexes(activeNode!.name, parentNode[0]);
@ -490,6 +525,34 @@ export const workflowHelpers = mixins(
runExecutionData = executionData.data;
}
parentNode.forEach((parentNodeName) => {
const pinData: PinData[string] = this.$store.getters['pinDataByNodeName'](parentNodeName);
if (pinData) {
runExecutionData = {
...runExecutionData,
resultData: {
...runExecutionData.resultData,
runData: {
...runExecutionData.resultData.runData,
[parentNodeName]: [
{
startTime: new Date().valueOf(),
executionTime: 0,
source: [],
data: {
main: [
pinData.map((data) => ({ json: data })),
],
},
},
],
},
},
};
}
});
if (connectionInputData === null) {
connectionInputData = [];
}
@ -509,7 +572,6 @@ export const workflowHelpers = mixins(
},
resolveExpression(expression: string, siblingParameters: INodeParameters = {}) {
const parameters = {
'__xxxxxxx__': expression,
...siblingParameters,

View file

@ -188,6 +188,7 @@ export const workflowRun = mixins(
const startRunData: IStartRunData = {
workflowData,
runData: newRunData,
pinData: workflowData.pinData,
startNodes,
};
if (nodeName) {
@ -208,6 +209,7 @@ export const workflowRun = mixins(
data: {
resultData: {
runData: newRunData || {},
pinData: workflowData.pinData,
startNodes,
workflowData,
},

View file

@ -1,3 +1,5 @@
export const MAX_WORKFLOW_SIZE = 16777216; // Workflow size limit in bytes
export const MAX_WORKFLOW_PINNED_DATA_SIZE = 12582912; // Workflow pinned data size limit in bytes
export const MAX_DISPLAY_DATA_SIZE = 204800;
export const MAX_DISPLAY_ITEMS_AUTO_ALL = 250;
export const NODE_NAME_PREFIX = 'node-';
@ -55,6 +57,7 @@ export const BREAKPOINT_XL = 1920;
export const N8N_IO_BASE_URL = `https://api.n8n.io/api/`;
export const DATA_PINNING_DOCS_URL = 'https://docs.n8n.io/data/data-pinning/';
export const NPM_COMMUNITY_NODE_SEARCH_API_URL = `https://api.npms.io/v2/`;
export const NPM_PACKAGE_DOCS_BASE_URL = `https://www.npmjs.com/package/`;
export const NPM_KEYWORD_SEARCH_URL = `https://www.npmjs.com/search?q=keywords%3An8n-community-node-package`;
@ -95,6 +98,7 @@ export const SET_NODE_TYPE = 'n8n-nodes-base.set';
export const SERVICENOW_NODE_TYPE = 'n8n-nodes-base.serviceNow';
export const SLACK_NODE_TYPE = 'n8n-nodes-base.slack';
export const SPREADSHEET_FILE_NODE_TYPE = 'n8n-nodes-base.spreadsheetFile';
export const SPLIT_IN_BATCHES_NODE_TYPE = 'n8n-nodes-base.splitInBatches';
export const START_NODE_TYPE = 'n8n-nodes-base.start';
export const SWITCH_NODE_TYPE = 'n8n-nodes-base.switch';
export const THE_HIVE_TRIGGER_NODE_TYPE = 'n8n-nodes-base.theHiveTrigger';
@ -106,6 +110,16 @@ export const XERO_NODE_TYPE = 'n8n-nodes-base.xero';
export const ZENDESK_NODE_TYPE = 'n8n-nodes-base.zendesk';
export const ZENDESK_TRIGGER_NODE_TYPE = 'n8n-nodes-base.zendeskTrigger';
export const MULTIPLE_OUTPUT_NODE_TYPES = [
IF_NODE_TYPE,
SWITCH_NODE_TYPE,
];
export const PIN_DATA_NODE_TYPES_DENYLIST = [
...MULTIPLE_OUTPUT_NODE_TYPES,
SPLIT_IN_BATCHES_NODE_TYPE,
];
// Node creator
export const CORE_NODES_CATEGORY = 'Core Nodes';
export const CUSTOM_NODES_CATEGORY = 'Custom Nodes';
@ -210,6 +224,8 @@ export const MODAL_CONFIRMED = 'confirmed';
export const VALID_EMAIL_REGEX = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
export const LOCAL_STORAGE_ACTIVATION_FLAG = 'N8N_HIDE_ACTIVATION_ALERT';
export const LOCAL_STORAGE_PIN_DATA_DISCOVERY_NDV_FLAG = 'N8N_PIN_DATA_DISCOVERY_NDV';
export const LOCAL_STORAGE_PIN_DATA_DISCOVERY_CANVAS_FLAG = 'N8N_PIN_DATA_DISCOVERY_CANVAS';
export const LOCAL_STORAGE_MAPPING_FLAG = 'N8N_MAPPING_ONBOARDED';
export const BASE_NODE_SURVEY_URL = 'https://n8n-community.typeform.com/to/BvmzxqYv#nodename=';
@ -262,4 +278,14 @@ export enum VIEWS {
COMMUNITY_NODES = "CommunityNodes",
}
export const TEST_PIN_DATA = [
{
name: "First item",
code: 1,
},
{
name: "Second item",
code: 2,
},
];
export const MAPPING_PARAMS = [`$evaluateExpression`, `$item`, `$jmespath`, `$node`, `$binary`, `$data`, `$env`, `$json`, `$now`, `$parameters`, `$position`, `$resumeWebhookUrl`, `$runIndex`, `$today`, `$workflow`, '$parameter'];

View file

@ -0,0 +1,3 @@
import Vue from 'vue';
export const dataPinningEventBus = new Vue();

View file

@ -108,6 +108,10 @@ const module: Module<IUiState, IRootState> = {
},
output: {
displayMode: 'table',
editMode: {
enabled: false,
value: '',
},
},
focusedMappableInput: '',
mappingTelemetry: {},
@ -147,6 +151,7 @@ const module: Module<IUiState, IRootState> = {
},
inputPanelDispalyMode: (state: IUiState) => state.ndv.input.displayMode,
outputPanelDispalyMode: (state: IUiState) => state.ndv.output.displayMode,
outputPanelEditMode: (state: IUiState): IUiState['ndv']['output']['editMode'] => state.ndv.output.editMode,
mainPanelPosition: (state: IUiState) => state.mainPanelPosition,
focusedMappableInput: (state: IUiState) => state.ndv.focusedMappableInput,
isDraggableDragging: (state: IUiState) => state.draggable.isDragging,
@ -198,6 +203,12 @@ const module: Module<IUiState, IRootState> = {
setPanelDisplayMode: (state: IUiState, params: {pane: 'input' | 'output', mode: IRunDataDisplayMode}) => {
Vue.set(state.ndv[params.pane], 'displayMode', params.mode);
},
setOutputPanelEditModeEnabled: (state: IUiState, payload: boolean) => {
Vue.set(state.ndv.output.editMode, 'enabled', payload);
},
setOutputPanelEditModeValue: (state: IUiState, payload: string) => {
Vue.set(state.ndv.output.editMode, 'value', payload);
},
setMainPanelRelativePosition(state: IUiState, relativePosition: number) {
state.mainPanelPosition = relativePosition;
},

View file

@ -196,7 +196,7 @@
&,
&:hover,
&:focus {
border-radius: 20px;
border-radius: var(--border-radius-base);
color: var(--color-text-dark);
background-color: var(--color-background-base);
border-color: var(--color-foreground-base);

View file

@ -49,6 +49,9 @@ import {
N8nAvatar,
N8nActionToggle,
N8nButton,
N8nElButton,
N8nCallout,
N8nPanelCallout,
N8nCard,
N8nIcon,
N8nIconButton,
@ -87,7 +90,10 @@ Vue.use(N8nInfoAccordion);
Vue.use(N8nActionBox);
Vue.use(N8nActionToggle);
Vue.use(N8nAvatar);
Vue.use(N8nButton);
Vue.component('n8n-button', N8nButton);
Vue.component('el-button', N8nElButton);
Vue.component('n8n-callout', N8nCallout);
Vue.component('n8n-panel-callout', N8nPanelCallout);
Vue.component('n8n-card', N8nCard);
Vue.component('n8n-form-box', N8nFormBox);
Vue.component('n8n-form-inputs', N8nFormInputs);
@ -161,7 +167,6 @@ Vue.prototype.$alert = async (message: string, configOrTitle: string | ElMessage
let temp = config || (typeof configOrTitle === 'object' ? configOrTitle : {});
temp = {
...temp,
roundButton: true,
cancelButtonClass: 'btn--cancel',
confirmButtonClass: 'btn--confirm',
};
@ -176,7 +181,6 @@ Vue.prototype.$confirm = async (message: string, configOrTitle: string | ElMessa
let temp = config || (typeof configOrTitle === 'object' ? configOrTitle : {});
temp = {
...temp,
roundButton: true,
cancelButtonClass: 'btn--cancel',
confirmButtonClass: 'btn--confirm',
distinguishCancelAndClose: true,
@ -194,7 +198,6 @@ Vue.prototype.$prompt = async (message: string, configOrTitle: string | ElMessag
let temp = config || (typeof configOrTitle === 'object' ? configOrTitle : {});
temp = {
...temp,
roundButton: true,
cancelButtonClass: 'btn--cancel',
confirmButtonClass: 'btn--confirm',
};

View file

@ -14,6 +14,7 @@
"generic.communityNode.tooltip": "This is a node from our community. It's part of the {packageName} package. <a href=\"{docURL}\" target=\"_blank\" title=\"Read the n8n docs\">Learn more</a>",
"generic.delete": "Delete",
"generic.copy": "Copy",
"generic.or": "or",
"generic.clickToCopy": "Click to copy",
"generic.copiedToClipboard": "Copied to clipboard",
"generic.beta": "beta",
@ -343,7 +344,10 @@
"ndv.input.notConnected.title": "Wire me up",
"ndv.input.notConnected.message": "This node can only receive input data if you connect it to another node.",
"ndv.input.notConnected.learnMore": "Learn more",
"ndv.input.disabled": "The '{nodeName}' node is disabled and wont execute.",
"ndv.input.disabled.cta": "Enable it",
"ndv.output": "Output",
"ndv.output.edit": "Edit Output",
"ndv.output.all": "all",
"ndv.output.branch": "Branch",
"ndv.output.executing": "Executing node...",
@ -357,7 +361,9 @@
"ndv.output.pageSize": "Page Size",
"ndv.output.run": "Run",
"ndv.output.runNodeHint": "Execute this node to output data",
"ndv.output.staleDataWarning": "Node parameters have changed. <br /> Execute node again to refresh output.",
"ndv.output.insertTestData": "insert test data",
"ndv.output.staleDataWarning.regular": "Node parameters have changed.<br>Execute node again to refresh output.",
"ndv.output.staleDataWarning.pinData": "Node parameter changes will not affect pinned output data.",
"ndv.output.tooMuchData.message": "The node contains {size} MB of data. Displaying it may cause problems. <br /> If you do decide to display it, avoid the JSON view.",
"ndv.output.tooMuchData.showDataAnyway": "Show data anyway",
"ndv.output.tooMuchData.title": "Output data is huge!",
@ -365,6 +371,20 @@
"ndv.title.cancel": "Cancel",
"ndv.title.rename": "Rename",
"ndv.title.renameNode": "Rename node",
"ndv.pinData.pin.title": "Pin data",
"ndv.pinData.pin.description": "Node will always output this data instead of executing. You can also pin data from previous executions.",
"ndv.pinData.pin.link": "More info",
"ndv.pinData.pin.multipleRuns.title": "Run #{index} was pinned",
"ndv.pinData.pin.multipleRuns.description": "This run will be outputted each time the node is run.",
"ndv.pinData.unpinAndExecute.title": "Unpin output data?",
"ndv.pinData.unpinAndExecute.description": "Executing a node overwrites pinned data.",
"ndv.pinData.unpinAndExecute.cancel": "Cancel",
"ndv.pinData.unpinAndExecute.confirm": "Unpin and execute",
"ndv.pinData.beforeClosing.title": "Save output changes before closing?",
"ndv.pinData.beforeClosing.cancel": "Discard",
"ndv.pinData.beforeClosing.confirm": "Save",
"ndv.pinData.error.tooLarge.title": "Output data is too large to pin",
"ndv.pinData.error.tooLarge.description": "You can pin at most 12MB of output per workflow.",
"noTagsView.readyToOrganizeYourWorkflows": "Ready to organize your workflows?",
"noTagsView.withWorkflowTagsYouReFree": "With workflow tags, you're free to create the perfect tagging system for your flows",
"node.activateDeactivateNode": "Activate/Deactivate Node",
@ -378,6 +398,8 @@
"node.nodeIsWaitingTill": "Node is waiting until {date} {time}",
"node.theNodeIsWaitingIndefinitelyForAnIncomingWebhookCall": "The node is waiting for an incoming webhook call (indefinitely)",
"node.waitingForYouToCreateAnEventIn": "Waiting for you to create an event in {nodeType}",
"node.discovery.pinData.canvas": "You can pin this output instead of waiting for a test event. Open node to do so.",
"node.discovery.pinData.ndv": "You can pin this output instead of waiting for a test event.",
"nodeBase.clickToAddNodeOrDragToConnect": "Click to add node<br />or drag to connect",
"nodeCreator.categoryNames.analytics": "Analytics",
"nodeCreator.categoryNames.communication": "Communication",
@ -644,14 +666,25 @@
"runData.unlinking.hint": "Unlink displayed input and output runs",
"runData.binary": "Binary",
"runData.copyItemPath": "Copy Item Path",
"runData.copyItemPath.toast": "Item path copied",
"runData.copyParameterPath": "Copy Parameter Path",
"runData.copyParameterPath.toast": "Parameter path copied",
"runData.copyValue": "Copy Selection",
"runData.copyValue.toast": "Output data copied",
"runData.copyToClipboard": "Copy to Clipboard",
"runData.copyValue": "Copy Value",
"runData.copyDisabled": "First click on the output data you want to copy, then click this button.",
"runData.editOutput": "Edit Output",
"runData.editOutputInvalid": "Problem with output data",
"runData.editOutputInvalid.singleQuote": "Unexpected single quote. Please use double quotes (\") instead",
"runData.editOutputInvalid.onLine": "On line {line}:",
"runData.editOutputInvalid.atPosition": "(at position {position})",
"runData.editValue": "Edit Value",
"runData.downloadBinaryData": "Download",
"runData.executeNode": "Execute Node",
"runData.executionTime": "Execution Time",
"runData.fileExtension": "File Extension",
"runData.fileName": "File Name",
"runData.invalidPinnedData": "Invalid pinned data",
"runData.items": "Items",
"runData.json": "JSON",
"runData.mimeType": "Mime Type",
@ -664,6 +697,12 @@
"runData.showBinaryData": "View",
"runData.startTime": "Start Time",
"runData.table": "Table",
"runData.pindata.learnMore": "Learn more",
"runData.pindata.thisDataIsPinned": "This data is pinned.",
"runData.pindata.unpin": "Unpin",
"runData.editor.save": "Save",
"runData.editor.cancel": "Cancel",
"runData.editor.copyDataInfo": "You can copy data from previous executions and paste it above.",
"saveButton.save": "@:_reusableBaseText.save",
"saveButton.saved": "Saved",
"saveButton.saving": "Saving",

View file

@ -92,6 +92,7 @@ import {
faTasks,
faTerminal,
faThLarge,
faThumbtack,
faTimes,
faTimesCircle,
faTrash,
@ -204,6 +205,7 @@ addIcon(faTable);
addIcon(faTasks);
addIcon(faTerminal);
addIcon(faThLarge);
addIcon(faThumbtack);
addIcon(faTimes);
addIcon(faTimesCircle);
addIcon(faTrash);

View file

@ -1,8 +1,10 @@
import Vue from 'vue';
import Vuex from 'vuex';
import { PLACEHOLDER_EMPTY_WORKFLOW_ID, DEFAULT_NODETYPE_VERSION } from '@/constants';
import {
PLACEHOLDER_EMPTY_WORKFLOW_ID,
DEFAULT_NODETYPE_VERSION,
} from '@/constants';
import {
IConnection,
@ -14,6 +16,7 @@ import {
IRunData,
ITaskData,
IWorkflowSettings,
PinData,
} from 'n8n-workflow';
import {
@ -40,6 +43,8 @@ import users from './modules/users';
import workflows from './modules/workflows';
import versions from './modules/versions';
import templates from './modules/templates';
import {stringSizeInBytes} from "@/components/helpers";
import {dataPinningEventBus} from "@/event-bus/data-pinning-event-bus";
import communityNodes from './modules/communityNodes';
import { isCommunityPackageName } from './components/helpers';
@ -90,6 +95,7 @@ const state: IRootState = {
nodes: [],
settings: {},
tags: [],
pinData: {},
},
sidebarMenuItems: [],
instanceId: '',
@ -114,13 +120,13 @@ export const store = new Vuex.Store({
state,
mutations: {
// Active Actions
addActiveAction (state, action: string) {
addActiveAction(state, action: string) {
if (!state.activeActions.includes(action)) {
state.activeActions.push(action);
}
},
removeActiveAction (state, action: string) {
removeActiveAction(state, action: string) {
const actionIndex = state.activeActions.indexOf(action);
if (actionIndex !== -1) {
state.activeActions.splice(actionIndex, 1);
@ -128,7 +134,7 @@ export const store = new Vuex.Store({
},
// Active Executions
addActiveExecution (state, newActiveExecution: IExecutionsCurrentSummaryExtended) {
addActiveExecution(state, newActiveExecution: IExecutionsCurrentSummaryExtended) {
// Check if the execution exists already
const activeExecution = state.activeExecutions.find(execution => {
return execution.id === newActiveExecution.id;
@ -144,7 +150,7 @@ export const store = new Vuex.Store({
state.activeExecutions.unshift(newActiveExecution);
},
finishActiveExecution (state, finishedActiveExecution: IPushDataExecutionFinished) {
finishActiveExecution(state, finishedActiveExecution: IPushDataExecutionFinished) {
// Find the execution to set to finished
const activeExecution = state.activeExecutions.find(execution => {
return execution.id === finishedActiveExecution.executionId;
@ -162,22 +168,22 @@ export const store = new Vuex.Store({
Vue.set(activeExecution, 'finished', finishedActiveExecution.data.finished);
Vue.set(activeExecution, 'stoppedAt', finishedActiveExecution.data.stoppedAt);
},
setActiveExecutions (state, newActiveExecutions: IExecutionsCurrentSummaryExtended[]) {
setActiveExecutions(state, newActiveExecutions: IExecutionsCurrentSummaryExtended[]) {
Vue.set(state, 'activeExecutions', newActiveExecutions);
},
// Active Workflows
setActiveWorkflows (state, newActiveWorkflows: string[]) {
setActiveWorkflows(state, newActiveWorkflows: string[]) {
state.activeWorkflows = newActiveWorkflows;
},
setWorkflowActive (state, workflowId: string) {
setWorkflowActive(state, workflowId: string) {
state.stateIsDirty = false;
const index = state.activeWorkflows.indexOf(workflowId);
if (index === -1) {
state.activeWorkflows.push(workflowId);
}
},
setWorkflowInactive (state, workflowId: string) {
setWorkflowInactive(state, workflowId: string) {
const index = state.activeWorkflows.indexOf(workflowId);
if (index !== -1) {
state.activeWorkflows.splice(index, 1);
@ -185,15 +191,15 @@ export const store = new Vuex.Store({
},
// Set state condition dirty or not
// ** Dirty: if current workflow state has been synchronized with database AKA has it been saved
setStateDirty (state, dirty : boolean) {
setStateDirty(state, dirty: boolean) {
state.stateIsDirty = dirty;
},
// Selected Nodes
addSelectedNode (state, node: INodeUi) {
addSelectedNode(state, node: INodeUi) {
state.selectedNodes.push(node);
},
removeNodeFromSelection (state, node: INodeUi) {
removeNodeFromSelection(state, node: INodeUi) {
let index;
for (index in state.selectedNodes) {
if (state.selectedNodes[index].name === node.name) {
@ -202,17 +208,38 @@ export const store = new Vuex.Store({
}
}
},
resetSelectedNodes (state) {
resetSelectedNodes(state) {
Vue.set(state, 'selectedNodes', []);
},
// Pin data
pinData(state, payload: { node: INodeUi, data: PinData[string] }) {
if (state.workflow.pinData) {
Vue.set(state.workflow.pinData, payload.node.name, payload.data);
}
state.stateIsDirty = true;
dataPinningEventBus.$emit('pin-data', { [payload.node.name]: payload.data });
},
unpinData(state, payload: { node: INodeUi }) {
if (state.workflow.pinData) {
Vue.set(state.workflow.pinData, payload.node.name, undefined);
delete state.workflow.pinData[payload.node.name];
}
state.stateIsDirty = true;
dataPinningEventBus.$emit('unpin-data', { [payload.node.name]: undefined });
},
// Active
setActive (state, newActive: boolean) {
setActive(state, newActive: boolean) {
state.workflow.active = newActive;
},
// Connections
addConnection (state, data) {
addConnection(state, data) {
if (data.connection.length !== 2) {
// All connections need two entries
// TODO: Check if there is an error or whatever that is supposed to be returned
@ -260,7 +287,7 @@ export const store = new Vuex.Store({
}
},
removeConnection (state, data) {
removeConnection(state, data) {
const sourceData = data.connection[0];
const destinationData = data.connection[1];
@ -285,13 +312,13 @@ export const store = new Vuex.Store({
}
},
removeAllConnections (state, data) {
removeAllConnections(state, data) {
if (data && data.setStateDirty === true) {
state.stateIsDirty = true;
}
state.workflow.connections = {};
},
removeAllNodeConnection (state, node: INodeUi) {
removeAllNodeConnection(state, node: INodeUi) {
state.stateIsDirty = true;
// Remove all source connections
if (state.workflow.connections.hasOwnProperty(node.name)) {
@ -320,7 +347,7 @@ export const store = new Vuex.Store({
}
},
renameNodeSelectedAndExecution (state, nameData) {
renameNodeSelectedAndExecution(state, nameData) {
state.stateIsDirty = true;
// If node has any WorkflowResultData rename also that one that the data
// does still get displayed also after node got renamed
@ -336,9 +363,14 @@ export const store = new Vuex.Store({
Vue.set(state.nodeMetadata, nameData.new, state.nodeMetadata[nameData.old]);
Vue.delete(state.nodeMetadata, nameData.old);
if (state.workflow.pinData && state.workflow.pinData.hasOwnProperty(nameData.old)) {
Vue.set(state.workflow.pinData, nameData.new, state.workflow.pinData[nameData.old]);
Vue.delete(state.workflow.pinData, nameData.old);
}
},
resetAllNodesIssues (state) {
resetAllNodesIssues(state) {
state.workflow.nodes.forEach((node) => {
node.issues = undefined;
});
@ -346,7 +378,7 @@ export const store = new Vuex.Store({
return true;
},
setNodeIssue (state, nodeIssueData: INodeIssueData) {
setNodeIssue(state, nodeIssueData: INodeIssueData) {
const node = state.workflow.nodes.find(node => {
return node.name === nodeIssueData.node;
@ -382,7 +414,7 @@ export const store = new Vuex.Store({
},
// Name
setWorkflowName (state, data) {
setWorkflowName(state, data) {
if (data.setStateDirty === true) {
state.stateIsDirty = true;
}
@ -390,7 +422,7 @@ export const store = new Vuex.Store({
},
// replace invalid credentials in workflow
replaceInvalidWorkflowCredentials(state, {credentials, invalid, type }) {
replaceInvalidWorkflowCredentials(state, {credentials, invalid, type}) {
state.workflow.nodes.forEach((node) => {
if (!node.credentials || !node.credentials[type]) {
return;
@ -403,7 +435,7 @@ export const store = new Vuex.Store({
}
if (nodeCredentials.id === null) {
if (nodeCredentials.name === invalid.name){
if (nodeCredentials.name === invalid.name) {
node.credentials[type] = credentials;
}
return;
@ -416,7 +448,7 @@ export const store = new Vuex.Store({
},
// Nodes
addNode (state, nodeData: INodeUi) {
addNode(state, nodeData: INodeUi) {
if (!nodeData.hasOwnProperty('name')) {
// All nodes have to have a name
// TODO: Check if there is an error or whatever that is supposed to be returned
@ -425,9 +457,13 @@ export const store = new Vuex.Store({
state.workflow.nodes.push(nodeData);
},
removeNode (state, node: INodeUi) {
removeNode(state, node: INodeUi) {
Vue.delete(state.nodeMetadata, node.name);
if (state.workflow.pinData && state.workflow.pinData.hasOwnProperty(node.name)) {
Vue.delete(state.workflow.pinData, node.name);
}
for (let i = 0; i < state.workflow.nodes.length; i++) {
if (state.workflow.nodes[i].name === node.name) {
state.workflow.nodes.splice(i, 1);
@ -436,14 +472,19 @@ export const store = new Vuex.Store({
}
}
},
removeAllNodes (state, data) {
removeAllNodes(state, data) {
if (data.setStateDirty === true) {
state.stateIsDirty = true;
}
if (data.removePinData) {
state.workflow.pinData = {};
}
state.workflow.nodes.splice(0, state.workflow.nodes.length);
state.nodeMetadata = {};
},
updateNodeProperties (state, updateInformation: INodeUpdatePropertiesInformation) {
updateNodeProperties(state, updateInformation: INodeUpdatePropertiesInformation) {
// Find the node that should be updated
const node = state.workflow.nodes.find(node => {
return node.name === updateInformation.name;
@ -456,7 +497,7 @@ export const store = new Vuex.Store({
}
}
},
setNodeValue (state, updateInformation: IUpdateInformation) {
setNodeValue(state, updateInformation: IUpdateInformation) {
// Find the node that should be updated
const node = state.workflow.nodes.find(node => {
return node.name === updateInformation.name;
@ -469,7 +510,7 @@ export const store = new Vuex.Store({
state.stateIsDirty = true;
Vue.set(node, updateInformation.key, updateInformation.value);
},
setNodeParameters (state, updateInformation: IUpdateInformation) {
setNodeParameters(state, updateInformation: IUpdateInformation) {
// Find the node that should be updated
const node = state.workflow.nodes.find(node => {
return node.name === updateInformation.name;
@ -489,70 +530,71 @@ export const store = new Vuex.Store({
},
// Node-Index
addToNodeIndex (state, nodeName: string) {
addToNodeIndex(state, nodeName: string) {
state.nodeIndex.push(nodeName);
},
setNodeIndex (state, newData: { index: number, name: string | null}) {
setNodeIndex(state, newData: { index: number, name: string | null }) {
state.nodeIndex[newData.index] = newData.name;
},
resetNodeIndex (state) {
resetNodeIndex(state) {
Vue.set(state, 'nodeIndex', []);
},
// Node-View
setNodeViewMoveInProgress (state, value: boolean) {
setNodeViewMoveInProgress(state, value: boolean) {
state.nodeViewMoveInProgress = value;
},
setNodeViewOffsetPosition (state, data) {
setNodeViewOffsetPosition(state, data) {
state.nodeViewOffsetPosition = data.newOffset;
},
setNodeTypes (state, nodeTypes: INodeTypeDescription[]) {
// Node-Types
setNodeTypes(state, nodeTypes: INodeTypeDescription[]) {
Vue.set(state, 'nodeTypes', nodeTypes);
},
// Active Execution
setExecutingNode (state, executingNode: string) {
setExecutingNode(state, executingNode: string) {
state.executingNode = executingNode;
},
setExecutionWaitingForWebhook (state, newWaiting: boolean) {
setExecutionWaitingForWebhook(state, newWaiting: boolean) {
state.executionWaitingForWebhook = newWaiting;
},
setActiveExecutionId (state, executionId: string | null) {
setActiveExecutionId(state, executionId: string | null) {
state.executionId = executionId;
},
// Push Connection
setPushConnectionActive (state, newActive: boolean) {
setPushConnectionActive(state, newActive: boolean) {
state.pushConnectionActive = newActive;
},
// Webhooks
setUrlBaseWebhook (state, urlBaseWebhook: string) {
setUrlBaseWebhook(state, urlBaseWebhook: string) {
Vue.set(state, 'urlBaseWebhook', urlBaseWebhook);
},
setEndpointWebhook (state, endpointWebhook: string) {
setEndpointWebhook(state, endpointWebhook: string) {
Vue.set(state, 'endpointWebhook', endpointWebhook);
},
setEndpointWebhookTest (state, endpointWebhookTest: string) {
setEndpointWebhookTest(state, endpointWebhookTest: string) {
Vue.set(state, 'endpointWebhookTest', endpointWebhookTest);
},
setSaveDataErrorExecution (state, newValue: string) {
setSaveDataErrorExecution(state, newValue: string) {
Vue.set(state, 'saveDataErrorExecution', newValue);
},
setSaveDataSuccessExecution (state, newValue: string) {
setSaveDataSuccessExecution(state, newValue: string) {
Vue.set(state, 'saveDataSuccessExecution', newValue);
},
setSaveManualExecutions (state, saveManualExecutions: boolean) {
setSaveManualExecutions(state, saveManualExecutions: boolean) {
Vue.set(state, 'saveManualExecutions', saveManualExecutions);
},
setTimezone (state, timezone: string) {
setTimezone(state, timezone: string) {
Vue.set(state, 'timezone', timezone);
},
setExecutionTimeout (state, executionTimeout: number) {
setExecutionTimeout(state, executionTimeout: number) {
Vue.set(state, 'executionTimeout', executionTimeout);
},
setMaxExecutionTimeout (state, maxExecutionTimeout: number) {
setMaxExecutionTimeout(state, maxExecutionTimeout: number) {
Vue.set(state, 'maxExecutionTimeout', maxExecutionTimeout);
},
setVersionCli(state, version: string) {
@ -570,25 +612,25 @@ export const store = new Vuex.Store({
setDefaultLocale(state, locale: string) {
Vue.set(state, 'defaultLocale', locale);
},
setActiveNode (state, nodeName: string) {
setActiveNode(state, nodeName: string) {
state.activeNode = nodeName;
},
setActiveCredentialType (state, activeCredentialType: string) {
setActiveCredentialType(state, activeCredentialType: string) {
state.activeCredentialType = activeCredentialType;
},
setLastSelectedNode (state, nodeName: string) {
setLastSelectedNode(state, nodeName: string) {
state.lastSelectedNode = nodeName;
},
setLastSelectedNodeOutputIndex (state, outputIndex: number | null) {
setLastSelectedNodeOutputIndex(state, outputIndex: number | null) {
state.lastSelectedNodeOutputIndex = outputIndex;
},
setWorkflowExecutionData (state, workflowResultData: IExecutionResponse | null) {
setWorkflowExecutionData(state, workflowResultData: IExecutionResponse | null) {
state.workflowExecutionData = workflowResultData;
},
addNodeExecutionData (state, pushData: IPushDataNodeExecuteAfter): void {
addNodeExecutionData(state, pushData: IPushDataNodeExecuteAfter): void {
if (state.workflowExecutionData === null) {
throw new Error('The "workflowExecutionData" is not initialized!');
}
@ -597,7 +639,7 @@ export const store = new Vuex.Store({
}
state.workflowExecutionData.data.resultData.runData[pushData.nodeName].push(pushData.data);
},
clearNodeExecutionData (state, nodeName: string): void {
clearNodeExecutionData(state, nodeName: string): void {
if (state.workflowExecutionData === null) {
return;
}
@ -605,19 +647,25 @@ export const store = new Vuex.Store({
Vue.delete(state.workflowExecutionData.data.resultData.runData, nodeName);
},
setWorkflowSettings (state, workflowSettings: IWorkflowSettings) {
setWorkflowSettings(state, workflowSettings: IWorkflowSettings) {
Vue.set(state.workflow, 'settings', workflowSettings);
},
setWorkflowTagIds (state, tags: string[]) {
setWorkflowPinData(state, pinData: PinData) {
Vue.set(state.workflow, 'pinData', pinData);
dataPinningEventBus.$emit('pin-data', pinData);
},
setWorkflowTagIds(state, tags: string[]) {
Vue.set(state.workflow, 'tags', tags);
},
addWorkflowTagIds (state, tags: string[]) {
addWorkflowTagIds(state, tags: string[]) {
Vue.set(state.workflow, 'tags', [...new Set([...(state.workflow.tags || []), ...tags])]);
},
removeWorkflowTagId (state, tagId: string) {
removeWorkflowTagId(state, tagId: string) {
const tags = state.workflow.tags as string[];
const updated = tags.filter((id: string) => id !== tagId);
@ -625,7 +673,7 @@ export const store = new Vuex.Store({
},
// Workflow
setWorkflow (state, workflow: IWorkflowDb) {
setWorkflow(state, workflow: IWorkflowDb) {
Vue.set(state, 'workflow', workflow);
if (!state.workflow.hasOwnProperty('active')) {
@ -651,7 +699,7 @@ export const store = new Vuex.Store({
}
},
updateNodeTypes (state, nodeTypes: INodeTypeDescription[]) {
updateNodeTypes(state, nodeTypes: INodeTypeDescription[]) {
const oldNodesToKeep = state.nodeTypes.filter(node => !nodeTypes.find(n => n.name === node.name && n.version.toString() === node.version.toString()));
const newNodesState = [...oldNodesToKeep, ...nodeTypes];
@ -673,7 +721,7 @@ export const store = new Vuex.Store({
},
getters: {
executedNode: (state): string | undefined => {
return state.workflowExecutionData? state.workflowExecutionData.executedNode: undefined;
return state.workflowExecutionData ? state.workflowExecutionData.executedNode : undefined;
},
activeCredentialType: (state): string | null => {
return state.activeCredentialType;
@ -729,7 +777,7 @@ export const store = new Vuex.Store({
return `${state.urlBaseWebhook}${state.endpointWebhookTest}`;
},
getStateIsDirty: (state) : boolean => {
getStateIsDirty: (state): boolean => {
return state.stateIsDirty;
},
@ -832,8 +880,8 @@ export const store = new Vuex.Store({
allNodes: (state): INodeUi[] => {
return state.workflow.nodes;
},
nodesByName: (state: IRootState): {[name: string]: INodeUi} => {
return state.workflow.nodes.reduce((accu: {[name: string]: INodeUi}, node) => {
nodesByName: (state: IRootState): { [name: string]: INodeUi } => {
return state.workflow.nodes.reduce((accu: { [name: string]: INodeUi }, node) => {
accu[node.name] = node;
return accu;
}, {});
@ -853,11 +901,32 @@ export const store = new Vuex.Store({
allNodeTypes: (state): INodeTypeDescription[] => {
return state.nodeTypes;
},
/**
* Pin data
*/
pinData: (state): PinData | undefined => {
return state.workflow.pinData;
},
pinDataByNodeName: (state) => (nodeName: string) => {
return state.workflow.pinData && state.workflow.pinData[nodeName];
},
pinDataSize: (state) => {
return state.workflow.nodes
.reduce((acc, node) => {
if (typeof node.pinData !== 'undefined' && node.name !== state.activeNode) {
acc += stringSizeInBytes(node.pinData);
}
return acc;
}, 0);
},
/**
* Getter for node default names ending with a number: `'S3'`, `'Magento 2'`, etc.
*/
nativelyNumberSuffixedDefaults: (_, getters): string[] => {
const { allNodeTypes } = getters as {
const {allNodeTypes} = getters as {
allNodeTypes: Array<INodeTypeDescription & { defaults: { name: string } }>;
};

View file

@ -74,7 +74,12 @@
:class="['add-sticky-button', showStickyButton ? 'visible-button' : '']"
@click="nodeTypeSelected(STICKY_NODE_TYPE)"
>
<n8n-icon-button size="large" :icon="['far', 'note-sticky']" type="outline" :title="$locale.baseText('nodeView.addSticky')"/>
<n8n-icon-button
size="medium"
type="secondary"
:icon="['far', 'note-sticky']"
:title="$locale.baseText('nodeView.addSticky')"
/>
</div>
</div>
</div>
@ -84,33 +89,27 @@
@closeNodeCreator="closeNodeCreator"
/>
<div :class="{ 'zoom-menu': true, 'regular-zoom-menu': !isDemo, 'demo-zoom-menu': isDemo, expanded: !sidebarMenuCollapsed }">
<button @click="zoomToFit" class="button-white" :title="$locale.baseText('nodeView.zoomToFit')">
<font-awesome-icon icon="expand"/>
</button>
<button @click="zoomIn()" class="button-white" :title="$locale.baseText('nodeView.zoomIn')">
<font-awesome-icon icon="search-plus"/>
</button>
<button @click="zoomOut()" class="button-white" :title="$locale.baseText('nodeView.zoomOut')">
<font-awesome-icon icon="search-minus"/>
</button>
<button
<n8n-icon-button @click="zoomToFit" type="tertiary" size="large" :title="$locale.baseText('nodeView.zoomToFit')" icon="expand" />
<n8n-icon-button @click="zoomIn" type="tertiary" size="large" :title="$locale.baseText('nodeView.zoomIn')" icon="search-plus" />
<n8n-icon-button @click="zoomOut" type="tertiary" size="large" :title="$locale.baseText('nodeView.zoomOut')" icon="search-minus" />
<n8n-icon-button
v-if="nodeViewScale !== 1 && !isDemo"
@click="resetZoom()"
class="button-white"
@click="resetZoom"
type="tertiary"
size="large"
:title="$locale.baseText('nodeView.resetZoom')"
>
<font-awesome-icon icon="undo" :title="$locale.baseText('nodeView.resetZoom')"/>
</button>
icon="undo"
/>
</div>
<div class="workflow-execute-wrapper" v-if="!isReadOnly">
<n8n-button
@click.stop="onRunWorkflow"
:loading="workflowRunning"
:label="runButtonText"
:title="$locale.baseText('nodeView.executesTheWorkflowFromTheStartOrWebhookNode')"
size="large"
icon="play-circle"
:title="$locale.baseText('nodeView.executesTheWorkflowFromTheStartOrWebhookNode')"
:type="workflowRunning ? 'light' : 'primary'"
type="primary"
/>
<n8n-icon-button
@ -118,7 +117,7 @@
icon="stop"
size="large"
class="stop-execution"
type="light"
type="secondary"
:title="stopExecutionInProgress
? $locale.baseText('nodeView.stoppingCurrentExecution')
: $locale.baseText('nodeView.stopCurrentExecution')
@ -133,7 +132,7 @@
icon="stop"
size="large"
:title="$locale.baseText('nodeView.stopWaitingForWebhookCall')"
type="light"
type="secondary"
@click.stop="stopWaitingForWebhook()"
/>
@ -196,6 +195,7 @@ import {
TelemetryHelpers,
ITelemetryTrackProperties,
IWorkflowBase,
PinData,
} from 'n8n-workflow';
import {
ICredentialsResponse,
@ -220,6 +220,7 @@ import {
import '../plugins/N8nCustomConnectorType';
import '../plugins/PlusEndpointType';
import {dataPinningEventBus} from "@/event-bus/data-pinning-event-bus";
interface AddNodeOptions {
position?: XYPosition;
@ -549,6 +550,7 @@ export default mixins(
this.$store.commit('setWorkflowId', PLACEHOLDER_EMPTY_WORKFLOW_ID);
this.$store.commit('setWorkflowExecutionData', data);
this.$store.commit('setWorkflowPinData', data.workflowData.pinData);
await this.addNodes(JSON.parse(JSON.stringify(data.workflowData.nodes)), JSON.parse(JSON.stringify(data.workflowData.connections)));
this.$nextTick(() => {
@ -556,6 +558,7 @@ export default mixins(
this.$store.commit('setStateDirty', false);
});
this.$externalHooks().run('execution.open', { workflowId: data.workflowData.id, workflowName: data.workflowData.name, executionId });
this.$telemetry.track('User opened read-only execution', { workflow_id: data.workflowData.id, execution_mode: data.mode, execution_finished: data.finished });
@ -610,6 +613,11 @@ export default mixins(
this.resetWorkspace();
data.workflow.nodes = CanvasHelpers.getFixedNodesList(data.workflow.nodes);
await this.addNodes(data.workflow.nodes, data.workflow.connections);
if (data.workflow.pinData) {
this.$store.commit('setWorkflowPinData', data.workflow.pinData);
}
this.$nextTick(() => {
this.zoomToFit();
});
@ -678,6 +686,7 @@ export default mixins(
this.$store.commit('setWorkflowId', workflowId);
this.$store.commit('setWorkflowName', {newName: data.name, setStateDirty: false});
this.$store.commit('setWorkflowSettings', data.settings || {});
this.$store.commit('setWorkflowPinData', data.pinData || {});
const tags = (data.tags || []) as ITag[];
this.$store.commit('tags/upsertTags', tags);
@ -1312,6 +1321,10 @@ export default mixins(
});
});
if (workflowData.pinData) {
this.$store.commit('setWorkflowPinData', workflowData.pinData);
}
const tagsEnabled = this.$store.getters['settings/areTagsEnabled'];
if (importTags && tagsEnabled && Array.isArray(workflowData.tags)) {
const allTags: ITag[] = await this.$store.dispatch('tags/fetchAll');
@ -2077,6 +2090,10 @@ export default mixins(
// so if we do not connect we have to save the connection manually
this.$store.commit('addConnection', connectionProperties);
}
setTimeout(() => {
this.addPinDataConnections(this.$store.getters.pinData);
});
},
__removeConnection (connection: [IConnection, IConnection], removeVisualConnection = false) {
if (removeVisualConnection === true) {
@ -2168,6 +2185,14 @@ export default mixins(
await this.addNodes([newNodeData]);
const pinData = this.$store.getters['pinDataByNodeName'](nodeName);
if (pinData) {
this.$store.commit('pinData', {
node: newNodeData,
data: pinData,
});
}
this.$store.commit('setStateDirty', true);
// Automatically deselect all nodes and select the current one and also active
@ -2271,6 +2296,7 @@ export default mixins(
if (connection) {
const output = outputMap[sourceOutputIndex][targetNodeName][targetInputIndex];
if (!output || !output.total) {
CanvasHelpers.resetConnection(connection);
}
@ -2832,7 +2858,7 @@ export default mixins(
}
this.$store.commit('removeAllConnections', {setStateDirty: false});
this.$store.commit('removeAllNodes', {setStateDirty: false});
this.$store.commit('removeAllNodes', { setStateDirty: false, removePinData: true });
// Reset workflow execution data
this.$store.commit('setWorkflowExecutionData', null);
@ -2940,6 +2966,31 @@ export default mixins(
await this.importWorkflowData(workflowData);
}
},
addPinDataConnections(pinData: PinData) {
Object.keys(pinData).forEach((nodeName) => {
// @ts-ignore
const connections = this.instance.getConnections({
source: NODE_NAME_PREFIX + this.getNodeIndex(nodeName),
}) as Connection[];
connections.forEach((connection) => {
CanvasHelpers.addConnectionOutputSuccess(connection, {
total: pinData[nodeName].length,
iterations: 0,
});
});
});
},
removePinDataConnections(pinData: PinData) {
Object.keys(pinData).forEach((nodeName) => {
// @ts-ignore
const connections = this.instance.getConnections({
source: NODE_NAME_PREFIX + this.getNodeIndex(nodeName),
}) as Connection[];
connections.forEach(CanvasHelpers.resetConnection);
});
},
},
async mounted () {
@ -2989,10 +3040,14 @@ export default mixins(
setTimeout(() => {
this.$store.dispatch('users/showPersonalizationSurvey');
this.checkForNewVersions();
this.addPinDataConnections(this.$store.getters.pinData);
}, 0);
});
this.$externalHooks().run('nodeView.mount');
dataPinningEventBus.$on('pin-data', this.addPinDataConnections);
dataPinningEventBus.$on('unpin-data', this.removePinDataConnections);
},
destroyed () {
@ -3002,6 +3057,9 @@ export default mixins(
this.$root.$off('newWorkflow', this.newWorkflow);
this.$root.$off('importWorkflowData', this.onImportWorkflowDataEvent);
this.$root.$off('importWorkflowUrl', this.onImportWorkflowUrlEvent);
dataPinningEventBus.$off('pin-data', this.addPinDataConnections);
dataPinningEventBus.$off('unpin-data', this.removePinDataConnections);
},
});
</script>
@ -3013,8 +3071,8 @@ export default mixins(
position: fixed;
left: $--sidebar-width + $--zoom-menu-margin;
width: 200px;
bottom: 45px;
width: 210px;
bottom: 44px;
line-height: 25px;
color: #444;
padding-right: 5px;
@ -3026,6 +3084,16 @@ export default mixins(
button {
border: var(--border-base);
}
> * {
+ * {
margin-left: var(--spacing-3xs);
}
&:hover {
transform: scale(1.1);
}
}
}
.regular-zoom-menu {
@ -3198,23 +3266,6 @@ export default mixins(
text-align: right;
}
.button-white {
border: none;
padding: 0.3em;
margin: 0 0.1em;
border-radius: 3px;
font-size: 1.2em;
background: #fff;
width: 40px;
height: 40px;
color: #666;
cursor: pointer;
&:hover {
transform: scale(1.1);
}
}
.connection-actions {
&:hover {
display: block !important;

View file

@ -1,3 +1,4 @@
const path = require('path');
const MonacoWebpackPlugin = require('monaco-editor-webpack-plugin');
module.exports = {
@ -27,6 +28,12 @@ module.exports = {
plugins: [
new MonacoWebpackPlugin({ languages: ['javascript', 'json', 'typescript'] }),
],
resolve: {
alias: {
'element-ui/packages/button': path.resolve(__dirname, '..', 'design-system/src/components/N8nButton/overrides/ElButton.vue'),
'element-ui/lib/button': path.resolve(__dirname, '..', 'design-system/src/components/N8nButton/overrides/ElButton.vue'),
},
},
},
css: {
loaderOptions: {

View file

@ -839,6 +839,11 @@ export interface INode {
parameters: INodeParameters;
credentials?: INodeCredentials;
webhookId?: string;
pinData?: IDataObject;
}
export interface PinData {
[nodeName: string]: IDataObject[];
}
export interface INodes {
@ -1323,6 +1328,7 @@ export interface IRunExecutionData {
resultData: {
error?: ExecutionError;
runData: IRunData;
pinData?: PinData;
lastNodeExecuted?: string;
};
executionData?: {
@ -1396,6 +1402,7 @@ export interface IWorkflowBase {
connections: IConnections;
settings?: IWorkflowSettings;
staticData?: IDataObject;
pinData?: PinData;
}
export interface IWorkflowCredentials {
@ -1516,6 +1523,7 @@ export interface INodesGraph {
node_connections: IDataObject[];
nodes: INodesGraphNode;
notes: INotesGraphNode;
is_pinned: boolean;
}
export interface INodesGraphNode {

View file

@ -1060,12 +1060,13 @@ export function getNodeWebhookUrl(
export function getNodeParametersIssues(
nodePropertiesArray: INodeProperties[],
node: INode,
pinDataNodeNames?: string[],
): INodeIssues | null {
const foundIssues: INodeIssues = {};
let propertyIssues: INodeIssues;
if (node.disabled === true) {
// Ignore issues on disabled nodes
if (node.disabled === true || pinDataNodeNames?.includes(node.name)) {
// Ignore issues on disabled and pindata nodes
return null;
}

View file

@ -120,6 +120,7 @@ export function generateNodesGraph(
node_connections: [],
nodes: {},
notes: {},
is_pinned: Object.keys(workflow.pinData ?? {}).length > 0,
};
const nodeNameAndIndex: INodeNameIndex = {};
const webhookNodeNames: string[] = [];

View file

@ -252,6 +252,7 @@ export class Workflow {
checkReadyForExecution(inputData: {
startNode?: string;
destinationNode?: string;
pinDataNodeNames?: string[];
}): IWorfklowIssues | null {
let node: INode;
let nodeType: INodeType | undefined;
@ -287,7 +288,11 @@ export class Workflow {
typeUnknown: true,
};
} else {
nodeIssues = NodeHelpers.getNodeParametersIssues(nodeType.description.properties, node);
nodeIssues = NodeHelpers.getNodeParametersIssues(
nodeType.description.properties,
node,
inputData.pinDataNodeNames,
);
}
if (nodeIssues !== null) {