|
| 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