From 3d4609d4a5e62609ac6f42f420655424cb7afc0c Mon Sep 17 00:00:00 2001 From: Sophia Mersmann Date: Tue, 18 Feb 2025 09:05:34 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=8E=89=20(admin)=20share=20newly=20create?= =?UTF-8?q?d=20DIs=20in=20Slack?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example-full | 3 + adminSiteClient/CreateDataInsightModal.tsx | 124 ++++++++++++++++++++- adminSiteClient/admin.scss | 11 +- adminSiteServer/apiRouter.ts | 4 + adminSiteServer/apiRoutes/slack.ts | 39 +++++++ settings/clientSettings.ts | 3 + 6 files changed, 178 insertions(+), 6 deletions(-) create mode 100644 adminSiteServer/apiRoutes/slack.ts diff --git a/.env.example-full b/.env.example-full index 3194c47388..9a65bf6941 100644 --- a/.env.example-full +++ b/.env.example-full @@ -57,3 +57,6 @@ ALGOLIA_SECRET_KEY= # optional ALGOLIA_INDEXING=false # optional DATA_API_URL= # optional + +SLACK_BOT_OAUTH_TOKEN= # optional +SLACK_DI_PITCHES_CHANNEL_ID= # optional; #data-insight-pitches channel id diff --git a/adminSiteClient/CreateDataInsightModal.tsx b/adminSiteClient/CreateDataInsightModal.tsx index 3ec4ea5076..f2f371a8b0 100644 --- a/adminSiteClient/CreateDataInsightModal.tsx +++ b/adminSiteClient/CreateDataInsightModal.tsx @@ -30,10 +30,14 @@ import cx from "classnames" import { fetchFigmaProvidedImageUrl, ImageUploadResponse, + makeImageSrc, uploadImageFromSourceUrl, } from "./imagesHelpers" import { AdminAppContext } from "./AdminAppContext" -import { GRAPHER_DYNAMIC_THUMBNAIL_URL } from "../settings/clientSettings" +import { + GRAPHER_DYNAMIC_THUMBNAIL_URL, + SLACK_DI_PITCHES_CHANNEL_ID, +} from "../settings/clientSettings" import { LoadingImage } from "./ReuploadImageForDataInsightModal" import { ApiChartViewOverview } from "../adminShared/AdminTypes" import { @@ -44,6 +48,7 @@ import { RequiredBy, } from "@ourworldindata/utils" import { match } from "ts-pattern" +import { Checkbox } from "antd/lib" const DEFAULT_RUNNING_MESSAGE: Record = { createDI: "Creating data insight...", @@ -51,6 +56,7 @@ const DEFAULT_RUNNING_MESSAGE: Record = { loadFigmaImage: "Loading Figma image...", suggestAltText: "Suggesting alt text...", setTopicTags: "Setting topic tags...", + sendSlackMessage: "Sending Slack message...", } as const const DEFAULT_SUCCESS_MESSAGE: Record = { @@ -59,6 +65,7 @@ const DEFAULT_SUCCESS_MESSAGE: Record = { loadFigmaImage: "Figma image loaded successfully", suggestAltText: "Alt text suggested successfully", setTopicTags: "Topic tags assigned", + sendSlackMessage: "Slack message sent", } as const const DEFAULT_ERROR_MESSAGE: Record = { @@ -67,6 +74,7 @@ const DEFAULT_ERROR_MESSAGE: Record = { loadFigmaImage: "Loading Figma image failed", suggestAltText: "Suggesting alt text failed", setTopicTags: "Setting topic tags failed", + sendSlackMessage: "Sending Slack message failed", } as const type Task = @@ -75,6 +83,7 @@ type Task = | "loadFigmaImage" | "suggestAltText" | "setTopicTags" + | "sendSlackMessage" type Progress = | { status: "idle" } @@ -90,6 +99,7 @@ type FormFieldName = | "figmaUrl" | "imageFilename" | "imageAltText" + | "slackNote" type ImageFormFieldName = "imageFilename" | "imageAltText" type FormData = Partial< @@ -152,9 +162,13 @@ export function CreateDataInsightModal(props: { "uploadImage", "loadFigmaImage", "suggestAltText", - "setTopicTags" + "setTopicTags", + "sendSlackMessage" ) + const [shouldSendMessageToSlack, setShouldSendMessageToSlack] = + useState(true) + // loaded from Figma if a Figma URL is provided const [figmaImageUrl, setFigmaImageUrl] = useState() @@ -263,6 +277,7 @@ export function CreateDataInsightModal(props: { setProgress("createDI", "running") + let cloudflareImageId: string | undefined if (imageUrl && isValidForImageUpload(formData)) { setProgress("uploadImage", "running") @@ -276,6 +291,8 @@ export function CreateDataInsightModal(props: { "Not attempted since image upload failed" ) return + } else { + cloudflareImageId = response.image.cloudflareId ?? undefined } } catch (error) { const errorMessage = @@ -309,6 +326,22 @@ export function CreateDataInsightModal(props: { } } + // Send a message to Slack if requested + if (shouldSendMessageToSlack && cloudflareImageId) { + setProgress("sendSlackMessage", "running") + try { + await sendDataInsightToSlack({ + formData, + imageUrl: makeImageSrc(cloudflareImageId, 1250), + }) + setProgress("sendSlackMessage", "success") + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error) + setProgress("sendSlackMessage", "failure", errorMessage) + } + } + props.onFinish?.(createResponse) } @@ -373,6 +406,40 @@ export function CreateDataInsightModal(props: { ) } + const sendDataInsightToSlack = async ({ + formData, + imageUrl, + }: { + formData: FormDataWithTitle + imageUrl: string + }) => { + const { title, slackNote, authors } = formData + + let text = `*${title}*` + if (slackNote) text += `\n\n${slackNote}` + if (authors) text += `\n\nby ${authors}` + + const blocks = [ + { + type: "section", + text: { type: "mrkdwn", text }, + }, + { + type: "image", + image_url: imageUrl, + alt_text: formData.imageAltText, + }, + ] + + const payload = { + blocks, + channel: SLACK_DI_PITCHES_CHANNEL_ID, + username: "Data insight bot", + } + + void admin.requestJSON(`/api/slack/sendMessage`, payload, "POST") + } + const fetchFigmaImage = async (figmaUrl: string) => { setProgress("loadFigmaImage", "running") try { @@ -592,7 +659,6 @@ export function CreateDataInsightModal(props: { }, ]} /> - -

Image preview

@@ -665,6 +730,31 @@ export function CreateDataInsightModal(props: { )} + {imageUrl && ( +

+ { + setShouldSendMessageToSlack( + e.target.checked + ) + }} + > + Share data insight in the #data-insight-pitches + channel + + {shouldSendMessageToSlack && ( + + + + )} +

+ )} + {showFeedbackBox({ formData, progress, imageUrl }) && (

This data insight will be created by:

@@ -685,6 +775,13 @@ export function CreateDataInsightModal(props: { progress={progress.setTopicTags} /> + +
)} @@ -924,6 +1021,25 @@ function TopicTagsFeedback({ ) } +function SendMessageToSlackFeedback({ + shouldSend, + progress, +}: { + shouldSend: boolean + progress: Progress +}) { + if (!shouldSend) return null + return ( +

+ + The newly created data insight will be shared in the + #data-insight-pitches channel. + + +

+ ) +} + function FeedbackTag({ progress }: { progress: Progress }) { if (progress.status === "idle") return null diff --git a/adminSiteClient/admin.scss b/adminSiteClient/admin.scss index 7317cc1510..31118f4a3c 100644 --- a/adminSiteClient/admin.scss +++ b/adminSiteClient/admin.scss @@ -1369,8 +1369,12 @@ main:not(.ChartEditorPage):not(.GdocsEditPage) { } } + .slackNote { + margin-top: 12px; + } + .image-preview { - margin-top: 24px; + margin: 24px 0; h3 { font-size: 1em; @@ -1391,7 +1395,7 @@ main:not(.ChartEditorPage):not(.GdocsEditPage) { } p { - margin: 0; + margin: 12px 0 0 0; } ul { @@ -1401,7 +1405,10 @@ main:not(.ChartEditorPage):not(.GdocsEditPage) { li { line-height: 1.5; + } + li, + p { // add a little margin before the feedback tag span:first-of-type { margin-right: 12px; diff --git a/adminSiteServer/apiRouter.ts b/adminSiteServer/apiRouter.ts index 534c64485f..5c81760f5c 100644 --- a/adminSiteServer/apiRouter.ts +++ b/adminSiteServer/apiRouter.ts @@ -134,6 +134,7 @@ import { getAllDataInsightIndexItems, } from "./apiRoutes/dataInsights.js" import { getFigmaImageUrl } from "./apiRoutes/figma.js" +import { sendMessageToSlack } from "./apiRoutes/slack.js" const apiRouter = new FunctionalRouter() @@ -464,6 +465,9 @@ getRouteWithROTransaction( // Figma routes getRouteWithROTransaction(apiRouter, "/figma/image", getFigmaImageUrl) +// Slack routes +postRouteWithRWTransaction(apiRouter, "/slack/sendMessage", sendMessageToSlack) + // Deploy helpers apiRouter.get("/deploys.json", async () => ({ deploys: await new DeployQueueServer().getDeploys(), diff --git a/adminSiteServer/apiRoutes/slack.ts b/adminSiteServer/apiRoutes/slack.ts new file mode 100644 index 0000000000..084264270f --- /dev/null +++ b/adminSiteServer/apiRoutes/slack.ts @@ -0,0 +1,39 @@ +import e from "express" +import * as db from "../../db/db.js" +import { Request } from "../authentication.js" +import { SLACK_BOT_OAUTH_TOKEN } from "../../settings/serverSettings" +import { JsonError } from "@ourworldindata/types" + +export async function sendMessageToSlack( + req: Request, + _res: e.Response>, + _trx: db.KnexReadWriteTransaction +) { + const url = "https://slack.com/api/chat.postMessage" + + const { channel, blocks, username } = req.body + + if (!channel) throw new JsonError("Channel missing") + if (!blocks) throw new JsonError("Blocks missing") + + const data = { channel, blocks, username } + + const fetchData = { + method: "POST", + body: JSON.stringify(data), + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${SLACK_BOT_OAUTH_TOKEN}`, + }, + } + + const response = await fetch(url, fetchData) + + if (!response.ok) { + throw new JsonError( + `Slack API error: ${response.status} ${response.statusText}` + ) + } + + return { success: true } +} diff --git a/settings/clientSettings.ts b/settings/clientSettings.ts index c2558e6673..be6bba02f7 100644 --- a/settings/clientSettings.ts +++ b/settings/clientSettings.ts @@ -117,3 +117,6 @@ export const FEATURE_FLAGS: Set = new Set( featureFlagsRaw.includes(key) ) as FeatureFlagFeature[] ) + +export const SLACK_DI_PITCHES_CHANNEL_ID: string = + process.env.SLACK_DI_PITCHES_CHANNEL_ID ?? ""