feat: add support for manual workflow execution arguments in various components

This commit is contained in:
Simon Larsen
2025-02-26 15:06:45 +00:00
parent cf7c39d96c
commit e9cb8e1604
11 changed files with 202 additions and 144 deletions

View File

@@ -29,7 +29,10 @@ import WorkspaceUserAuthTokenService from "./WorkspaceUserAuthTokenService";
import WorkspaceMessagePayload, {
WorkspaceMessageBlock,
} from "../../Types/Workspace/WorkspaceMessagePayload";
import WorkspaceProjectAuthToken, { MiscData, SlackMiscData } from "../../Models/DatabaseModels/WorkspaceProjectAuthToken";
import WorkspaceProjectAuthToken, {
MiscData,
SlackMiscData,
} from "../../Models/DatabaseModels/WorkspaceProjectAuthToken";
import WorkspaceProjectAuthTokenService from "./WorkspaceProjectAuthTokenService";
import logger from "../Utils/Logger";
@@ -52,14 +55,16 @@ export class Service extends DatabaseService<Model> {
const miscData: MiscData | undefined = data.projectAuthToken.miscData;
if (!miscData) {
throw new BadDataException("Misc data not found in project auth token");
throw new BadDataException("Misc data not found in project auth token");
}
if(data.workspaceType === WorkspaceType.Slack) {
const userId: string = (miscData as SlackMiscData).botUserId;
if (data.workspaceType === WorkspaceType.Slack) {
const userId: string = (miscData as SlackMiscData).botUserId;
if (!userId) {
throw new BadDataException("Bot user ID not found in project auth token");
throw new BadDataException(
"Bot user ID not found in project auth token",
);
}
return userId;
@@ -68,7 +73,6 @@ export class Service extends DatabaseService<Model> {
throw new BadDataException("Workspace type not supported");
}
public async createInviteAndPostToChannelsBasedOnRules(data: {
projectId: ObjectID;
notificationRuleEventType: NotificationRuleEventType;

View File

@@ -74,7 +74,15 @@ export default class OnTriggerBaseModel<
args: JSONObject,
options: RunOptions,
): Promise<RunReturnType> {
const data: JSONObject = args["data"] as JSONObject;
let data: JSONObject = args["data"] as JSONObject;
if (!data) {
data = {};
}
if (args["_id"]) {
data["_id"] = args["_id"];
}
const miscData: JSONObject = (data?.["miscData"] as JSONObject) || {};
@@ -106,14 +114,15 @@ export default class OnTriggerBaseModel<
let select: Select<TBaseModel> = args["select"] as Select<TBaseModel>;
if (select) {
select = JSONFunctions.deserialize(
args["select"] as JSONObject,
) as Select<TBaseModel>;
logger.debug("Select: ");
logger.debug(select);
if (select && typeof select === "string") {
select = JSONFunctions.parse(select) as Select<TBaseModel>;
}
const model: TBaseModel | null = await this.service!.findOneById({
id: new ObjectID(data["_id"] as string),
id: new ObjectID(data["_id"].toString()),
props: {
isRoot: true,
},
@@ -124,8 +133,10 @@ export default class OnTriggerBaseModel<
});
if (!model) {
options.log("Model not found with id " + data["_id"].toString());
throw new BadDataException(
("Model not found with id " + args["_id"]) as string,
("Model not found with id " + data["_id"].toString()) as string,
);
}

View File

@@ -15,7 +15,6 @@ import WorkspaceBase, { WorkspaceChannel } from "../WorkspaceBase";
import WorkspaceType from "../../../../Types/Workspace/WorkspaceType";
export default class SlackUtil extends WorkspaceBase {
public static override async joinChannel(data: {
authToken: string;
channelId: string;
@@ -24,16 +23,17 @@ export default class SlackUtil extends WorkspaceBase {
logger.debug(data);
// Join channel
const response = await API.post(
URL.fromString("https://slack.com/api/conversations.join"),
{
channel: data.channelId,
},
{
Authorization: `Bearer ${data.authToken}`,
['Content-Type']: "application/x-www-form-urlencoded",
},
);
const response: HTTPErrorResponse | HTTPResponse<JSONObject> =
await API.post(
URL.fromString("https://slack.com/api/conversations.join"),
{
channel: data.channelId,
},
{
Authorization: `Bearer ${data.authToken}`,
["Content-Type"]: "application/x-www-form-urlencoded",
},
);
logger.debug("Response from Slack API for joining channel:");
logger.debug(response);
@@ -52,7 +52,6 @@ export default class SlackUtil extends WorkspaceBase {
logger.debug("Channel joined successfully with data:");
logger.debug(data);
}
public static override async inviteUserToChannelByChannelId(data: {
@@ -72,7 +71,7 @@ export default class SlackUtil extends WorkspaceBase {
},
{
Authorization: `Bearer ${data.authToken}`,
['Content-Type']: "application/x-www-form-urlencoded",
["Content-Type"]: "application/x-www-form-urlencoded",
},
);
@@ -99,8 +98,7 @@ export default class SlackUtil extends WorkspaceBase {
channelName: string;
workspaceUserId: string;
}): Promise<void> {
if(data.channelName && data.channelName.startsWith("#")) {
if (data.channelName && data.channelName.startsWith("#")) {
// trim # from channel name
data.channelName = data.channelName.substring(1);
}
@@ -139,9 +137,8 @@ export default class SlackUtil extends WorkspaceBase {
logger.debug(existingWorkspaceChannels);
for (let channelName of data.channelNames) {
// if channel name starts with #, remove it
if(channelName && channelName.startsWith("#")) {
if (channelName && channelName.startsWith("#")) {
channelName = channelName.substring(1);
}
@@ -168,7 +165,6 @@ export default class SlackUtil extends WorkspaceBase {
return workspaceChannels;
}
public static override async getWorkspaceChannelFromChannelName(data: {
authToken: string;
channelName: string;
@@ -176,9 +172,10 @@ export default class SlackUtil extends WorkspaceBase {
logger.debug("Getting workspace channel ID from channel name with data:");
logger.debug(data);
const channels: Dictionary<WorkspaceChannel> = await this.getAllWorkspaceChannels({
authToken: data.authToken,
});
const channels: Dictionary<WorkspaceChannel> =
await this.getAllWorkspaceChannels({
authToken: data.authToken,
});
logger.debug("All workspace channels:");
logger.debug(channels);
@@ -191,7 +188,7 @@ export default class SlackUtil extends WorkspaceBase {
logger.debug("Workspace channel ID obtained:");
logger.debug(channels[data.channelName]!.id);
return channels[data.channelName]!;
return channels[data.channelName]!;
}
public static override async getWorkspaceChannelFromChannelId(data: {
@@ -209,7 +206,7 @@ export default class SlackUtil extends WorkspaceBase {
},
{
Authorization: `Bearer ${data.authToken}`,
['Content-Type']: "application/x-www-form-urlencoded",
["Content-Type"]: "application/x-www-form-urlencoded",
},
);
@@ -262,7 +259,7 @@ export default class SlackUtil extends WorkspaceBase {
{},
{
Authorization: `Bearer ${data.authToken}`,
['Content-Type']: "application/x-www-form-urlencoded",
["Content-Type"]: "application/x-www-form-urlencoded",
},
);
@@ -329,8 +326,7 @@ export default class SlackUtil extends WorkspaceBase {
const channelIdsToPostTo: Array<string> = [];
for (let channelName of data.workspaceMessagePayload.channelNames) {
if(channelName && channelName.startsWith("#")) {
if (channelName && channelName.startsWith("#")) {
// trim # from channel name
channelName = channelName.substring(1);
}
@@ -353,19 +349,18 @@ export default class SlackUtil extends WorkspaceBase {
for (const channelId of channelIdsToPostTo) {
try {
// check if the user is in the channel.
const isUserInChannel = await this.isUserInChannel({
// check if the user is in the channel.
const isUserInChannel: boolean = await this.isUserInChannel({
authToken: data.authToken,
channelId: channelId,
userId: data.userId,
});
if(!isUserInChannel) {
// add user to the channel
if (!isUserInChannel) {
// add user to the channel
await this.joinChannel({
authToken: data.authToken,
channelId: channelId
channelId: channelId,
});
}
@@ -376,7 +371,6 @@ export default class SlackUtil extends WorkspaceBase {
});
logger.debug(`Message sent to channel ID ${channelId} successfully.`);
} catch (e) {
logger.error(`Error sending message to channel ID ${channelId}:`);
logger.error(e);
@@ -401,7 +395,7 @@ export default class SlackUtil extends WorkspaceBase {
},
{
Authorization: `Bearer ${data.authToken}`,
['Content-Type']: "application/json",
["Content-Type"]: "application/json",
},
);
@@ -438,7 +432,7 @@ export default class SlackUtil extends WorkspaceBase {
},
{
Authorization: `Bearer ${data.authToken}`,
['Content-Type']: "application/x-www-form-urlencoded",
["Content-Type"]: "application/x-www-form-urlencoded",
},
);
@@ -520,15 +514,12 @@ export default class SlackUtil extends WorkspaceBase {
return markdownBlock;
}
public static override async isUserInChannel(data: {
authToken: string;
channelId: string;
userId: string;
}): Promise<boolean> {
const members: Array<string> = [];
const members: Array<string> = [];
logger.debug("Checking if user is in channel with data:");
logger.debug(data);
@@ -536,63 +527,61 @@ export default class SlackUtil extends WorkspaceBase {
let cursor: string | undefined = undefined;
do {
// check if the user is in the channel, return true if they are, false if they are not
// check if the user is in the channel, return true if they are, false if they are not
const requestBody: JSONObject = {
channel: data.channelId,
limit: 1000,
};
const requestBody: JSONObject = {
channel: data.channelId,
limit: 1000,
};
if(cursor) {
requestBody["cursor"] = cursor;
}
if (cursor) {
requestBody["cursor"] = cursor;
}
const response: HTTPErrorResponse | HTTPResponse<JSONObject> = await API.post<JSONObject>(
URL.fromString("https://slack.com/api/conversations.members"),
requestBody,
{
Authorization: `Bearer ${data.authToken}`,
['Content-Type']: "application/x-www-form-urlencoded",
},
);
const response: HTTPErrorResponse | HTTPResponse<JSONObject> =
await API.post<JSONObject>(
URL.fromString("https://slack.com/api/conversations.members"),
requestBody,
{
Authorization: `Bearer ${data.authToken}`,
["Content-Type"]: "application/x-www-form-urlencoded",
},
);
logger.debug("Response from Slack API for getting channel members:");
logger.debug(response);
logger.debug("Response from Slack API for getting channel members:");
logger.debug(response);
if (response instanceof HTTPErrorResponse) {
logger.error("Error response from Slack API:");
logger.error(response);
throw response;
}
if (response instanceof HTTPErrorResponse) {
logger.error("Error response from Slack API:");
logger.error(response);
throw response;
}
// check for ok response
// check for ok response
if ((response.jsonData as JSONObject)?.["ok"] !== true) {
logger.error("Invalid response from Slack API:");
logger.error(response.jsonData);
throw new BadRequestException("Invalid response");
}
if ((response.jsonData as JSONObject)?.["ok"] !== true) {
logger.error("Invalid response from Slack API:");
logger.error(response.jsonData);
throw new BadRequestException("Invalid response");
}
// check if the user is in the channel
const membersOnThisPage: Array<string> = (
response.jsonData as JSONObject
)["members"] as Array<string>;
// check if the user is in the channel
const membersOnThisPage: Array<string> = (response.jsonData as JSONObject)["members"] as Array<string>;
members.push(...membersOnThisPage);
members.push(...membersOnThisPage);
cursor = (
(response.jsonData as JSONObject)["response_metadata"] as JSONObject
)?.["next_cursor"] as string;
} while (cursor);
cursor = ((response.jsonData as JSONObject)["response_metadata"] as JSONObject)?.["next_cursor"] as string;
} while(cursor);
if(members.includes(data.userId)) {
if (members.includes(data.userId)) {
return true;
}
return false;
}
public static override getButtonBlock(data: {
@@ -623,17 +612,18 @@ export default class SlackUtil extends WorkspaceBase {
logger.debug("Sending message to channel via incoming webhook with data:");
logger.debug(data);
const apiResult: HTTPResponse<JSONObject> | HTTPErrorResponse | null = await API.post(data.url, {
blocks: [
{
type: "section",
text: {
type: "mrkdwn",
text: `${data.text}`,
const apiResult: HTTPResponse<JSONObject> | HTTPErrorResponse | null =
await API.post(data.url, {
blocks: [
{
type: "section",
text: {
type: "mrkdwn",
text: `${data.text}`,
},
},
},
],
});
],
});
logger.debug("Response from Slack API for sending message via webhook:");
logger.debug(apiResult);

View File

@@ -21,7 +21,6 @@ export interface WorkspaceChannel {
}
export default class WorkspaceBase {
public static async joinChannel(_data: {
authToken: string;
channelId: string;
@@ -78,8 +77,7 @@ export default class WorkspaceBase {
authToken: string;
channelId: string;
workspaceUserId: string;
}): Promise<void> {
}
}): Promise<void> {}
public static async createChannelsIfDoesNotExist(_data: {
authToken: string;

View File

@@ -90,6 +90,7 @@ export default interface ComponentMetadata {
outPorts: Array<Port>;
tableName?: string | undefined;
documentationLink?: Route;
runWorkflowManuallyArguments?: Array<Argument> | undefined;
}
export interface ComponentCategory {

View File

@@ -157,6 +157,16 @@ export default class BaseModelComponent {
iconProp: IconProp.Bolt,
tableName: model.tableName!,
componentType: ComponentType.Trigger,
runWorkflowManuallyArguments: [
{
type: ComponentInputType.Text,
name: `${model.singularName} ID`,
description: `Please provide the ID of the ${model.singularName}, if you wish to run this workflow manually.`,
required: true,
id: "_id",
placeholder: `ID of the ${model.singularName}`,
},
],
arguments: [],
returnValues: [
{
@@ -288,6 +298,16 @@ export default class BaseModelComponent {
iconProp: IconProp.Bolt,
tableName: model.tableName!,
componentType: ComponentType.Trigger,
runWorkflowManuallyArguments: [
{
type: ComponentInputType.Text,
name: `${model.singularName} ID`,
description: `Please provide the ID of the ${model.singularName}, if you wish to run this workflow manually.`,
required: true,
id: "_id",
placeholder: `ID of the ${model.singularName}`,
},
],
arguments: [
{
type: ComponentInputType.Select,
@@ -430,6 +450,16 @@ export default class BaseModelComponent {
iconProp: IconProp.Bolt,
tableName: model.tableName!,
componentType: ComponentType.Trigger,
runWorkflowManuallyArguments: [
{
type: ComponentInputType.Text,
name: `${model.singularName} ID`,
description: `Please provide the ID of the ${model.singularName}, if you wish to run this workflow manually.`,
required: true,
id: "_id",
placeholder: `ID of the ${model.singularName}`,
},
],
arguments: [
{
type: ComponentInputType.Select,

View File

@@ -13,6 +13,7 @@ const components: Array<ComponentMetadata> = [
description: "Run this workflow on particular schedule",
iconProp: IconProp.Clock,
componentType: ComponentType.Trigger,
runWorkflowManuallyArguments: [],
arguments: [
{
type: ComponentInputType.CronTab,

View File

@@ -17,6 +17,32 @@ const components: Array<ComponentMetadata> = [
componentType: ComponentType.Trigger,
documentationLink: Route.fromString("/workflow/docs/Webhook.md"),
arguments: [],
runWorkflowManuallyArguments: [
{
id: "request-headers",
name: "Request Headers",
description: "Request Headers for this request",
type: ComponentInputType.StringDictionary,
required: false,
placeholder: '{"header1": "value1", "header2": "value2", ....}',
},
{
id: "request-params",
name: "Request Query Params",
description: "Request Query Params for this request",
type: ComponentInputType.StringDictionary,
required: false,
placeholder: '{"query1": "value1", "query2": "value2", ....}',
},
{
id: "request-body",
name: "Request Body",
description: "Request Body",
type: ComponentInputType.JSON,
required: false,
placeholder: '{"key1": "value1", "key2": "value2", ....}',
},
],
returnValues: [
{
id: "request-headers",

View File

@@ -3,7 +3,7 @@ import BasicForm, { FormProps } from "../Forms/BasicForm";
import FormValues from "../Forms/Types/FormValues";
import { componentInputTypeToFormFieldType } from "./Utils";
import { JSONObject } from "Common/Types/JSON";
import { NodeDataProp, ReturnValue } from "Common/Types/Workflow/Component";
import { Argument, NodeDataProp } from "Common/Types/Workflow/Component";
import React, {
FunctionComponent,
ReactElement,
@@ -38,34 +38,32 @@ const RunForm: FunctionComponent<ComponentProps> = (
<div className="mb-3 mt-3">
<div className="mt-5 mb-5">
<h2 className="text-base font-medium text-gray-500">
Return Values from Trigger
Run {component.metadata.title}
</h2>
<p className="text-sm font-medium text-gray-400 mb-5">
This workflow has a trigger to get it to run. Since this trigger
returns some values to work. You can pass these return values from
trigger manually and test this workflow.
{component.metadata.description}
</p>
{component.metadata.returnValues &&
component.metadata.returnValues.length === 0 && (
{component.metadata.runWorkflowManuallyArguments &&
component.metadata.runWorkflowManuallyArguments.length === 0 && (
<ErrorMessage
message={
'This workflow trigger does not take any return values. You can run it by clicking the "Run" button below.'
'This workflow trigger does not take any values. You can run it by clicking the "Run" button below.'
}
/>
)}
{component.metadata.returnValues &&
component.metadata.returnValues.length > 0 && (
{component.metadata.runWorkflowManuallyArguments &&
component.metadata.runWorkflowManuallyArguments.length > 0 && (
<BasicForm
hideSubmitButton={true}
ref={formRef}
initialValues={{
...(component.returnValues || {}),
...(component.arguments || {}),
}}
onChange={(values: FormValues<JSONObject>) => {
setComponent({
...component,
returnValues: {
...((component.returnValues as JSONObject) || {}),
arguments: {
...((component.arguments as JSONObject) || {}),
...((values as JSONObject) || {}),
},
});
@@ -74,25 +72,25 @@ const RunForm: FunctionComponent<ComponentProps> = (
setHasFormValidationErrors(hasError);
}}
fields={
component.metadata.returnValues &&
component.metadata.returnValues.map(
(returnValue: ReturnValue) => {
component.metadata.runWorkflowManuallyArguments &&
component.metadata.runWorkflowManuallyArguments.map(
(argument: Argument) => {
return {
title: `${returnValue.name}`,
title: `${argument.name}`,
description: `${
returnValue.required ? "Required" : "Optional"
}. ${returnValue.description}`,
argument.required ? "Required" : "Optional"
}. ${argument.description}`,
field: {
[returnValue.id]: true,
[argument.id]: true,
},
required: returnValue.required,
placeholder: returnValue.placeholder,
required: argument.required,
placeholder: argument.placeholder,
...componentInputTypeToFormFieldType(
returnValue.type,
argument.type,
component.returnValues &&
component.returnValues[returnValue.id]
? component.returnValues[returnValue.id]
component.returnValues[argument.id]
? component.returnValues[argument.id]
: null,
),
};

View File

@@ -312,16 +312,17 @@ const Delete: FunctionComponent<PageComponentProps> = (): ReactElement => {
}}
onRun={async (component: NodeDataProp) => {
try {
const result: HTTPErrorResponse | HTTPResponse<JSONObject> = await API.post(
URL.fromString(WORKFLOW_URL.toString()).addRoute(
"/manual/run/" + modelId.toString(),
),
{
data: component.returnValues,
},
);
const result: HTTPErrorResponse | HTTPResponse<JSONObject> =
await API.post(
URL.fromString(WORKFLOW_URL.toString()).addRoute(
"/manual/run/" + modelId.toString(),
),
{
data: component.arguments,
},
);
if(result instanceof HTTPErrorResponse) {
if (result instanceof HTTPErrorResponse) {
throw result;
}

View File

@@ -23,9 +23,8 @@ export default class ManualAPI {
public async manuallyRunWorkflow(
req: ExpressRequest,
res: ExpressResponse,
next: NextFunction
next: NextFunction,
): Promise<void> {
try {
// add this workflow to the run queue and return the 200 response.
@@ -45,7 +44,6 @@ export default class ManualAPI {
return Response.sendJsonObjectResponse(req, res, {
status: "Scheduled",
});
} catch (err) {
next(err);
}