slack-channel-manager.tsx24.6 KBView on GitHub 'use client';
import { useState, useEffect, useMemo, useRef, forwardRef, useImperativeHandle } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Loader2, Search, Ban, X } from 'lucide-react';
import { useTRPC } from '@/providers/query-provider';
import { Checkbox } from '@/components/ui/checkbox';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import { toast } from 'sonner';
const ChannelRow = function ChannelRow({
channel,
isSelected,
isBlocked,
onToggle,
onBlockToggle,
}: {
channel: { id: string; name: string; isPrivate?: boolean };
isSelected: boolean;
isBlocked: boolean;
onToggle: (id: string) => void;
onBlockToggle: (id: string) => void;
}) {
return (
<div
onClick={(e) => {
if ((e.target as HTMLElement).closest('button')) return;
if (!isBlocked) onToggle(channel.id);
}}
className={`group flex cursor-pointer items-center justify-between rounded-md px-3 py-2 text-sm transition-colors ${
isBlocked
? 'bg-destructive/10 text-muted-foreground'
: isSelected
? 'bg-accent'
: 'hover:bg-accent/50'
}`}
>
<div className="flex flex-1 items-center gap-3">
<Checkbox
id={channel.id}
checked={isSelected}
onCheckedChange={() => !isBlocked && onToggle(channel.id)}
disabled={isBlocked}
className="pointer-events-none"
/>
<div className={`flex flex-1 items-center gap-2 ${isBlocked ? 'line-through' : ''}`}>
<span className="font-medium">#{channel.name}</span>
{channel.isPrivate && (
<Badge variant="outline" className="h-5 px-1 text-[10px]">
Private
</Badge>
)}
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
className={`h-6 w-6 ${isBlocked ? 'text-destructive hover:text-destructive' : 'text-muted-foreground hover:text-destructive opacity-0 group-hover:opacity-100'}`}
onClick={(e) => {
e.stopPropagation();
onBlockToggle(channel.id);
}}
title={isBlocked ? 'Unblock channel' : 'Block channel'}
>
<Ban className="h-3 w-3" />
</Button>
</div>
</div>
);
};
interface SlackChannelManagerProps {
workspaceId: string;
userId?: string; // For admin use - when viewing another user's sync config
}
export interface SlackChannelManagerRef {
saveChanges: () => Promise<void>;
hasChanges: boolean;
getSelectedChannels: () => Set<string>;
getSelectedChannelObjects: () => Array<{ id: string; name: string }>;
}
export const SlackChannelManager = forwardRef<SlackChannelManagerRef, SlackChannelManagerProps>(
({ workspaceId, userId }, ref) => {
const [selectedChannels, setSelectedChannels] = useState<Set<string>>(new Set());
const [blockedChannels, setBlockedChannels] = useState<Set<string>>(new Set());
const [isConnectingChannels, setIsConnectingChannels] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [startsWith, setStartsWith] = useState('');
const [endsWith, setEndsWith] = useState('');
const [autoSyncNewChannels, setAutoSyncNewChannels] = useState(false);
// Tracks which channel IDs were selected by the current pattern so they can be
// removed when the pattern changes (without touching manually-selected channels).
const patternSelectedIdsRef = useRef<Set<string>>(new Set());
const trpc = useTRPC();
const queryClient = useQueryClient();
// Fetch available channels
const { data: channelsData, isLoading: isLoadingChannels } = useQuery({
...trpc.integrations.slack.listChannels.queryOptions({
workspaceId: workspaceId || '',
...(userId && { userId }),
}),
enabled: !!workspaceId,
});
// Fetch connected channels
const { data: connectedChannelsData } = useQuery({
...trpc.integrations.slack.getConnectedChannels.queryOptions({
workspaceId: workspaceId || '',
...(userId && { userId }),
}),
enabled: !!workspaceId,
});
// const { mutateAsync: connectChannels } = useMutation(
// trpc.integrations.slack.connectChannels.mutationOptions(),
// );
// const { mutateAsync: disconnectChannel } = useMutation(
// trpc.integrations.slack.disconnectChannel.mutationOptions(),
// );
const { mutateAsync: updateConfig } = useMutation(
trpc.integrations.slack.updateConfiguration.mutationOptions(),
);
const { data: configData } = useQuery({
...trpc.integrations.slack.getConfiguration.queryOptions({
workspaceId: workspaceId || '',
...(userId && { userId }),
}),
enabled: !!workspaceId,
});
const availableChannels = useMemo(
() =>
(channelsData?.channels || []) as Array<{ id: string; name: string; isPrivate?: boolean }>,
[channelsData?.channels],
);
const connectedChannelIds = useMemo(
() => new Set<string>(connectedChannelsData?.channelIds || []),
[connectedChannelsData?.channelIds],
);
// Load saved configuration and sync with connected channels
// Priority: config > connected channels (if config is empty)
useEffect(() => {
if (!configData?.config && !connectedChannelsData) {
// Neither loaded yet, wait
return;
}
const config = configData?.config;
// Load config values first
const configSelectedIds = config?.selectedChannelIds || [];
const configBlockedIds = config?.blockedChannelIds || [];
// If config has selected channels, use them; otherwise fall back to connected channels
const initialSelectedIds =
configSelectedIds.length > 0 ? configSelectedIds : Array.from(connectedChannelIds);
setSelectedChannels(new Set(initialSelectedIds));
setBlockedChannels(new Set(configBlockedIds || []));
// The stored config holds a list of patterns; this UI edits a single pair.
const pattern = config?.autoSelectPatterns?.[0];
if (pattern) {
setStartsWith(pattern.startsWith || '');
setEndsWith(pattern.endsWith || '');
}
setAutoSyncNewChannels(config?.autoSyncNewChannels ?? false);
}, [configData, connectedChannelsData, connectedChannelIds]);
// Filter channels based on search query, split into linked vs unlinked
const { linkedChannels, unlinkedChannels } = useMemo(() => {
const query = searchQuery.toLowerCase().trim();
const filtered = query
? availableChannels.filter(
(channel: { id: string; name: string }) =>
channel.name.toLowerCase().includes(query) || channel.id.toLowerCase().includes(query),
)
: availableChannels;
return {
linkedChannels: filtered.filter((c) => connectedChannelIds.has(c.id)),
unlinkedChannels: filtered.filter((c) => !connectedChannelIds.has(c.id)),
};
}, [availableChannels, searchQuery, connectedChannelIds]);
const filteredChannels = useMemo(
() => [...linkedChannels, ...unlinkedChannels],
[linkedChannels, unlinkedChannels],
);
// Auto-select channels based on criteria.
// When the pattern changes, channels that matched the OLD pattern are removed
// (unless they were also manually selected) and channels matching the NEW pattern
// are added. patternSelectedIdsRef tracks which IDs the current pattern owns.
useEffect(() => {
const startStr = startsWith.toLowerCase();
const endStr = endsWith.toLowerCase();
const newPatternMatches = new Set<string>();
if (startStr || endStr) {
availableChannels.forEach((channel: { id: string; name: string }) => {
if (blockedChannels.has(channel.id)) return;
const name = channel.name.toLowerCase();
// Both fields filled → AND (must match both); single field → that field alone.
const matchesStart = startStr ? name.startsWith(startStr) : true;
const matchesEnd = endStr ? name.endsWith(endStr) : true;
if (matchesStart && matchesEnd) newPatternMatches.add(channel.id);
});
}
setSelectedChannels((prev) => {
const next = new Set(prev);
// Remove channels that the previous pattern selected but the new one doesn't
patternSelectedIdsRef.current.forEach((id) => {
if (!newPatternMatches.has(id)) next.delete(id);
});
// Add channels the new pattern selects
newPatternMatches.forEach((id) => next.add(id));
return next;
});
patternSelectedIdsRef.current = newPatternMatches;
}, [startsWith, endsWith, blockedChannels, availableChannels]);
const handleChannelToggle = (channelId: string) => {
setSelectedChannels((prev) => {
const next = new Set(prev);
if (next.has(channelId)) {
next.delete(channelId);
} else {
next.add(channelId);
}
return next;
});
};
const handleBlockToggle = (channelId: string) => {
setBlockedChannels((prev) => {
const next = new Set(prev);
if (next.has(channelId)) {
next.delete(channelId);
} else {
next.add(channelId);
// If blocking, remove from selected
setSelectedChannels((selected) => {
const newSelected = new Set(selected);
newSelected.delete(channelId);
return newSelected;
});
}
return next;
});
};
const handleSaveConfiguration = async () => {
try {
setIsConnectingChannels(true);
// Save configuration including selected channels
await updateConfig({
workspaceId,
config: {
selectedChannelIds: Array.from(selectedChannels),
blockedChannelIds: Array.from(blockedChannels),
autoSelectPatterns:
startsWith || endsWith
? [{ startsWith: startsWith || undefined, endsWith: endsWith || undefined }]
: [],
autoSyncNewChannels,
},
...(userId && { userId }),
});
// Invalidate and refetch queries to ensure fresh data
await queryClient.invalidateQueries({
queryKey=[redacted]
workspaceId,
...(userId && { userId }),
}),
});
await queryClient.invalidateQueries({
queryKey=[redacted]
workspaceId,
...(userId && { userId }),
}),
});
} catch (error) {
console.error('[SlackChannelManager] Error saving configuration:', error);
toast.error(
`Failed to save configuration: ${error instanceof Error ? error.message : 'Unknown error'}`,
);
throw error;
} finally {
setIsConnectingChannels(false);
}
};
// Calculate changes by comparing current state with saved config
const hasChanges = useMemo(() => {
if (!configData?.config) {
// No config yet, compare with connected channels
return (
selectedChannels.size !== connectedChannelIds.size ||
Array.from(selectedChannels).some((id) => !connectedChannelIds.has(id)) ||
Array.from(connectedChannelIds).some((id) => !selectedChannels.has(id))
);
}
const config = configData.config;
const configSelected = new Set(config.selectedChannelIds || []);
const configBlocked = new Set(config.blockedChannelIds || []);
const configStartsWith = config.autoSelectPatterns?.[0]?.startsWith || '';
const configEndsWith = config.autoSelectPatterns?.[0]?.endsWith || '';
// Check if selected channels changed
const selectedChanged =
selectedChannels.size !== configSelected.size ||
Array.from(selectedChannels).some((id) => !configSelected.has(id)) ||
Array.from(configSelected).some((id) => !selectedChannels.has(id));
// Check if blocked channels changed
const blockedChanged =
blockedChannels.size !== configBlocked.size ||
Array.from(blockedChannels).some((id) => !configBlocked.has(id)) ||
Array.from(configBlocked).some((id) => !blockedChannels.has(id));
// Check if patterns changed
const patternsChanged = startsWith !== configStartsWith || endsWith !== configEndsWith;
// Check if autoSyncNewChannels changed
const autoSyncChanged = autoSyncNewChannels !== (config.autoSyncNewChannels ?? false);
return selectedChanged || blockedChanged || patternsChanged || autoSyncChanged;
}, [
selectedChannels,
blockedChannels,
startsWith,
endsWith,
autoSyncNewChannels,
configData?.config,
connectedChannelIds,
]);
useImperativeHandle(ref, () => ({
saveChanges: async () => {
await handleSaveConfiguration();
},
hasChanges,
getSelectedChannels: () => new Set(selectedChannels),
getSelectedChannelObjects: () =>
availableChannels.filter((ch: { id: string; name: string }) => selectedChannels.has(ch.id)),
}));
const selectedChannelsList = useMemo(() => {
return availableChannels.filter((ch: { id: string; name: string }) =>
selectedChannels.has(ch.id),
);
}, [availableChannels, selectedChannels]);
const blockedChannelsList = useMemo(() => {
return availableChannels.filter((ch: { id: string; name: string }) =>
blockedChannels.has(ch.id),
);
}, [availableChannels, blockedChannels]);
if (isLoadingChannels) {
return (
<div className="flex items-center justify-center py-4">
<Loader2 className="h-5 w-5 animate-spin" />
<span className="text-muted-foreground ml-2 text-sm">Loading channels...</span>
</div>
);
}
if (availableChannels.length === 0) {
return (
<div className="text-muted-foreground py-4 text-center text-sm">
No channels found. Make sure you have access to at least one Slack channel.
</div>
);
}
return (
<div className="flex h-[520px] gap-6">
{/* Left Column: Selection & Rules */}
<div className="flex flex-1 flex-col gap-4 overflow-hidden">
{/* Smart Selection */}
<div className="bg-muted/20 grid gap-4 rounded-lg border p-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Starts with</Label>
<Input
value={startsWith}
onChange={(e) => setStartsWith(e.target.value)}
placeholder="e.g. cedar-"
/>
<p className="text-muted-foreground text-xs">
Will also automatically add future channels that start with this
</p>
</div>
<div className="space-y-2">
<Label>Ends with</Label>
<Input
value={endsWith}
onChange={(e) => setEndsWith(e.target.value)}
placeholder="e.g. -cedar"
/>
<p className="text-muted-foreground text-xs">
Will also automatically add future channels that end with this
</p>
</div>
</div>
<div className="flex items-center gap-3 border-t pt-3">
<Switch
id="auto-sync-new"
checked={autoSyncNewChannels}
onCheckedChange={setAutoSyncNewChannels}
disabled={!startsWith && !endsWith}
/>
<div>
<Label htmlFor="auto-sync-new" className="cursor-pointer text-sm font-medium">
Automatically sync new channels matching these patterns
</Label>
<p className="text-muted-foreground text-xs">
During each periodic sync, Cedar will discover and sync any new channels matching
the patterns above
</p>
</div>
</div>
</div>
{/* Search Bar */}
<div className="relative">
<Search className="text-muted-foreground absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2" />
<Input
type="text"
placeholder="Search channels..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
<ScrollArea className="flex-1">
<div className="">
{filteredChannels.length === 0 ? (
<div className="text-muted-foreground py-4 text-center text-sm">
{searchQuery.trim()
? `No channels found matching "${searchQuery}"`
: 'No channels available'}
</div>
) : (
<div className="space-y-3">
{/* Already-linked channels */}
{linkedChannels.length > 0 && (
<div>
<div className="mb-1 flex items-center justify-between px-1">
<span className="text-muted-foreground text-xs font-medium uppercase tracking-wide">
Already linked ({linkedChannels.length})
</span>
<button
className="text-muted-foreground hover:text-foreground text-xs underline-offset-2 hover:underline"
onClick={() => {
setSelectedChannels((prev) => {
const next = new Set(prev);
linkedChannels.forEach((c) => next.delete(c.id));
return next;
});
}}
>
Exclude all
</button>
</div>
<div className="space-y-1">
{linkedChannels.map((channel) => {
const isSelected = selectedChannels.has(channel.id);
const isBlocked = blockedChannels.has(channel.id);
return (
<ChannelRow
key=[redacted]
channel={channel}
isSelected={isSelected}
isBlocked={isBlocked}
onToggle={handleChannelToggle}
onBlockToggle={handleBlockToggle}
/>
);
})}
</div>
</div>
)}
{/* Separator */}
{linkedChannels.length > 0 && unlinkedChannels.length > 0 && (
<div className="border-t" />
)}
{/* Other channels */}
{unlinkedChannels.length > 0 && (
<div>
{linkedChannels.length > 0 && (
<div className="mb-1 px-1">
<span className="text-muted-foreground text-xs font-medium uppercase tracking-wide">
Other channels ({unlinkedChannels.length})
</span>
</div>
)}
<div className="space-y-1">
{unlinkedChannels.map((channel) => {
const isSelected = selectedChannels.has(channel.id);
const isBlocked = blockedChannels.has(channel.id);
return (
<ChannelRow
key=[redacted]
channel={channel}
isSelected={isSelected}
isBlocked={isBlocked}
onToggle={handleChannelToggle}
onBlockToggle={handleBlockToggle}
/>
);
})}
</div>
</div>
)}
</div>
)}
</div>
</ScrollArea>
</div>
{/* Right Column: Summary */}
<div className="flex w-[300px] flex-col gap-6 border-l pl-6">
{/* Selected Channels Summary */}
<div className="flex flex-1 flex-col gap-2 overflow-hidden">
<div className="space-y-1">
<div className="flex items-center justify-between">
<h4 className="font-medium">Selected</h4>
<Badge variant="secondary">{selectedChannels.size}</Badge>
</div>
<p className="text-muted-foreground text-xs">Currently selected channels</p>
</div>
<ScrollArea className="flex-1">
{selectedChannelsList.length === 0 ? (
<p className="text-muted-foreground text-sm italic">No channels selected</p>
) : (
<div className="space-y-2 pr-4">
{selectedChannelsList.map((channel: { id: string; name: string }) => (
<div
key=[redacted]
className="flex items-center justify-between rounded-md border px-3 py-2 text-sm"
>
<span className="truncate">#{channel.name}</span>
<Button
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-foreground h-6 w-6"
onClick={() => handleChannelToggle(channel.id)}
>
<X className="h-3 w-3" />
</Button>
</div>
))}
</div>
)}
</ScrollArea>
</div>
{/* Save button */}
{hasChanges && (
<div className="border-t pt-3">
<Button
size="sm"
className="w-full"
onClick={handleSaveConfiguration}
disabled={isConnectingChannels}
>
{isConnectingChannels ? (
<>
<Loader2 className="mr-2 h-3 w-3 animate-spin" />
Saving...
</>
) : (
'Save'
)}
</Button>
</div>
)}
{/* Blocked Channels Summary */}
{blockedChannels.size > 0 && (
<div className="flex flex-1 flex-col gap-2 overflow-hidden border-t pt-4">
<div className="space-y-1">
<div className="flex items-center justify-between">
<h4 className="text-destructive font-medium">Blocked</h4>
<Badge variant="outline" className="border-destructive/50 text-destructive">
{blockedChannels.size}
</Badge>
</div>
<p className="text-muted-foreground text-xs">
Explicitly blocks Cedar from accessing your mail
</p>
</div>
<ScrollArea className="flex-1">
<div className="space-y-2 pr-4">
{blockedChannelsList.map((channel: { id: string; name: string }) => (
<div
key=[redacted]
className="bg-destructive/5 border-destructive/20 flex items-center justify-between rounded-md border px-3 py-2 text-sm"
>
<span className="text-muted-foreground line-through">#{channel.name}</span>
<Button
variant="ghost"
size="icon"
className="text-destructive/50 hover:text-destructive h-6 w-6"
onClick={() => handleBlockToggle(channel.id)}
>
<X className="h-3 w-3" />
</Button>
</div>
))}
</div>
</ScrollArea>
</div>
)}
</div>
</div>
);
},
);
SlackChannelManager.displayName = 'SlackChannelManager';