diff --git a/lib/components/Slack/SlackConnect.tsx b/lib/components/Slack/SlackConnect.tsx new file mode 100644 index 0000000..4a67839 --- /dev/null +++ b/lib/components/Slack/SlackConnect.tsx @@ -0,0 +1,381 @@ +import { useContext, useEffect, useState, useCallback } from 'react'; +import { + Box, + Button, + FormControl, + InputLabel, + MenuItem, + Select, + Typography, + CircularProgress, + Alert, + Stack +} from '@mui/material'; +import { NotificationAPIContext } from '../Provider/context'; +import { User } from '@notificationapi/core/dist/interfaces'; + +interface SlackChannel { + id: string; + name: string; + type: 'channel' | 'user'; +} + +interface SlackConnectProps { + description?: string; + connectButtonText?: string; + editButtonText?: string; + disconnectButtonText?: string; + saveButtonText?: string; + cancelButtonText?: string; + connectedText?: string; + selectChannelText?: string; +} + +export function SlackConnect({ + description = 'Connect your Slack workspace to receive notifications directly in Slack.', + connectButtonText = 'Connect Slack', + editButtonText = 'Edit Channel', + disconnectButtonText = 'Disconnect', + saveButtonText = 'Save', + cancelButtonText = 'Cancel', + connectedText = 'Slack notifications will be sent to:', + selectChannelText = 'Choose a channel or user to receive notifications:' +}: SlackConnectProps = {}) { + const context = useContext(NotificationAPIContext); + const [slackToken, setSlackToken] = useState< + User['slackToken'] | undefined + >(); + const [slackChannel, setSlackChannel] = useState(); + const [channels, setChannels] = useState([]); + const [selectedChannel, setSelectedChannel] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [isEditing, setIsEditing] = useState(false); + + const fetchUserSlackStatus = useCallback(async () => { + if (!context) return; + + try { + setLoading(true); + const client = context.getClient(); + + // Get user's current slack configuration using user.get + const user = await client.user.get(); + + if (user.slackToken) { + setSlackToken(user.slackToken); + } + + if (user.slackChannel) { + setSlackChannel(user.slackChannel); + } + } catch (err) { + console.error('Error fetching Slack status:', err); + // If the endpoint doesn't exist yet, that's okay + } finally { + setLoading(false); + } + }, [context]); + + const loadChannels = useCallback(async () => { + if (!context || !slackToken) return []; + + try { + setLoading(true); + setError(null); + const client = context.getClient(); + + // Get channels and users from Slack + const response = await client.slack.getChannels(); + + // Combine channels and users into a single array + const allOptions: SlackChannel[] = [ + ...(response.channels || []) + .filter((c) => c.id && c.name) + .map((c) => ({ + id: c.id!, + name: c.name!, + type: 'channel' as SlackChannel['type'] + })), + ...(response.users || []) + .filter((u) => u.id && u.name) + .map((u) => ({ + id: u.id!, + name: u.name!, + type: 'user' as SlackChannel['type'] + })) + ]; + + setChannels(allOptions); + return allOptions; + } catch (err) { + console.error('Error loading channels and users:', err); + setError('Failed to load Slack channels and users. Please try again.'); + return []; + } finally { + setLoading(false); + } + }, [context, slackToken]); + + useEffect(() => { + // Fetch the user's current slackToken and slackChannel from the API + fetchUserSlackStatus(); + }, [fetchUserSlackStatus]); + + useEffect(() => { + if (slackToken && !slackChannel && !isEditing) { + loadChannels(); + } + }, [slackToken, slackChannel, isEditing, loadChannels]); + + const handleConnectSlack = async () => { + if (!context) return; + + try { + setLoading(true); + setError(null); + const client = context.getClient(); + + // Generate Slack OAuth URL + const url = await client.slack.getOAuthUrl(); + + // Redirect to Slack OAuth + window.location.href = url; + } catch (err) { + console.error('Error connecting to Slack:', err); + setError('Failed to connect to Slack. Please try again.'); + } finally { + setLoading(false); + } + }; + + const handleSaveChannel = async () => { + if (!context || !selectedChannel) return; + + try { + setLoading(true); + setError(null); + const client = context.getClient(); + + // Find the selected channel info to get its name and type + const channelInfo = channels.find((c) => c.id === selectedChannel); + if (!channelInfo) { + setError('Channel not found. Please try again.'); + return; + } + + // Format the channel as #channelname or @username + const formattedChannel = `${channelInfo.type === 'channel' ? '#' : '@'}${ + channelInfo.name + }`; + + // Set the selected channel with formatted name + await client.slack.setChannel(formattedChannel); + + setSlackChannel(formattedChannel); + setIsEditing(false); + setError(null); + } catch (err) { + console.error('Error saving channel:', err); + setError('Failed to save channel. Please try again.'); + } finally { + setLoading(false); + } + }; + + const handleDisconnect = async () => { + if (!context) return; + + try { + setLoading(true); + setError(null); + const client = context.getClient(); + + // Remove slackToken and slackChannel using identify + await client.identify({ + // @ts-expect-error - null is not assignable to type string + slackToken: null, + // @ts-expect-error - null is not assignable to type string + slackChannel: null + }); + + setSlackToken(undefined); + setSlackChannel(undefined); + setSelectedChannel(''); + setChannels([]); + setIsEditing(false); + } catch (err) { + console.error('Error disconnecting Slack:', err); + setError('Failed to disconnect Slack. Please try again.'); + } finally { + setLoading(false); + } + }; + + const handleEdit = async () => { + setIsEditing(true); + + // Load channels if not already loaded + let channelsList = channels; + if (channels.length === 0) { + channelsList = await loadChannels(); + } + + // Parse the slackChannel to find the matching channel ID + if (slackChannel) { + const isChannel = slackChannel.startsWith('#'); + const channelName = slackChannel.substring(1); // Remove # or @ + const channelType = isChannel ? 'channel' : 'user'; + + // Find the channel ID that matches the name and type + const matchingChannel = channelsList.find( + (c) => c.name === channelName && c.type === channelType + ); + + if (matchingChannel) { + setSelectedChannel(matchingChannel.id); + } + } + }; + + const handleCancelEdit = () => { + setIsEditing(false); + setSelectedChannel(''); + }; + + if (!context) { + return null; + } + + // Show loading state + if (loading && !slackToken && !channels.length) { + return ( + + + + ); + } + + // No Slack token - show connect button + if (!slackToken) { + return ( + + {error && ( + + {error} + + )} + + + {description} + + + + + ); + } + + // Has token but no channel (or editing) + if (!slackChannel || isEditing) { + return ( + + {error && ( + + {error} + + )} + {loading ? ( + + + + ) : ( + + + {selectChannelText} + + + Channel or User + + + + {isEditing && ( + + )} + + + )} + + ); + } + + // Has both token and channel - show connected state + return ( + + + {connectedText} + + + {slackChannel} + + + + + ); +} diff --git a/lib/components/Slack/index.tsx b/lib/components/Slack/index.tsx new file mode 100644 index 0000000..a5a6b8e --- /dev/null +++ b/lib/components/Slack/index.tsx @@ -0,0 +1 @@ +export { SlackConnect } from './SlackConnect'; diff --git a/lib/main.ts b/lib/main.ts index 9d9bf26..fcd004d 100644 --- a/lib/main.ts +++ b/lib/main.ts @@ -14,6 +14,7 @@ export { NotificationPreferencesPopup } from './components/Preferences'; export { NotificationAPIProvider } from './components/Provider'; +export { SlackConnect } from './components/Slack'; // Debug utilities export { createDebugLogger, type DebugLogger } from './utils/debug'; diff --git a/package-lock.json b/package-lock.json index 1fc5868..1bbbc91 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,19 +1,19 @@ { "name": "@notificationapi/react", - "version": "1.4.1", + "version": "1.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@notificationapi/react", - "version": "1.4.1", + "version": "1.5.0", "dependencies": { "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.0", "@fontsource/roboto": "^5.1.1", "@mui/icons-material": "^6.3.1", "@mui/material": "^6.3.1", - "@notificationapi/core": "^0.0.17", + "@notificationapi/core": "^1.0.2", "javascript-time-ago": "^2.5.10", "liquidjs": "^10.14.0", "rc-virtual-list": "^3.11.5", @@ -206,13 +206,14 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.26.2", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", - "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.25.9", + "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", - "picocolors": "^1.0.0" + "picocolors": "^1.1.1" }, "engines": { "node": ">=6.9.0" @@ -345,17 +346,19 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", - "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", - "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", "engines": { "node": ">=6.9.0" } @@ -370,24 +373,26 @@ } }, "node_modules/@babel/helpers": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.0.tgz", - "integrity": "sha512-tbhNuIxNcVb21pInl3ZSjksLCvgdZy9KwJ8brv993QtIVKJBBkYXz4q4ZbAv31GdnC+R90np23L5FbEBlthAEw==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/template": "^7.25.9", - "@babel/types": "^7.26.0" + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.26.3", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.3.tgz", - "integrity": "sha512-WJ/CvmY8Mea8iDXo6a7RK2wbmJITT5fN3BEkRuFlxVyNx8jOKIIhmC4fSkTcPcf8JyavbBwIe6OpiCOBXt/IcA==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "license": "MIT", "dependencies": { - "@babel/types": "^7.26.3" + "@babel/types": "^7.28.5" }, "bin": { "parser": "bin/babel-parser.js" @@ -439,13 +444,14 @@ } }, "node_modules/@babel/template": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.9.tgz", - "integrity": "sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg==", + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.25.9", - "@babel/parser": "^7.25.9", - "@babel/types": "^7.25.9" + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -469,12 +475,13 @@ } }, "node_modules/@babel/types": { - "version": "7.26.3", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.3.tgz", - "integrity": "sha512-vN5p+1kl59GVKMvTHt55NzzmYVxprfJD+ql7U9NFIfKCBkYE55LYtS+WtPlaYOyzydrKI8Nezd+aZextrd+FMA==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.25.9", - "@babel/helper-validator-identifier": "^7.25.9" + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" }, "engines": { "node": ">=6.9.0" @@ -1107,10 +1114,11 @@ } }, "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -1189,10 +1197,11 @@ } }, "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -1359,10 +1368,11 @@ } }, "node_modules/@microsoft/api-extractor/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -1725,9 +1735,9 @@ } }, "node_modules/@notificationapi/core": { - "version": "0.0.17", - "resolved": "https://registry.npmjs.org/@notificationapi/core/-/core-0.0.17.tgz", - "integrity": "sha512-3T1qJVnBDMJisPDzEouYgbJz/Ao1RGbYC+NQ3KQ7nD6IHex5XZ25SSpVlxpavVJzFsxbWrXzKpFRkEdp8bGT1Q==" + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@notificationapi/core/-/core-1.0.2.tgz", + "integrity": "sha512-GvsY+uKRfvTUkAtjmSYQVSz/v9TSvZUHpo22Kfbaso4VZqzyp1WQIIHCrG1Vi9do7F6kEuaoeAGB3kusVdY1uA==" }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", @@ -2676,10 +2686,11 @@ "dev": true }, "node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } @@ -3162,10 +3173,11 @@ } }, "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -4524,10 +4536,11 @@ } }, "node_modules/rimraf/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -4967,10 +4980,11 @@ } }, "node_modules/validator": { - "version": "13.12.0", - "resolved": "https://registry.npmjs.org/validator/-/validator-13.12.0.tgz", - "integrity": "sha512-c1Q0mCiPlgdTVVVIJIrBuxNicYE+t/7oKeI9MWLj3fh/uq2Pxh/3eeWbVZ4OcGW1TUf53At0njHw5SMdA3tmMg==", + "version": "13.15.15", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.15.tgz", + "integrity": "sha512-BgWVbCI72aIQy937xbawcs+hrVaN/CZ2UwutgaJ36hGqRrLNM+f5LUT/YPRbo8IV/ASeFzXszezV+y2+rq3l8A==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.10" } diff --git a/package.json b/package.json index 3e1743f..f1c6b6a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@notificationapi/react", "private": false, - "version": "1.4.1", + "version": "1.5.0", "type": "module", "overrides": { "esbuild": "^0.25.0", @@ -36,6 +36,7 @@ }, "devDependencies": { "@faker-js/faker": "^9.2.0", + "@playwright/test": "^1.56.1", "@types/node": "^20.12.7", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", @@ -48,7 +49,6 @@ "eslint-plugin-react-refresh": "^0.4.6", "faker-js": "^1.0.0", "glob": "^10.4.1", - "@playwright/test": "^1.56.1", "prettier": "^3.3.3", "typescript": "^5.2.2", "vite": "^5.4.21", @@ -67,7 +67,7 @@ "@fontsource/roboto": "^5.1.1", "@mui/icons-material": "^6.3.1", "@mui/material": "^6.3.1", - "@notificationapi/core": "^0.0.17", + "@notificationapi/core": "^1.0.2", "javascript-time-ago": "^2.5.10", "liquidjs": "^10.14.0", "rc-virtual-list": "^3.11.5", diff --git a/src/LiveComponents.tsx b/src/LiveComponents.tsx index 6b81550..51793cd 100644 --- a/src/LiveComponents.tsx +++ b/src/LiveComponents.tsx @@ -6,7 +6,8 @@ import { NotificationCounter, NotificationAPIProvider, NotificationPreferencesPopup, - NotificationPreferencesInline + NotificationPreferencesInline, + SlackConnect } from '../lib/main'; import { Button, @@ -199,6 +200,11 @@ const LiveComponents: React.FC = ({

Preferences Inline:

+ + + +

Slack Connect:

+