Skip to content

Commit f2ea969

Browse files
committed
feat(discord): add work item notifications
1 parent d40d6d0 commit f2ea969

23 files changed

Lines changed: 2075 additions & 33 deletions

File tree

Lines changed: 350 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,350 @@
1+
/**
2+
* Copyright (c) 2023-present Plane Software, Inc. and contributors
3+
* SPDX-License-Identifier: AGPL-3.0-only
4+
* See the LICENSE file for details.
5+
*/
6+
7+
import { useEffect, useMemo, useState } from "react";
8+
import { Trash2 } from "lucide-react";
9+
import { Button } from "@plane/propel/button";
10+
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
11+
import type {
12+
IDiscordConfiguration,
13+
IDiscordConfigurationUpdate,
14+
IDiscordMemberMapping,
15+
TDiscordEventKey,
16+
} from "@plane/types";
17+
import { Checkbox, CustomSelect, Input, ToggleSwitch } from "@plane/ui";
18+
// hooks
19+
import { useInstance } from "@/hooks/store";
20+
21+
type Props = {
22+
config: IDiscordConfiguration;
23+
};
24+
25+
const EVENT_OPTIONS: { key: TDiscordEventKey; label: string; description: string }[] = [
26+
{
27+
key: "work_item.created",
28+
label: "Work item created",
29+
description: "Send a message when a work item is created.",
30+
},
31+
{
32+
key: "work_item.assignee_added",
33+
label: "Assignee added",
34+
description: "Send a message and mention newly assigned mapped members.",
35+
},
36+
{
37+
key: "work_item.completed",
38+
label: "Work item completed",
39+
description: "Send a message when a work item moves into a completed state.",
40+
},
41+
];
42+
43+
const DISCORD_USER_ID_PATTERN = /^\d{17,20}$/;
44+
45+
export function DiscordConfigForm({ config }: Props) {
46+
const { discordMembers, fetchDiscordWorkspaceMembers, sendDiscordTestMessage, updateDiscordConfiguration } =
47+
useInstance();
48+
const [enabled, setEnabled] = useState(config.enabled);
49+
const [workspaceId, setWorkspaceId] = useState<string | null>(config.workspace_id);
50+
const [webhookUrl, setWebhookUrl] = useState("");
51+
const [enabledEvents, setEnabledEvents] = useState<TDiscordEventKey[]>(config.enabled_events);
52+
const [memberMappings, setMemberMappings] = useState<IDiscordMemberMapping[]>(config.member_mappings);
53+
const [error, setError] = useState<string | null>(null);
54+
const [isSaving, setIsSaving] = useState(false);
55+
const [isTesting, setIsTesting] = useState(false);
56+
const [isLoadingMembers, setIsLoadingMembers] = useState(false);
57+
58+
useEffect(() => {
59+
if (!workspaceId) return;
60+
setIsLoadingMembers(true);
61+
fetchDiscordWorkspaceMembers(workspaceId)
62+
.catch(() => setError("Unable to load members for the selected workspace."))
63+
.finally(() => setIsLoadingMembers(false));
64+
}, [fetchDiscordWorkspaceMembers, workspaceId]);
65+
66+
const selectedWorkspace = config.workspaces.find((workspace) => workspace.id === workspaceId);
67+
const mappedPlaneUserIds = useMemo(
68+
() => new Set(memberMappings.map((mapping) => mapping.plane_user_id)),
69+
[memberMappings]
70+
);
71+
const availableMembers = discordMembers.filter((member) => !mappedPlaneUserIds.has(member.id));
72+
const memberNames = useMemo(
73+
() => new Map(discordMembers.map((member) => [member.id, member.display_name])),
74+
[discordMembers]
75+
);
76+
77+
const handleWorkspaceChange = (nextWorkspaceId: string) => {
78+
if (nextWorkspaceId === workspaceId) return;
79+
setWorkspaceId(nextWorkspaceId);
80+
setMemberMappings([]);
81+
setError(null);
82+
};
83+
84+
const toggleEvent = (eventKey: TDiscordEventKey) => {
85+
setEnabledEvents((current) =>
86+
current.includes(eventKey) ? current.filter((key) => key !== eventKey) : [...current, eventKey]
87+
);
88+
};
89+
90+
const addMapping = (planeUserId: string) => {
91+
if (!planeUserId || mappedPlaneUserIds.has(planeUserId)) return;
92+
setMemberMappings((current) => [...current, { plane_user_id: planeUserId, discord_user_id: "" }]);
93+
};
94+
95+
const updateMapping = (planeUserId: string, discordUserId: string) => {
96+
setMemberMappings((current) =>
97+
current.map((mapping) =>
98+
mapping.plane_user_id === planeUserId ? { ...mapping, discord_user_id: discordUserId.trim() } : mapping
99+
)
100+
);
101+
};
102+
103+
const validate = () => {
104+
if (enabled && !workspaceId) return "Select a workspace before enabling Discord notifications.";
105+
if (enabled && !webhookUrl.trim() && !config.webhook_configured)
106+
return "Enter a Discord Incoming Webhook URL before enabling the integration.";
107+
if (webhookUrl.trim() && !webhookUrl.trim().startsWith("https://"))
108+
return "The Discord Webhook URL must use HTTPS.";
109+
if (memberMappings.some((mapping) => !DISCORD_USER_ID_PATTERN.test(mapping.discord_user_id)))
110+
return "Each Discord User ID must contain 17 to 20 digits.";
111+
if (new Set(memberMappings.map((mapping) => mapping.discord_user_id)).size !== memberMappings.length)
112+
return "Each Discord User ID can only be mapped once.";
113+
return null;
114+
};
115+
116+
const handleSave = async () => {
117+
const validationError = validate();
118+
if (validationError) {
119+
setError(validationError);
120+
return;
121+
}
122+
123+
const payload: IDiscordConfigurationUpdate = {
124+
enabled,
125+
workspace_id: workspaceId,
126+
enabled_events: enabledEvents,
127+
member_mappings: memberMappings,
128+
...(webhookUrl.trim() ? { webhook_url: webhookUrl.trim() } : {}),
129+
};
130+
setError(null);
131+
setIsSaving(true);
132+
try {
133+
await updateDiscordConfiguration(payload);
134+
setWebhookUrl("");
135+
setToast({
136+
type: TOAST_TYPE.SUCCESS,
137+
title: "Discord settings saved",
138+
message: "New matching work item events will use this configuration.",
139+
});
140+
} catch (requestError) {
141+
const message =
142+
requestError && typeof requestError === "object" && "error" in requestError
143+
? String(requestError.error)
144+
: "Unable to save Discord settings.";
145+
setError(message);
146+
} finally {
147+
setIsSaving(false);
148+
}
149+
};
150+
151+
const handleTest = async () => {
152+
if (!webhookUrl.trim() && !config.webhook_configured) {
153+
setError("Enter or save a Discord Incoming Webhook URL before sending a test message.");
154+
return;
155+
}
156+
setError(null);
157+
setIsTesting(true);
158+
try {
159+
await sendDiscordTestMessage(webhookUrl.trim() || undefined);
160+
setToast({
161+
type: TOAST_TYPE.SUCCESS,
162+
title: "Test message sent",
163+
message: "Discord accepted the Plane test message.",
164+
});
165+
} catch (requestError) {
166+
const message =
167+
requestError && typeof requestError === "object" && "error" in requestError
168+
? String(requestError.error)
169+
: "Discord did not accept the test message.";
170+
setError(message);
171+
setToast({ type: TOAST_TYPE.ERROR, title: "Test message failed", message });
172+
} finally {
173+
setIsTesting(false);
174+
}
175+
};
176+
177+
return (
178+
<div className="max-w-4xl space-y-8">
179+
<section className="space-y-5 border-b border-subtle pb-8">
180+
<div className="flex items-center justify-between gap-6">
181+
<div>
182+
<h2 className="text-15 font-medium text-primary">Discord notifications</h2>
183+
<p className="mt-1 text-13 text-tertiary">Enable event delivery for one Plane workspace.</p>
184+
</div>
185+
<ToggleSwitch value={enabled} onChange={() => setEnabled((current) => !current)} size="sm" />
186+
</div>
187+
188+
<div className="grid grid-cols-1 gap-5 md:grid-cols-2">
189+
<div className="flex min-w-0 flex-col gap-1.5">
190+
<label className="text-13 text-tertiary" htmlFor="discord-workspace">
191+
Workspace
192+
</label>
193+
<CustomSelect
194+
value={workspaceId ?? ""}
195+
label={selectedWorkspace?.name ?? "Select a workspace"}
196+
onChange={handleWorkspaceChange}
197+
buttonClassName="w-full rounded-md border-subtle"
198+
input
199+
>
200+
{config.workspaces.map((workspace) => (
201+
<CustomSelect.Option key={workspace.id} value={workspace.id} className="w-full">
202+
{workspace.name}
203+
</CustomSelect.Option>
204+
))}
205+
</CustomSelect>
206+
</div>
207+
208+
<div className="flex min-w-0 flex-col gap-1.5">
209+
<label className="text-13 text-tertiary" htmlFor="discord-webhook-url">
210+
Discord Incoming Webhook URL
211+
</label>
212+
<Input
213+
id="discord-webhook-url"
214+
type="password"
215+
value={webhookUrl}
216+
onChange={(event) => setWebhookUrl(event.target.value)}
217+
placeholder={
218+
config.webhook_configured
219+
? "Webhook configured - enter a replacement"
220+
: "https://discord.com/api/webhooks/..."
221+
}
222+
autoComplete="new-password"
223+
/>
224+
<p className="text-11 text-tertiary">
225+
{config.webhook_configured
226+
? "The saved Webhook is hidden. Leave this empty to keep it."
227+
: "Create an Incoming Webhook in the target Discord channel."}
228+
</p>
229+
</div>
230+
</div>
231+
</section>
232+
233+
<section className="space-y-4 border-b border-subtle pb-8">
234+
<div>
235+
<h2 className="text-15 font-medium text-primary">Events</h2>
236+
<p className="mt-1 text-13 text-tertiary">Choose which work item events are sent.</p>
237+
</div>
238+
<div className="grid grid-cols-1 gap-5 md:grid-cols-2">
239+
{EVENT_OPTIONS.map((option) => (
240+
<div key={option.key} className="flex items-start gap-2">
241+
<Checkbox
242+
id={`discord-event-${option.key}`}
243+
checked={enabledEvents.includes(option.key)}
244+
onChange={() => toggleEvent(option.key)}
245+
/>
246+
<label className="cursor-pointer" htmlFor={`discord-event-${option.key}`}>
247+
<span className="block text-13 text-primary">{option.label}</span>
248+
<span className="mt-0.5 block text-11 text-tertiary">{option.description}</span>
249+
</label>
250+
</div>
251+
))}
252+
</div>
253+
</section>
254+
255+
<section className="space-y-4">
256+
<div>
257+
<h2 className="text-15 font-medium text-primary">Member mappings</h2>
258+
<p className="mt-1 text-13 text-tertiary">
259+
Map Plane users to Discord User IDs so assignment messages can mention the right people.
260+
</p>
261+
</div>
262+
263+
<div className="max-w-md">
264+
<CustomSelect
265+
value=""
266+
label={isLoadingMembers ? "Loading members..." : "Add a Plane member"}
267+
onChange={addMapping}
268+
buttonClassName="w-full rounded-md border-subtle"
269+
disabled={!workspaceId || isLoadingMembers || availableMembers.length === 0}
270+
input
271+
>
272+
{availableMembers.map((member) => (
273+
<CustomSelect.Option key={member.id} value={member.id} className="w-full">
274+
{member.display_name}
275+
</CustomSelect.Option>
276+
))}
277+
</CustomSelect>
278+
</div>
279+
280+
{memberMappings.length > 0 ? (
281+
<div className="overflow-x-auto border-y border-subtle">
282+
<div className="grid min-w-[720px] grid-cols-[1fr_1.35fr_1fr_40px] gap-4 border-b border-subtle px-3 py-2 text-11 font-medium text-tertiary">
283+
<span>Plane member</span>
284+
<span>Plane User ID</span>
285+
<span>Discord User ID</span>
286+
<span className="sr-only">Actions</span>
287+
</div>
288+
{memberMappings.map((mapping) => (
289+
<div
290+
key={mapping.plane_user_id}
291+
className="grid min-w-[720px] grid-cols-[1fr_1.35fr_1fr_40px] items-center gap-4 border-b border-subtle px-3 py-3 last:border-b-0"
292+
>
293+
<span className="truncate text-13 text-primary">
294+
{memberNames.get(mapping.plane_user_id) ?? "Unavailable member"}
295+
</span>
296+
<Input value={mapping.plane_user_id} disabled className="font-mono text-11" />
297+
<Input
298+
value={mapping.discord_user_id}
299+
onChange={(event) => updateMapping(mapping.plane_user_id, event.target.value)}
300+
placeholder="123456789012345678"
301+
inputMode="numeric"
302+
aria-label={`Discord User ID for ${memberNames.get(mapping.plane_user_id) ?? mapping.plane_user_id}`}
303+
/>
304+
<button
305+
type="button"
306+
title="Remove mapping"
307+
aria-label={`Remove mapping for ${memberNames.get(mapping.plane_user_id) ?? mapping.plane_user_id}`}
308+
className="flex size-8 items-center justify-center text-tertiary hover:text-danger-primary focus-visible:outline-2 focus-visible:outline-offset-2"
309+
onClick={() =>
310+
setMemberMappings((current) =>
311+
current.filter((item) => item.plane_user_id !== mapping.plane_user_id)
312+
)
313+
}
314+
>
315+
<Trash2 className="size-4" />
316+
</button>
317+
</div>
318+
))}
319+
</div>
320+
) : (
321+
<p className="py-3 text-13 text-tertiary">No members are mapped yet.</p>
322+
)}
323+
</section>
324+
325+
{error && (
326+
<div
327+
role="alert"
328+
className="border-danger-primary border-l-2 bg-danger-subtle px-3 py-2 text-13 text-danger-primary"
329+
>
330+
{error}
331+
</div>
332+
)}
333+
334+
<div className="flex flex-wrap items-center gap-3 border-t border-subtle pt-5">
335+
<Button variant="primary" size="lg" onClick={handleSave} loading={isSaving} disabled={isSaving || isTesting}>
336+
Save changes
337+
</Button>
338+
<Button
339+
variant="secondary"
340+
size="lg"
341+
onClick={handleTest}
342+
loading={isTesting}
343+
disabled={isSaving || isTesting || (!config.webhook_configured && !webhookUrl.trim())}
344+
>
345+
Send test message
346+
</Button>
347+
</div>
348+
</div>
349+
);
350+
}

0 commit comments

Comments
 (0)