mirror of
https://github.com/makeplane/plane.git
synced 2026-02-25 04:35:21 +01:00
[SILO-345] [SILO-349] feat: Added intake issue creation with slack shortcuts (#3536)
* feat: added intake issue handler in create new work item * feat: added intake service * feat: modified issue modal and project selection to fit in intake creation * feat: modified block action and view submission to handle intake issues * feat: created intake linkback * feat: added error for intake * fix: intake issue not supporting states and labels * fix: added link addition for issue creation
This commit is contained in:
@@ -1,10 +1,10 @@
|
||||
/** @type {import('ts-jest').JestConfigWithTsJest} **/
|
||||
export default {
|
||||
moduleNameMapper: {
|
||||
'^@/(.*)$': '<rootDir>/src/$1'
|
||||
"^@/(.*)$": "<rootDir>/src/$1",
|
||||
},
|
||||
testEnvironment: 'node',
|
||||
testEnvironment: "node",
|
||||
transform: {
|
||||
'^.+\\.tsx?$': 'ts-jest'
|
||||
}
|
||||
"^.+\\.tsx?$": "ts-jest",
|
||||
},
|
||||
};
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -95,6 +95,7 @@ export type SlackPrivateMetadata<T extends keyof EntityPayloadMapping = keyof En
|
||||
export enum E_MESSAGE_ACTION_TYPES {
|
||||
LINK_WORK_ITEM = "link_work_item",
|
||||
CREATE_NEW_WORK_ITEM = "issue_shortcut",
|
||||
CREATE_INTAKE_ISSUE = "create_intake_issue",
|
||||
DISCONNECT_WORK_ITEM = "disconnect_work_item",
|
||||
ISSUE_WEBLINK_SUBMISSION = "issue_weblink_submission",
|
||||
ISSUE_COMMENT_SUBMISSION = "issue_comment_submission",
|
||||
|
||||
181
apps/silo/src/apps/slack/views/intake-linkback.ts
Normal file
181
apps/silo/src/apps/slack/views/intake-linkback.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import { ExIntakeIssue } from "@plane/sdk";
|
||||
import { env } from "@/env";
|
||||
import { formatTimestampToNaturalLanguage } from "../helpers/format-date";
|
||||
|
||||
// Intake issue statuses based on the Django model
|
||||
export const INTAKE_STATUSES = [
|
||||
{ id: -2, name: "Pending", emoji: "⏳" },
|
||||
{ id: -1, name: "Rejected", emoji: "❌" },
|
||||
{ id: 0, name: "Snoozed", emoji: "😴" },
|
||||
{ id: 1, name: "Accepted", emoji: "✅" },
|
||||
{ id: 2, name: "Duplicate", emoji: "🔄" },
|
||||
];
|
||||
|
||||
export const createSlackIntakeLinkback = (workspaceSlug: string, issue: ExIntakeIssue, showLogo = false) => {
|
||||
const { issue_detail } = issue;
|
||||
|
||||
// Get status info
|
||||
const statusInfo = INTAKE_STATUSES.find((s) => s.id === issue.status) || INTAKE_STATUSES[0];
|
||||
|
||||
const blocks: any[] = [];
|
||||
|
||||
if (showLogo) {
|
||||
blocks.push({
|
||||
type: "context",
|
||||
elements: [
|
||||
{
|
||||
type: "image",
|
||||
image_url: "https://res.cloudinary.com/ddglxo0l3/image/upload/v1732200793/xljpcpmftawmjkv4x61s.png",
|
||||
alt_text: "Plane",
|
||||
},
|
||||
{
|
||||
type: "mrkdwn",
|
||||
text: `*Plane Intake*`,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// Main title with link to issue (if we have the issue detail)
|
||||
const titleText = issue_detail?.project
|
||||
? `<${env.APP_BASE_URL}/${workspaceSlug}/projects/${issue_detail.project}/intake|📥 ${issue_detail.name}>`
|
||||
: `📥 *${issue_detail.name}*`;
|
||||
|
||||
blocks.push({
|
||||
type: "section",
|
||||
text: {
|
||||
type: "mrkdwn",
|
||||
text: titleText,
|
||||
},
|
||||
});
|
||||
|
||||
// Create context elements array with intake-specific information
|
||||
const contextElements: any[] = [];
|
||||
|
||||
// Status information
|
||||
contextElements.push({
|
||||
type: "mrkdwn",
|
||||
text: `*Status:* ${statusInfo.emoji} ${statusInfo.name}`,
|
||||
});
|
||||
|
||||
// Source information
|
||||
if (issue.source) {
|
||||
let sourceText = `*Source:* ${issue.source}`;
|
||||
if (issue.source_email) {
|
||||
sourceText += ` (${issue.source_email})`;
|
||||
}
|
||||
contextElements.push({
|
||||
type: "mrkdwn",
|
||||
text: sourceText,
|
||||
});
|
||||
}
|
||||
|
||||
// External source if available
|
||||
if (issue.external_source) {
|
||||
contextElements.push({
|
||||
type: "mrkdwn",
|
||||
text: `*External Source:* ${issue.external_source}`,
|
||||
});
|
||||
}
|
||||
|
||||
// Project information (if available)
|
||||
if (issue_detail?.project) {
|
||||
contextElements.push({
|
||||
type: "mrkdwn",
|
||||
text: `📋 <${env.APP_BASE_URL}/${workspaceSlug}/projects/${issue_detail.project}/issues|Project>`,
|
||||
});
|
||||
}
|
||||
|
||||
// Add context block for intake-specific info
|
||||
if (contextElements.length > 0) {
|
||||
blocks.push({
|
||||
type: "context",
|
||||
elements: contextElements,
|
||||
});
|
||||
}
|
||||
|
||||
// Issue details context (state, priority, assignees, dates)
|
||||
const issueContextElements: any[] = [];
|
||||
|
||||
// State and priority information
|
||||
if (issue_detail?.state || issue_detail?.priority) {
|
||||
// @ts-expect-error
|
||||
const stateLabel = issue_detail?.state ? `*State:* ${issue_detail.state.name}` : "*State:* Not set";
|
||||
const priorityLabel =
|
||||
issue_detail?.priority && issue_detail.priority !== "none"
|
||||
? `*Priority:* ${titleCaseWord(issue_detail.priority)}`
|
||||
: "*Priority:* Not set";
|
||||
|
||||
issueContextElements.push({
|
||||
type: "mrkdwn",
|
||||
text: `${stateLabel} ${priorityLabel}`,
|
||||
});
|
||||
}
|
||||
|
||||
// Assignees information
|
||||
if (issue_detail?.assignees && issue_detail.assignees.length > 0) {
|
||||
const assigneeCount = issue_detail.assignees.length;
|
||||
const assigneeText = assigneeCount === 1 ? "Assignee" : "Assignees";
|
||||
|
||||
issueContextElements.push({
|
||||
type: "mrkdwn",
|
||||
text: `*${assigneeText}:* ${assigneeCount} assigned`,
|
||||
});
|
||||
}
|
||||
|
||||
// Target date
|
||||
if (issue_detail?.target_date) {
|
||||
issueContextElements.push({
|
||||
type: "plain_text",
|
||||
text: `Target Date: ${formatTimestampToNaturalLanguage(issue_detail.target_date)}`,
|
||||
});
|
||||
}
|
||||
|
||||
// Created date
|
||||
if (issue.created_at) {
|
||||
issueContextElements.push({
|
||||
type: "plain_text",
|
||||
text: `Created: ${formatTimestampToNaturalLanguage(issue.created_at)}`,
|
||||
});
|
||||
}
|
||||
|
||||
// Snoozed until date (for snoozed status)
|
||||
if (issue.status === 0 && issue.snoozed_till) {
|
||||
issueContextElements.push({
|
||||
type: "plain_text",
|
||||
text: `Snoozed until: ${formatTimestampToNaturalLanguage(issue.snoozed_till)}`,
|
||||
});
|
||||
}
|
||||
|
||||
// Add issue context block if there are any elements
|
||||
if (issueContextElements.length > 0) {
|
||||
blocks.push({
|
||||
type: "context",
|
||||
elements: issueContextElements,
|
||||
});
|
||||
}
|
||||
|
||||
// Duplicate issue information (for duplicate status)
|
||||
if (issue.status === 2 && issue.duplicate_to) {
|
||||
blocks.push({
|
||||
type: "context",
|
||||
elements: [
|
||||
{
|
||||
type: "mrkdwn",
|
||||
text: `🔄 *Duplicate of:* <${env.APP_BASE_URL}/${workspaceSlug}/projects/${issue_detail.project}/issues/${issue.duplicate_to}|View Original Issue>`,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
blocks.push({
|
||||
type: "divider",
|
||||
});
|
||||
|
||||
return { blocks };
|
||||
};
|
||||
|
||||
function titleCaseWord(word: string) {
|
||||
if (!word) return word;
|
||||
return word[0].toUpperCase() + word.substr(1).toLowerCase();
|
||||
}
|
||||
@@ -21,15 +21,13 @@ export type IssueModalViewFull = {
|
||||
blocks: IssueModalViewBlocks;
|
||||
};
|
||||
|
||||
export type IssueModalViewBlocks = [
|
||||
StaticSelectInputBlock,
|
||||
PlainTextInputBlock,
|
||||
RichTextInputBlock,
|
||||
StaticSelectInputBlock,
|
||||
StaticSelectInputBlock,
|
||||
MultiExternalSelectInputBlock,
|
||||
...(CheckboxInputBlock | undefined)[],
|
||||
];
|
||||
export type IssueModalViewBlocks = (
|
||||
| StaticSelectInputBlock
|
||||
| PlainTextInputBlock
|
||||
| RichTextInputBlock
|
||||
| MultiExternalSelectInputBlock
|
||||
| CheckboxInputBlock
|
||||
)[];
|
||||
|
||||
export const createIssueModalViewFull = (
|
||||
{
|
||||
@@ -46,34 +44,36 @@ export const createIssueModalViewFull = (
|
||||
title?: string,
|
||||
privateMetadata: string = "{}",
|
||||
showThreadSync: boolean = true,
|
||||
isWorkItem: boolean = true,
|
||||
messageBlocks?: any[] // Add optional messageBlocks parameter
|
||||
): IssueModalViewFull => ({
|
||||
type: "modal",
|
||||
callback_id: E_MESSAGE_ACTION_TYPES.CREATE_NEW_WORK_ITEM,
|
||||
callback_id: isWorkItem ? E_MESSAGE_ACTION_TYPES.CREATE_NEW_WORK_ITEM : E_MESSAGE_ACTION_TYPES.CREATE_INTAKE_ISSUE,
|
||||
private_metadata: privateMetadata,
|
||||
title: {
|
||||
type: "plain_text",
|
||||
text: "Create Issue",
|
||||
text: isWorkItem ? "Create Work Item" : "Create Intake Issue",
|
||||
emoji: true,
|
||||
},
|
||||
submit: {
|
||||
type: "plain_text",
|
||||
text: "Create Issue",
|
||||
text: isWorkItem ? "Create Work Item" : "Create Intake Issue",
|
||||
emoji: true,
|
||||
},
|
||||
close: {
|
||||
type: "plain_text",
|
||||
text: "Discard Issue",
|
||||
text: "Discard",
|
||||
emoji: true,
|
||||
},
|
||||
blocks: [
|
||||
// For intake issues (non-work items), only show title, description, and priority
|
||||
{
|
||||
dispatch_action: true,
|
||||
type: "input",
|
||||
type: "input" as const,
|
||||
element: {
|
||||
type: "static_select",
|
||||
type: "static_select" as const,
|
||||
placeholder: {
|
||||
type: "plain_text",
|
||||
type: "plain_text" as const,
|
||||
text: "Select a Project",
|
||||
emoji: true,
|
||||
},
|
||||
@@ -82,74 +82,78 @@ export const createIssueModalViewFull = (
|
||||
action_id: ACTIONS.PROJECT,
|
||||
},
|
||||
label: {
|
||||
type: "plain_text",
|
||||
type: "plain_text" as const,
|
||||
text: "Project",
|
||||
emoji: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "input",
|
||||
type: "input" as const,
|
||||
element: {
|
||||
type: "plain_text_input",
|
||||
type: "plain_text_input" as const,
|
||||
placeholder: {
|
||||
type: "plain_text",
|
||||
type: "plain_text" as const,
|
||||
text: "Issue Title",
|
||||
},
|
||||
action_id: ACTIONS.ISSUE_TITLE,
|
||||
},
|
||||
label: {
|
||||
type: "plain_text",
|
||||
type: "plain_text" as const,
|
||||
text: "Title",
|
||||
emoji: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "input",
|
||||
type: "input" as const,
|
||||
optional: true,
|
||||
element: {
|
||||
type: "rich_text_input",
|
||||
type: "rich_text_input" as const,
|
||||
action_id: ACTIONS.ISSUE_DESCRIPTION,
|
||||
initial_value: {
|
||||
type: "rich_text",
|
||||
type: "rich_text" as const,
|
||||
elements: extractRichTextElements(title, messageBlocks),
|
||||
},
|
||||
placeholder: {
|
||||
type: "plain_text",
|
||||
type: "plain_text" as const,
|
||||
text: "Issue Description (Optional)",
|
||||
},
|
||||
},
|
||||
label: {
|
||||
type: "plain_text",
|
||||
type: "plain_text" as const,
|
||||
text: "Description",
|
||||
emoji: true,
|
||||
},
|
||||
},
|
||||
...(isWorkItem
|
||||
? [
|
||||
{
|
||||
type: "input" as const,
|
||||
optional: true,
|
||||
element: {
|
||||
type: "static_select" as const,
|
||||
placeholder: {
|
||||
type: "plain_text" as const,
|
||||
text: "Select a State",
|
||||
emoji: true,
|
||||
},
|
||||
options: stateOptions,
|
||||
action_id: ACTIONS.ISSUE_STATE,
|
||||
},
|
||||
label: {
|
||||
type: "plain_text" as const,
|
||||
text: "State",
|
||||
emoji: true,
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
type: "input",
|
||||
type: "input" as const,
|
||||
optional: true,
|
||||
element: {
|
||||
type: "static_select",
|
||||
type: "static_select" as const,
|
||||
placeholder: {
|
||||
type: "plain_text",
|
||||
text: "Select a State",
|
||||
emoji: true,
|
||||
},
|
||||
options: stateOptions,
|
||||
action_id: ACTIONS.ISSUE_STATE,
|
||||
},
|
||||
label: {
|
||||
type: "plain_text",
|
||||
text: "State",
|
||||
emoji: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "input",
|
||||
optional: true,
|
||||
element: {
|
||||
type: "static_select",
|
||||
placeholder: {
|
||||
type: "plain_text",
|
||||
type: "plain_text" as const,
|
||||
text: "Select a Priority (Optional)",
|
||||
emoji: true,
|
||||
},
|
||||
@@ -157,32 +161,36 @@ export const createIssueModalViewFull = (
|
||||
action_id: ACTIONS.ISSUE_PRIORITY,
|
||||
},
|
||||
label: {
|
||||
type: "plain_text",
|
||||
type: "plain_text" as const,
|
||||
text: "Priority",
|
||||
emoji: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "input",
|
||||
optional: true,
|
||||
element: {
|
||||
type: "multi_external_select",
|
||||
placeholder: {
|
||||
type: "plain_text",
|
||||
text: "Labels (Optional)",
|
||||
emoji: true,
|
||||
},
|
||||
min_query_length: 3,
|
||||
action_id: ACTIONS.ISSUE_LABELS,
|
||||
initial_options: [],
|
||||
},
|
||||
label: {
|
||||
type: "plain_text",
|
||||
text: "Labels",
|
||||
emoji: true,
|
||||
},
|
||||
},
|
||||
...(showThreadSync
|
||||
...(isWorkItem
|
||||
? [
|
||||
{
|
||||
type: "input" as const,
|
||||
optional: true,
|
||||
element: {
|
||||
type: "multi_external_select" as const,
|
||||
placeholder: {
|
||||
type: "plain_text" as const,
|
||||
text: "Labels (Optional)",
|
||||
emoji: true,
|
||||
},
|
||||
min_query_length: 3,
|
||||
action_id: ACTIONS.ISSUE_LABELS,
|
||||
initial_options: [],
|
||||
},
|
||||
label: {
|
||||
type: "plain_text" as const,
|
||||
text: "Labels",
|
||||
emoji: true,
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(showThreadSync && isWorkItem
|
||||
? [
|
||||
{
|
||||
type: "input" as const,
|
||||
|
||||
@@ -5,22 +5,24 @@ import { E_MESSAGE_ACTION_TYPES, ShortcutActionPayload } from "../types/types";
|
||||
export const createProjectSelectionModal = (
|
||||
projects: Array<PlainTextOption>,
|
||||
privateMetadata: ShortcutActionPayload,
|
||||
type: EntityTypeValue = ENTITIES.SHORTCUT_PROJECT_SELECTION
|
||||
type: EntityTypeValue = ENTITIES.SHORTCUT_PROJECT_SELECTION,
|
||||
isWorkItem: boolean = true,
|
||||
error: string = ""
|
||||
) => ({
|
||||
type: "modal",
|
||||
callback_id: E_MESSAGE_ACTION_TYPES.CREATE_NEW_WORK_ITEM,
|
||||
callback_id: isWorkItem ? E_MESSAGE_ACTION_TYPES.CREATE_NEW_WORK_ITEM : E_MESSAGE_ACTION_TYPES.CREATE_INTAKE_ISSUE,
|
||||
private_metadata: JSON.stringify({
|
||||
entityType: type,
|
||||
entityPayload: privateMetadata,
|
||||
}),
|
||||
title: {
|
||||
type: "plain_text",
|
||||
text: "Create Issue",
|
||||
text: isWorkItem ? "Create Work Item" : "Create Intake Issue",
|
||||
emoji: true,
|
||||
},
|
||||
close: {
|
||||
type: "plain_text",
|
||||
text: "Discard Issue",
|
||||
text: "Discard",
|
||||
emoji: true,
|
||||
},
|
||||
blocks: [
|
||||
@@ -43,5 +45,18 @@ export const createProjectSelectionModal = (
|
||||
emoji: true,
|
||||
},
|
||||
},
|
||||
...(error
|
||||
? [
|
||||
{
|
||||
type: "context",
|
||||
elements: [
|
||||
{
|
||||
type: "mrkdwn",
|
||||
text: `:warning: *Error:* ${error}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
});
|
||||
|
||||
@@ -2,16 +2,16 @@ import axios from "axios";
|
||||
import { TBlockActionModalPayload, TBlockActionPayload } from "@plane/etl/slack";
|
||||
import { fetchPlaneAssets } from "@/apps/slack/helpers/fetch-plane-data";
|
||||
import { convertToSlackOption, convertToSlackOptions } from "@/apps/slack/helpers/slack-options";
|
||||
import { createIssueModalViewFull } from "@/apps/slack/views";
|
||||
import { createIssueModalViewFull, createProjectSelectionModal } from "@/apps/slack/views";
|
||||
import { CONSTANTS } from "@/helpers/constants";
|
||||
import { logger } from "@/logger";
|
||||
import { getConnectionDetails } from "../../helpers/connection-details";
|
||||
import { ACTIONS, ENTITIES, PLANE_PRIORITIES } from "../../helpers/constants";
|
||||
import { E_MESSAGE_ACTION_TYPES, SlackPrivateMetadata, TSlackConnectionDetails } from "../../types/types";
|
||||
import { getAccountConnectionBlocks } from "../../views/account-connection";
|
||||
import { createCommentModal } from "../../views/create-comment-modal";
|
||||
import { createWebLinkModal } from "../../views/create-weblink-modal";
|
||||
import { createSlackLinkback } from "../../views/issue-linkback";
|
||||
import { getAccountConnectionBlocks } from "../../views/account-connection";
|
||||
|
||||
const shouldSkipActions = (data: TBlockActionPayload) => {
|
||||
const excludedActions = [E_MESSAGE_ACTION_TYPES.CONNECT_ACCOUNT];
|
||||
@@ -247,6 +247,30 @@ async function handleProjectSelectAction(data: TBlockActionModalPayload, details
|
||||
typeof ENTITIES.SHORTCUT_PROJECT_SELECTION
|
||||
>;
|
||||
|
||||
if (data.view.callback_id === E_MESSAGE_ACTION_TYPES.CREATE_INTAKE_ISSUE) {
|
||||
const intakeEnabled = selectedProject.intake_view;
|
||||
if (!intakeEnabled) {
|
||||
const modal = createProjectSelectionModal(
|
||||
convertToSlackOptions(projects.results),
|
||||
{
|
||||
type: ENTITIES.SHORTCUT_PROJECT_SELECTION,
|
||||
message: {
|
||||
text: metadata.entityPayload.message.text,
|
||||
ts: metadata.entityPayload.message.ts,
|
||||
},
|
||||
channel: {
|
||||
id: metadata.entityPayload.channel.id,
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
false,
|
||||
"Intake is not enabled for this project."
|
||||
);
|
||||
await slackService.updateModal(data.view.id, modal);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
metadata.entityType === ENTITIES.SHORTCUT_PROJECT_SELECTION ||
|
||||
metadata.entityType === ENTITIES.COMMAND_PROJECT_SELECTION
|
||||
@@ -260,7 +284,8 @@ async function handleProjectSelectAction(data: TBlockActionModalPayload, details
|
||||
},
|
||||
metadata.entityType === ENTITIES.SHORTCUT_PROJECT_SELECTION ? metadata.entityPayload.message?.text : "",
|
||||
JSON.stringify({ entityType: metadata.entityType, entityPayload: metadata.entityPayload }),
|
||||
metadata.entityPayload.type !== ENTITIES.COMMAND_PROJECT_SELECTION
|
||||
metadata.entityPayload.type !== ENTITIES.COMMAND_PROJECT_SELECTION,
|
||||
data.view.callback_id === E_MESSAGE_ACTION_TYPES.CREATE_NEW_WORK_ITEM
|
||||
);
|
||||
|
||||
await slackService.updateModal(data.view.id, modal);
|
||||
|
||||
@@ -23,7 +23,8 @@ export const handleMessageAction = async (data: TMessageActionPayload) => {
|
||||
await handleLinkWorkItem(data);
|
||||
break;
|
||||
case E_MESSAGE_ACTION_TYPES.CREATE_NEW_WORK_ITEM:
|
||||
await handleCreateNewWorkItem(data);
|
||||
case E_MESSAGE_ACTION_TYPES.CREATE_INTAKE_ISSUE:
|
||||
await handleCreateNewWorkItem(data, data.callback_id === E_MESSAGE_ACTION_TYPES.CREATE_NEW_WORK_ITEM);
|
||||
break;
|
||||
}
|
||||
};
|
||||
@@ -112,7 +113,7 @@ const handleLinkWorkItem = async (data: TMessageActionPayload) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateNewWorkItem = async (data: TMessageActionPayload) => {
|
||||
const handleCreateNewWorkItem = async (data: TMessageActionPayload, isWorkItem: boolean = true) => {
|
||||
// Get the workspace connection for the associated team
|
||||
const details = await getConnectionDetails(data.team.id, {
|
||||
id: data.user.id,
|
||||
@@ -141,17 +142,22 @@ const handleCreateNewWorkItem = async (data: TMessageActionPayload) => {
|
||||
const projects = await planeClient.project.list(workspaceConnection.workspace_slug);
|
||||
const filteredProjects = projects.results.filter((project) => project.is_member === true);
|
||||
const plainTextOptions = convertToSlackOptions(filteredProjects);
|
||||
const modal = createProjectSelectionModal(plainTextOptions, {
|
||||
type: ENTITIES.SHORTCUT_PROJECT_SELECTION,
|
||||
message: {
|
||||
text: data.message.text,
|
||||
ts: data.message.ts,
|
||||
blocks: data.message.blocks,
|
||||
const modal = createProjectSelectionModal(
|
||||
plainTextOptions,
|
||||
{
|
||||
type: ENTITIES.SHORTCUT_PROJECT_SELECTION,
|
||||
message: {
|
||||
text: data.message.text,
|
||||
ts: data.message.ts,
|
||||
blocks: data.message.blocks,
|
||||
},
|
||||
channel: {
|
||||
id: data.channel.id,
|
||||
},
|
||||
},
|
||||
channel: {
|
||||
id: data.channel.id,
|
||||
},
|
||||
});
|
||||
undefined,
|
||||
isWorkItem
|
||||
);
|
||||
|
||||
try {
|
||||
const res = await slackService.openModal(data.trigger_id, modal);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { credentials } from "amqplib";
|
||||
import { E_INTEGRATION_KEYS } from "@plane/etl/core";
|
||||
import { ContentParser } from "@plane/etl/parser";
|
||||
import { TSlackIssueEntityData, TViewSubmissionPayload } from "@plane/etl/slack";
|
||||
import { IssueWithExpanded } from "@plane/sdk";
|
||||
import { IssueWithExpanded, PlaneUser } from "@plane/sdk";
|
||||
import { env } from "@/env";
|
||||
import { CONSTANTS } from "@/helpers/constants";
|
||||
import { downloadFile } from "@/helpers/utils";
|
||||
@@ -13,10 +14,13 @@ import { getSlackContentParser } from "../../helpers/content-parser";
|
||||
import { parseIssueFormData, parseLinkWorkItemFormData } from "../../helpers/parse-issue-form";
|
||||
import {
|
||||
E_MESSAGE_ACTION_TYPES,
|
||||
ParsedIssueData,
|
||||
ShortcutActionPayload,
|
||||
SlackPrivateMetadata,
|
||||
TSlackConnectionDetails,
|
||||
TSlackWorkspaceConnectionConfig,
|
||||
} from "../../types/types";
|
||||
import { createSlackIntakeLinkback } from "../../views/intake-linkback";
|
||||
import { createSlackLinkback } from "../../views/issue-linkback";
|
||||
import { createLinkIssueModalView } from "../../views/link-issue-modal";
|
||||
|
||||
@@ -41,6 +45,7 @@ export const handleViewSubmission = async (data: TViewSubmissionPayload) => {
|
||||
case E_MESSAGE_ACTION_TYPES.ISSUE_WEBLINK_SUBMISSION:
|
||||
return await handleIssueWeblinkViewSubmission(details, data);
|
||||
case E_MESSAGE_ACTION_TYPES.CREATE_NEW_WORK_ITEM:
|
||||
case E_MESSAGE_ACTION_TYPES.CREATE_INTAKE_ISSUE:
|
||||
return await handleCreateNewWorkItemViewSubmission(details, data);
|
||||
default:
|
||||
logger.error("Unknown view submission callback id:", data.view.callback_id);
|
||||
@@ -292,7 +297,7 @@ export const handleCreateNewWorkItemViewSubmission = async (
|
||||
details: TSlackConnectionDetails,
|
||||
data: TViewSubmissionPayload
|
||||
) => {
|
||||
const { workspaceConnection, slackService, planeClient, botCredentials } = details;
|
||||
const { workspaceConnection, slackService, planeClient } = details;
|
||||
|
||||
try {
|
||||
const parsedData = parseIssueFormData(data.view.state.values);
|
||||
@@ -309,8 +314,7 @@ export const handleCreateNewWorkItemViewSubmission = async (
|
||||
|
||||
const slackUser = await slackService.getUserInfo(data.user.id);
|
||||
const members = await planeClient.users.listAllUsers(workspaceConnection.workspace_slug);
|
||||
const member = members.find((m: any) => m.email === slackUser?.user.profile.email);
|
||||
|
||||
const member = members.find((m: PlaneUser) => m.email === slackUser?.user.profile.email);
|
||||
const userMap = new Map<string, string>();
|
||||
config.userMap?.forEach((user) => {
|
||||
userMap.set(user.slackUser, user.planeUserId);
|
||||
@@ -321,72 +325,12 @@ export const handleCreateNewWorkItemViewSubmission = async (
|
||||
teamDomain: data.team.domain,
|
||||
});
|
||||
|
||||
let parsedDescription: string;
|
||||
|
||||
try {
|
||||
parsedDescription = await parser.toPlaneHtml(parsedData.description ?? "<p></p>");
|
||||
} catch (error) {
|
||||
logger.error("[SLACK] Error parsing issue description:", error);
|
||||
// Fallback to the original description or a safe default
|
||||
parsedDescription = parsedData.description ?? "<p></p>";
|
||||
}
|
||||
|
||||
const issue = await planeClient.issue.create(workspaceConnection.workspace_slug, parsedData.project, {
|
||||
name: parsedData.title,
|
||||
description_html: parsedDescription,
|
||||
created_by: member?.id,
|
||||
state: parsedData.state,
|
||||
priority: parsedData.priority,
|
||||
labels: parsedData.labels,
|
||||
});
|
||||
|
||||
const issueWithFields = await planeClient.issue.getIssueWithFields(
|
||||
workspaceConnection.workspace_slug,
|
||||
parsedData.project,
|
||||
issue.id,
|
||||
["state", "project", "assignees", "labels"]
|
||||
);
|
||||
|
||||
const states = await planeClient.state.list(workspaceConnection.workspace_slug, issue.project);
|
||||
|
||||
const linkBack = createSlackLinkback(
|
||||
workspaceConnection.workspace_slug,
|
||||
issueWithFields,
|
||||
states.results,
|
||||
parsedData.enableThreadSync || false
|
||||
);
|
||||
|
||||
// Step 6: Send the appropriate response based on entity type
|
||||
if (metadata.entityType === ENTITIES.SHORTCUT_PROJECT_SELECTION) {
|
||||
/* For shortcut project selection,
|
||||
- Send a thread message to the channel
|
||||
- Create a thread connection if thread sync is enabled
|
||||
Hence, we need to handle the shortcut project selection in a separate function
|
||||
*/
|
||||
await handleShortcutProjectSelection(
|
||||
slackService,
|
||||
apiClient,
|
||||
workspaceConnection,
|
||||
planeClient,
|
||||
parsedData,
|
||||
metadata as SlackPrivateMetadata<typeof ENTITIES.SHORTCUT_PROJECT_SELECTION>,
|
||||
linkBack,
|
||||
issue,
|
||||
botCredentials
|
||||
);
|
||||
} else if (metadata.entityType === ENTITIES.COMMAND_PROJECT_SELECTION) {
|
||||
/* For command project selection,
|
||||
- Send a message to the response_url, directly to the channel
|
||||
Hence, we need to handle the command project selection in a separate function
|
||||
*/
|
||||
await handleCommandProjectSelection(
|
||||
slackService,
|
||||
metadata as SlackPrivateMetadata<typeof ENTITIES.COMMAND_PROJECT_SELECTION>,
|
||||
linkBack
|
||||
);
|
||||
if (data.view.callback_id === E_MESSAGE_ACTION_TYPES.CREATE_INTAKE_ISSUE) {
|
||||
await createIntakeIssueFromViewSubmission(parsedData, details, metadata, member, details, parser);
|
||||
} else {
|
||||
await createWorkItemFromViewSubmission(data.team.domain, parsedData, details, metadata, member, details, parser);
|
||||
}
|
||||
} catch (error: any) {
|
||||
// Handle any errors
|
||||
const isPermissionError = error?.detail?.includes(CONSTANTS.NO_PERMISSION_ERROR);
|
||||
if (isPermissionError) {
|
||||
logger.error("Permission error in handleCreateNewWorkItemViewSubmission:", error);
|
||||
@@ -397,8 +341,140 @@ export const handleCreateNewWorkItemViewSubmission = async (
|
||||
}
|
||||
};
|
||||
|
||||
async function createIntakeIssueFromViewSubmission(
|
||||
parsedData: ParsedIssueData,
|
||||
credentials: TSlackConnectionDetails,
|
||||
metadata: SlackPrivateMetadata,
|
||||
member: PlaneUser | undefined,
|
||||
details: TSlackConnectionDetails,
|
||||
parser: ContentParser
|
||||
) {
|
||||
const { workspaceConnection, planeClient, slackService } = details;
|
||||
let parsedDescription: string;
|
||||
|
||||
try {
|
||||
parsedDescription = await parser.toPlaneHtml(parsedData.description ?? "<p></p>");
|
||||
} catch (error) {
|
||||
logger.error("[SLACK] Error parsing issue description:", error);
|
||||
// Fallback to the original description or a safe default
|
||||
parsedDescription = parsedData.description ?? "<p></p>";
|
||||
}
|
||||
|
||||
const issue = await planeClient.intake.create(workspaceConnection.workspace_slug, parsedData.project, {
|
||||
issue: {
|
||||
name: parsedData.title,
|
||||
description_html: parsedDescription,
|
||||
created_by: member?.id,
|
||||
state: parsedData.state,
|
||||
priority: parsedData.priority || "none",
|
||||
labels: parsedData.labels || [],
|
||||
project: parsedData.project,
|
||||
},
|
||||
});
|
||||
|
||||
const linkBack = createSlackIntakeLinkback(workspaceConnection.workspace_slug, issue, false);
|
||||
|
||||
if (metadata.entityPayload.type === ENTITIES.SHORTCUT_PROJECT_SELECTION) {
|
||||
const payload = metadata.entityPayload as ShortcutActionPayload;
|
||||
const channelId = payload.channel.id;
|
||||
const messageTs = payload.message.ts;
|
||||
if (messageTs) {
|
||||
await slackService.sendThreadMessage(
|
||||
channelId,
|
||||
messageTs,
|
||||
{
|
||||
text: "Intake issue created successfully. ✅",
|
||||
blocks: linkBack.blocks,
|
||||
},
|
||||
issue,
|
||||
false
|
||||
);
|
||||
} else {
|
||||
logger.error("No message ts found in entity payload");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function createWorkItemFromViewSubmission(
|
||||
teamDomain: string,
|
||||
parsedData: ParsedIssueData,
|
||||
credentials: TSlackConnectionDetails,
|
||||
metadata: SlackPrivateMetadata,
|
||||
member: PlaneUser | undefined,
|
||||
details: TSlackConnectionDetails,
|
||||
parser: ContentParser
|
||||
) {
|
||||
const { workspaceConnection, slackService, planeClient } = details;
|
||||
let parsedDescription: string;
|
||||
|
||||
try {
|
||||
parsedDescription = await parser.toPlaneHtml(parsedData.description ?? "<p></p>");
|
||||
} catch (error) {
|
||||
logger.error("[SLACK] Error parsing issue description:", error);
|
||||
// Fallback to the original description or a safe default
|
||||
parsedDescription = parsedData.description ?? "<p></p>";
|
||||
}
|
||||
|
||||
const issue = await planeClient.issue.create(workspaceConnection.workspace_slug, parsedData.project, {
|
||||
name: parsedData.title,
|
||||
description_html: parsedDescription,
|
||||
created_by: member?.id,
|
||||
state: parsedData.state,
|
||||
priority: parsedData.priority,
|
||||
labels: parsedData.labels,
|
||||
});
|
||||
|
||||
const issueWithFields = await planeClient.issue.getIssueWithFields(
|
||||
workspaceConnection.workspace_slug,
|
||||
parsedData.project,
|
||||
issue.id,
|
||||
["state", "project", "assignees", "labels"]
|
||||
);
|
||||
|
||||
const states = await planeClient.state.list(workspaceConnection.workspace_slug, issue.project);
|
||||
|
||||
const linkBack = createSlackLinkback(
|
||||
workspaceConnection.workspace_slug,
|
||||
issueWithFields,
|
||||
states.results,
|
||||
parsedData.enableThreadSync || false
|
||||
);
|
||||
|
||||
// Step 6: Send the appropriate response based on entity type
|
||||
if (metadata.entityType === ENTITIES.SHORTCUT_PROJECT_SELECTION) {
|
||||
/* For shortcut project selection,
|
||||
- Send a thread message to the channel
|
||||
- Create a thread connection if thread sync is enabled
|
||||
Hence, we need to handle the shortcut project selection in a separate function
|
||||
*/
|
||||
await handleShortcutProjectSelection(
|
||||
teamDomain,
|
||||
slackService,
|
||||
apiClient,
|
||||
workspaceConnection,
|
||||
planeClient,
|
||||
parsedData,
|
||||
metadata as SlackPrivateMetadata<typeof ENTITIES.SHORTCUT_PROJECT_SELECTION>,
|
||||
linkBack,
|
||||
issue,
|
||||
credentials
|
||||
);
|
||||
} else if (metadata.entityType === ENTITIES.COMMAND_PROJECT_SELECTION) {
|
||||
/* For command project selection,
|
||||
- Send a message to the response_url, directly to the channel
|
||||
Hence, we need to handle the command project selection in a separate function
|
||||
*/
|
||||
await handleCommandProjectSelection(
|
||||
slackService,
|
||||
metadata as SlackPrivateMetadata<typeof ENTITIES.COMMAND_PROJECT_SELECTION>,
|
||||
linkBack
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function for shortcut project selection handling
|
||||
async function handleShortcutProjectSelection(
|
||||
teamDomain: string,
|
||||
slackService: any,
|
||||
apiClient: any,
|
||||
workspaceConnection: any,
|
||||
@@ -437,6 +513,11 @@ async function handleShortcutProjectSelection(
|
||||
await Promise.all(fileUploadPromises);
|
||||
}
|
||||
}
|
||||
// Attach link of the slack thread to the issue
|
||||
const title = "Connected to Slack thread";
|
||||
const link = `https://${teamDomain}.slack.com/archives/${metadata.entityPayload.channel.id}/p${metadata.entityPayload.message?.ts}`;
|
||||
|
||||
await planeClient.issue.createLink(workspaceConnection.workspace_slug, parsedData.project, issue.id, title, link);
|
||||
}
|
||||
|
||||
// Send thread message
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export enum CONSTANTS {
|
||||
NO_PERMISSION_ERROR = "do not have permission",
|
||||
NO_PERMISSION_ERROR_MESSAGE = "You don't have permission to access this resource.",
|
||||
INTAKE_NOT_ENABLED_ERROR = "Intake is not enabled for this project. Please contact your administrator.",
|
||||
SOMETHING_WENT_WRONG = "Something went wrong. Please try again.",
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import { UserService } from "@/services/user.service";
|
||||
|
||||
// types
|
||||
import { ClientOptions } from "@/types/types";
|
||||
import { AssetService } from "./services";
|
||||
import { AssetService, IntakeService } from "./services";
|
||||
|
||||
export class Client {
|
||||
options: ClientOptions;
|
||||
@@ -33,6 +33,7 @@ export class Client {
|
||||
issueProperty: IssuePropertyService;
|
||||
issuePropertyOption: IssuePropertyOptionService;
|
||||
issuePropertyValue: IssuePropertyValueService;
|
||||
intake: IntakeService;
|
||||
|
||||
constructor(options: ClientOptions) {
|
||||
this.options = options;
|
||||
@@ -50,5 +51,6 @@ export class Client {
|
||||
this.issueProperty = new IssuePropertyService(options);
|
||||
this.issuePropertyOption = new IssuePropertyOptionService(options);
|
||||
this.issuePropertyValue = new IssuePropertyValueService(options);
|
||||
this.intake = new IntakeService(options);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./api.service";
|
||||
export * from "./issue.service";
|
||||
export * from "./asset.service";
|
||||
export * from "./intake.service";
|
||||
|
||||
43
packages/sdk/src/services/intake.service.ts
Normal file
43
packages/sdk/src/services/intake.service.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { TInboxIssueStatus, TInboxIssueWithPagination } from "@plane/types";
|
||||
import { APIService } from "@/services/api.service";
|
||||
// types
|
||||
import { ExIntakeIssue, ExIssue } from "@/types/types";
|
||||
// constants
|
||||
|
||||
export type IntakeIssueCreatePayload = {
|
||||
issue: Partial<ExIssue>;
|
||||
};
|
||||
|
||||
export class IntakeService extends APIService {
|
||||
/**
|
||||
* Get all intake issues for a project
|
||||
*/
|
||||
async list(
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
params?: {
|
||||
status?: TInboxIssueStatus | TInboxIssueStatus[];
|
||||
cursor?: string;
|
||||
per_page?: number;
|
||||
}
|
||||
): Promise<TInboxIssueWithPagination> {
|
||||
return this.get(`/api/v1/workspaces/${workspaceSlug}/projects/${projectId}/intake-issues/`, {
|
||||
params,
|
||||
})
|
||||
.then((response) => response.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new intake issue
|
||||
*/
|
||||
async create(workspaceSlug: string, projectId: string, payload: IntakeIssueCreatePayload): Promise<ExIntakeIssue> {
|
||||
return this.post(`/api/v1/workspaces/${workspaceSlug}/projects/${projectId}/intake-issues/`, payload)
|
||||
.then((response) => response.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -183,6 +183,29 @@ export type ExIssueAttachment = {
|
||||
external_source: string;
|
||||
};
|
||||
|
||||
export type ExIntakeIssue<T = ExIssue> = {
|
||||
id: string;
|
||||
issue_detail: T;
|
||||
inbox: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
deleted_at: string | null;
|
||||
status: number;
|
||||
snoozed_till: string | null;
|
||||
source: string;
|
||||
source_email: string | null;
|
||||
external_source: string | null;
|
||||
external_id: string | null;
|
||||
created_by: string;
|
||||
updated_by: string | null;
|
||||
project: string;
|
||||
workspace: string;
|
||||
intake: string;
|
||||
issue: string;
|
||||
duplicate_to: string | null;
|
||||
extra: Record<string, any>;
|
||||
}
|
||||
|
||||
/* ----------------- Project Type --------------------- */
|
||||
type IProject = {
|
||||
id: string;
|
||||
@@ -206,6 +229,7 @@ type IProject = {
|
||||
issue_views_view: boolean;
|
||||
page_view: boolean;
|
||||
inbox_view: boolean;
|
||||
intake_view: boolean;
|
||||
is_time_tracking_enabled: boolean;
|
||||
is_issue_type_enabled: boolean;
|
||||
cover_image: string;
|
||||
|
||||
Reference in New Issue
Block a user