feat: improve connector popup with grouped OAuth connectors

Active Connectors tab:
- Group OAuth connectors by type (Gmail, Google Drive, etc.)
- Show account count badge on grouped cards
- Show most recent last indexed date across all accounts
- Show non-OAuth connectors individually with active task messages

All Connectors tab:
- Show most recent last indexed date for OAuth connector types
- Check if any account is indexing for OAuth types

Accounts List View:
- Remove document count from individual account cards
- Back button returns to previous tab (not always All Connectors)

General:
- Update handleViewAccountsList to use (connectorType, connectorTitle) signature
- Consistent behavior for viewing accounts from both tabs
This commit is contained in:
CREDO23 2026-01-07 11:40:21 +02:00
parent 9ad1348d6b
commit 3ff87a218d
6 changed files with 193 additions and 93 deletions

View file

@ -205,7 +205,6 @@ export const ConnectorIndicator: FC = () => {
connectors={(allConnectors || []) as SearchSourceConnector[]} connectors={(allConnectors || []) as SearchSourceConnector[]}
indexingConnectorIds={indexingConnectorIds} indexingConnectorIds={indexingConnectorIds}
logsSummary={logsSummary} logsSummary={logsSummary}
documentTypeCounts={documentTypeCounts}
onBack={handleBackFromAccountsList} onBack={handleBackFromAccountsList}
onManage={handleStartEdit} onManage={handleStartEdit}
onAddAccount={() => { onAddAccount={() => {
@ -328,6 +327,7 @@ export const ConnectorIndicator: FC = () => {
searchSpaceId={searchSpaceId} searchSpaceId={searchSpaceId}
onTabChange={handleTabChange} onTabChange={handleTabChange}
onManage={handleStartEdit} onManage={handleStartEdit}
onViewAccountsList={handleViewAccountsList}
/> />
</div> </div>
</div> </div>

View file

@ -679,19 +679,20 @@ export const useConnectorDialog = () => {
// Handle viewing accounts list for OAuth connector type // Handle viewing accounts list for OAuth connector type
const handleViewAccountsList = useCallback( const handleViewAccountsList = useCallback(
(connector: (typeof OAUTH_CONNECTORS)[number]) => { (connectorType: string, connectorTitle: string) => {
if (!searchSpaceId) return; if (!searchSpaceId) return;
setViewingAccountsType({ setViewingAccountsType({
connectorType: connector.connectorType, connectorType,
connectorTitle: connector.title, connectorTitle,
}); });
// Update URL to show accounts view // Update URL to show accounts view, preserving current tab
const url = new URL(window.location.href); const url = new URL(window.location.href);
url.searchParams.set("modal", "connectors"); url.searchParams.set("modal", "connectors");
url.searchParams.set("view", "accounts"); url.searchParams.set("view", "accounts");
url.searchParams.set("connectorType", connector.connectorType); url.searchParams.set("connectorType", connectorType);
// Keep the current tab in URL so we can go back to it
window.history.pushState({ modal: true }, "", url.toString()); window.history.pushState({ modal: true }, "", url.toString());
}, },
[searchSpaceId] [searchSpaceId]
@ -702,7 +703,7 @@ export const useConnectorDialog = () => {
setViewingAccountsType(null); setViewingAccountsType(null);
const url = new URL(window.location.href); const url = new URL(window.location.href);
url.searchParams.set("modal", "connectors"); url.searchParams.set("modal", "connectors");
url.searchParams.set("tab", "all"); // Keep the current tab (don't change it) - just remove view-specific params
url.searchParams.delete("view"); url.searchParams.delete("view");
url.searchParams.delete("connectorType"); url.searchParams.delete("connectorType");
router.replace(url.pathname + url.search, { scroll: false }); router.replace(url.pathname + url.search, { scroll: false });

View file

@ -11,8 +11,8 @@ import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import type { SearchSourceConnector } from "@/contracts/types/connector.types"; import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import type { LogActiveTask, LogSummary } from "@/contracts/types/log.types"; import type { LogActiveTask, LogSummary } from "@/contracts/types/log.types";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { OAUTH_CONNECTORS } from "../constants/connector-constants";
import { getDocumentCountForConnector } from "../utils/connector-document-mapping"; import { getDocumentCountForConnector } from "../utils/connector-document-mapping";
import { getConnectorDisplayName } from "./all-connectors-tab";
interface ActiveConnectorsTabProps { interface ActiveConnectorsTabProps {
searchQuery: string; searchQuery: string;
@ -25,6 +25,7 @@ interface ActiveConnectorsTabProps {
searchSpaceId: string; searchSpaceId: string;
onTabChange: (value: string) => void; onTabChange: (value: string) => void;
onManage?: (connector: SearchSourceConnector) => void; onManage?: (connector: SearchSourceConnector) => void;
onViewAccountsList?: (connectorType: string, connectorTitle: string) => void;
} }
export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
@ -37,6 +38,7 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
searchSpaceId, searchSpaceId,
onTabChange, onTabChange,
onManage, onManage,
onViewAccountsList,
}) => { }) => {
const router = useRouter(); const router = useRouter();
@ -72,38 +74,24 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
const minutesAgo = differenceInMinutes(now, date); const minutesAgo = differenceInMinutes(now, date);
const daysAgo = differenceInDays(now, date); const daysAgo = differenceInDays(now, date);
// Just now (within last minute) if (minutesAgo < 1) return "Just now";
if (minutesAgo < 1) { if (minutesAgo < 60) return `${minutesAgo} ${minutesAgo === 1 ? "minute" : "minutes"} ago`;
return "Just now"; if (isToday(date)) return `Today at ${format(date, "h:mm a")}`;
} if (isYesterday(date)) return `Yesterday at ${format(date, "h:mm a")}`;
if (daysAgo < 7) return `${daysAgo} ${daysAgo === 1 ? "day" : "days"} ago`;
// X minutes ago (less than 1 hour)
if (minutesAgo < 60) {
return `${minutesAgo} ${minutesAgo === 1 ? "minute" : "minutes"} ago`;
}
// Today at [time]
if (isToday(date)) {
return `Today at ${format(date, "h:mm a")}`;
}
// Yesterday at [time]
if (isYesterday(date)) {
return `Yesterday at ${format(date, "h:mm a")}`;
}
// X days ago (less than 7 days)
if (daysAgo < 7) {
return `${daysAgo} ${daysAgo === 1 ? "day" : "days"} ago`;
}
// Full date for older entries
return format(date, "MMM d, yyyy"); return format(date, "MMM d, yyyy");
}; };
// Document types that should be shown as cards (not from connectors) // Get most recent last indexed date from a list of connectors
// These are: EXTENSION (browser extension), FILE (uploaded files), NOTE (editor notes), const getMostRecentLastIndexed = (connectorsList: SearchSourceConnector[]): string | undefined => {
// YOUTUBE_VIDEO (YouTube videos), and CRAWLED_URL (web pages - shown separately even though it can come from WEBCRAWLER_CONNECTOR) return connectorsList.reduce<string | undefined>((latest, c) => {
if (!c.last_indexed_at) return latest;
if (!latest) return c.last_indexed_at;
return new Date(c.last_indexed_at) > new Date(latest) ? c.last_indexed_at : latest;
}, undefined);
};
// Document types that should be shown as standalone cards (not from connectors)
const standaloneDocumentTypes = ["EXTENSION", "FILE", "NOTE", "YOUTUBE_VIDEO", "CRAWLED_URL"]; const standaloneDocumentTypes = ["EXTENSION", "FILE", "NOTE", "YOUTUBE_VIDEO", "CRAWLED_URL"];
// Filter to only show standalone document types that have documents (count > 0) // Filter to only show standalone document types that have documents (count > 0)
@ -119,8 +107,47 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
return doc.label.toLowerCase().includes(searchQuery.toLowerCase()); return doc.label.toLowerCase().includes(searchQuery.toLowerCase());
}); });
// Filter connectors based on search query // Get OAuth connector types set for quick lookup
const filteredConnectors = connectors.filter((connector) => { const oauthConnectorTypes = new Set<string>(OAUTH_CONNECTORS.map((c) => c.connectorType));
// Separate OAuth and non-OAuth connectors
const oauthConnectors = connectors.filter((c) => oauthConnectorTypes.has(c.connector_type));
const nonOauthConnectors = connectors.filter((c) => !oauthConnectorTypes.has(c.connector_type));
// Group OAuth connectors by type
const oauthConnectorsByType = oauthConnectors.reduce(
(acc, connector) => {
const type = connector.connector_type;
if (!acc[type]) {
acc[type] = [];
}
acc[type].push(connector);
return acc;
},
{} as Record<string, SearchSourceConnector[]>
);
// Get display info for OAuth connector type
const getOAuthConnectorTypeInfo = (connectorType: string) => {
const oauthConnector = OAUTH_CONNECTORS.find((c) => c.connectorType === connectorType);
return {
title: oauthConnector?.title || connectorType.replace(/_/g, " ").replace(/connector/gi, "").trim(),
};
};
// Filter OAuth connector types based on search query
const filteredOAuthConnectorTypes = Object.entries(oauthConnectorsByType).filter(([connectorType]) => {
if (!searchQuery) return true;
const searchLower = searchQuery.toLowerCase();
const { title } = getOAuthConnectorTypeInfo(connectorType);
return (
title.toLowerCase().includes(searchLower) ||
connectorType.toLowerCase().includes(searchLower)
);
});
// Filter non-OAuth connectors based on search query
const filteredNonOAuthConnectors = nonOauthConnectors.filter((connector) => {
if (!searchQuery) return true; if (!searchQuery) return true;
const searchLower = searchQuery.toLowerCase(); const searchLower = searchQuery.toLowerCase();
return ( return (
@ -129,18 +156,98 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
); );
}); });
const hasActiveConnectors = filteredOAuthConnectorTypes.length > 0 || filteredNonOAuthConnectors.length > 0;
return ( return (
<TabsContent value="active" className="m-0"> <TabsContent value="active" className="m-0">
{hasSources ? ( {hasSources ? (
<div className="space-y-6"> <div className="space-y-6">
{/* Active Connectors Section */} {/* Active Connectors Section */}
{filteredConnectors.length > 0 && ( {hasActiveConnectors && (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h3 className="text-sm font-semibold text-muted-foreground">Active Connectors</h3> <h3 className="text-sm font-semibold text-muted-foreground">Active Connectors</h3>
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{filteredConnectors.map((connector) => { {/* OAuth Connectors - Grouped by Type */}
{filteredOAuthConnectorTypes.map(([connectorType, typeConnectors]) => {
const { title } = getOAuthConnectorTypeInfo(connectorType);
const isAnyIndexing = typeConnectors.some(
(c: SearchSourceConnector) => indexingConnectorIds.has(c.id)
);
const documentCount = getDocumentCountForConnector(
connectorType,
documentTypeCounts
);
const accountCount = typeConnectors.length;
const mostRecentLastIndexed = getMostRecentLastIndexed(typeConnectors);
const handleManageClick = () => {
if (onViewAccountsList) {
onViewAccountsList(connectorType, title);
} else if (onManage && typeConnectors[0]) {
onManage(typeConnectors[0]);
}
};
return (
<div
key={`oauth-type-${connectorType}`}
className={cn(
"relative flex items-center gap-4 p-4 rounded-xl border border-border transition-all",
isAnyIndexing
? "bg-primary/5 border-primary/20"
: "bg-slate-400/5 dark:bg-white/5 hover:bg-slate-400/10 dark:hover:bg-white/10"
)}
>
{/* Account count badge */}
<div className="absolute -top-2 -right-2 flex h-5 items-center justify-center rounded-md bg-primary px-2 text-[10px] font-semibold text-primary-foreground whitespace-nowrap">
{accountCount > 99 ? "99+" : accountCount} {accountCount === 1 ? "Account" : "Accounts"}
</div>
<div
className={cn(
"flex h-12 w-12 items-center justify-center rounded-lg border shrink-0",
isAnyIndexing
? "bg-primary/10 border-primary/20"
: "bg-slate-400/5 dark:bg-white/5 border-slate-400/5 dark:border-white/5"
)}
>
{getConnectorIcon(connectorType, "size-6")}
</div>
<div className="flex-1 min-w-0">
<p className="text-[14px] font-semibold leading-tight truncate">
{title}
</p>
{isAnyIndexing ? (
<p className="text-[11px] text-primary mt-1 flex items-center gap-1.5">
<Loader2 className="size-3 animate-spin" />
Indexing...
</p>
) : (
<p className="text-[10px] text-muted-foreground mt-1 whitespace-nowrap">
{mostRecentLastIndexed
? `Last indexed: ${formatLastIndexedDate(mostRecentLastIndexed)}`
: "Never indexed"}
</p>
)}
<p className="text-[10px] text-muted-foreground mt-0.5">
{formatDocumentCount(documentCount)}
</p>
</div>
<Button
variant="secondary"
size="sm"
className="h-8 text-[11px] px-3 rounded-lg font-medium bg-white text-slate-700 hover:bg-slate-50 border-0 shadow-xs dark:bg-secondary dark:text-secondary-foreground dark:hover:bg-secondary/80 shrink-0"
onClick={handleManageClick}
>
Manage
</Button>
</div>
);
})}
{/* Non-OAuth Connectors - Individual Cards */}
{filteredNonOAuthConnectors.map((connector) => {
const isIndexing = indexingConnectorIds.has(connector.id); const isIndexing = indexingConnectorIds.has(connector.id);
const activeTask = logsSummary?.active_tasks?.find( const activeTask = logsSummary?.active_tasks?.find(
(task: LogActiveTask) => task.connector_id === connector.id (task: LogActiveTask) => task.connector_id === connector.id
@ -162,7 +269,7 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
> >
<div <div
className={cn( className={cn(
"flex h-12 w-12 items-center justify-center rounded-lg border", "flex h-12 w-12 items-center justify-center rounded-lg border shrink-0",
isIndexing isIndexing
? "bg-primary/10 border-primary/20" ? "bg-primary/10 border-primary/20"
: "bg-slate-400/5 dark:bg-white/5 border-slate-400/5 dark:border-white/5" : "bg-slate-400/5 dark:bg-white/5 border-slate-400/5 dark:border-white/5"
@ -172,7 +279,7 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-[14px] font-semibold leading-tight truncate"> <p className="text-[14px] font-semibold leading-tight truncate">
{getConnectorDisplayName(connector.name)} {connector.name}
</p> </p>
{isIndexing ? ( {isIndexing ? (
<p className="text-[11px] text-primary mt-1 flex items-center gap-1.5"> <p className="text-[11px] text-primary mt-1 flex items-center gap-1.5">
@ -198,7 +305,7 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
<Button <Button
variant="secondary" variant="secondary"
size="sm" size="sm"
className="h-8 text-[11px] px-3 rounded-lg font-medium bg-white text-slate-700 hover:bg-slate-50 border-0 shadow-xs dark:bg-secondary dark:text-secondary-foreground dark:hover:bg-secondary/80" className="h-8 text-[11px] px-3 rounded-lg font-medium bg-white text-slate-700 hover:bg-slate-50 border-0 shadow-xs dark:bg-secondary dark:text-secondary-foreground dark:hover:bg-secondary/80 shrink-0"
onClick={onManage ? () => onManage(connector) : undefined} onClick={onManage ? () => onManage(connector) : undefined}
> >
Manage Manage

View file

@ -40,7 +40,7 @@ interface AllConnectorsTabProps {
onCreateWebcrawler?: () => void; onCreateWebcrawler?: () => void;
onCreateYouTubeCrawler?: () => void; onCreateYouTubeCrawler?: () => void;
onManage?: (connector: SearchSourceConnector) => void; onManage?: (connector: SearchSourceConnector) => void;
onViewAccountsList?: (connector: (typeof OAUTH_CONNECTORS)[number]) => void; onViewAccountsList?: (connectorType: string, connectorTitle: string) => void;
} }
export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
@ -102,25 +102,40 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
{filteredOAuth.map((connector) => { {filteredOAuth.map((connector) => {
const isConnected = connectedTypes.has(connector.connectorType); const isConnected = connectedTypes.has(connector.connectorType);
const isConnecting = connectingId === connector.id; const isConnecting = connectingId === connector.id;
// Find the actual connector object if connected
const actualConnector = // Find all connectors of this type
const typeConnectors =
isConnected && allConnectors isConnected && allConnectors
? allConnectors.find( ? allConnectors.filter(
(c: SearchSourceConnector) => (c: SearchSourceConnector) =>
c.connector_type === connector.connectorType c.connector_type === connector.connectorType
) )
: undefined; : [];
// Get the most recent last_indexed_at across all accounts
const mostRecentLastIndexed = typeConnectors.reduce<string | undefined>(
(latest, c) => {
if (!c.last_indexed_at) return latest;
if (!latest) return c.last_indexed_at;
return new Date(c.last_indexed_at) > new Date(latest) ? c.last_indexed_at : latest;
},
undefined
);
const documentCount = getDocumentCountForConnector( const documentCount = getDocumentCountForConnector(
connector.connectorType, connector.connectorType,
documentTypeCounts documentTypeCounts
); );
const isIndexing =
actualConnector && // Check if any account is currently indexing
indexingConnectorIds?.has(actualConnector.id); const isIndexing = typeConnectors.some(
const activeTask = actualConnector (c) => indexingConnectorIds?.has(c.id)
? getActiveTaskForConnector(actualConnector.id) );
: undefined;
// Get active task from any indexing account
const activeTask = typeConnectors
.map((c) => getActiveTaskForConnector(c.id))
.find((task) => task !== undefined);
return ( return (
<ConnectorCard <ConnectorCard
@ -132,13 +147,13 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
isConnected={isConnected} isConnected={isConnected}
isConnecting={isConnecting} isConnecting={isConnecting}
documentCount={documentCount} documentCount={documentCount}
lastIndexedAt={actualConnector?.last_indexed_at} lastIndexedAt={mostRecentLastIndexed}
isIndexing={isIndexing} isIndexing={isIndexing}
activeTask={activeTask} activeTask={activeTask}
onConnect={() => onConnectOAuth(connector)} onConnect={() => onConnectOAuth(connector)}
onManage={ onManage={
isConnected && onViewAccountsList isConnected && onViewAccountsList
? () => onViewAccountsList(connector) ? () => onViewAccountsList(connector.connectorType, connector.title)
: undefined : undefined
} }
/> />

View file

@ -8,7 +8,6 @@ import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import type { SearchSourceConnector } from "@/contracts/types/connector.types"; import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import type { LogActiveTask, LogSummary } from "@/contracts/types/log.types"; import type { LogActiveTask, LogSummary } from "@/contracts/types/log.types";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { getDocumentCountForConnector } from "../utils/connector-document-mapping";
import { getConnectorDisplayName } from "../tabs/all-connectors-tab"; import { getConnectorDisplayName } from "../tabs/all-connectors-tab";
interface ConnectorAccountsListViewProps { interface ConnectorAccountsListViewProps {
@ -17,27 +16,12 @@ interface ConnectorAccountsListViewProps {
connectors: SearchSourceConnector[]; connectors: SearchSourceConnector[];
indexingConnectorIds: Set<number>; indexingConnectorIds: Set<number>;
logsSummary: LogSummary | undefined; logsSummary: LogSummary | undefined;
documentTypeCounts?: Record<string, number>;
onBack: () => void; onBack: () => void;
onManage: (connector: SearchSourceConnector) => void; onManage: (connector: SearchSourceConnector) => void;
onAddAccount: () => void; onAddAccount: () => void;
isConnecting?: boolean; isConnecting?: boolean;
} }
/**
* Format document count (e.g., "1.2k docs", "500 docs", "1.5M docs")
*/
function formatDocumentCount(count: number | undefined): string {
if (count === undefined || count === 0) return "0 docs";
if (count < 1000) return `${count} docs`;
if (count < 1000000) {
const k = (count / 1000).toFixed(1);
return `${k.replace(/\.0$/, "")}k docs`;
}
const m = (count / 1000000).toFixed(1);
return `${m.replace(/\.0$/, "")}M docs`;
}
/** /**
* Format last indexed date with contextual messages * Format last indexed date with contextual messages
*/ */
@ -76,7 +60,6 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
connectors, connectors,
indexingConnectorIds, indexingConnectorIds,
logsSummary, logsSummary,
documentTypeCounts,
onBack, onBack,
onManage, onManage,
onAddAccount, onAddAccount,
@ -145,10 +128,6 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
const activeTask = logsSummary?.active_tasks?.find( const activeTask = logsSummary?.active_tasks?.find(
(task: LogActiveTask) => task.connector_id === connector.id (task: LogActiveTask) => task.connector_id === connector.id
); );
const documentCount = getDocumentCountForConnector(
connector.connector_type,
documentTypeCounts
);
return ( return (
<div <div
@ -191,9 +170,6 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
: "Never indexed"} : "Never indexed"}
</p> </p>
)} )}
<p className="text-[10px] text-muted-foreground mt-0.5">
{formatDocumentCount(documentCount)}
</p>
</div> </div>
<Button <Button
variant="secondary" variant="secondary"

View file

@ -15,6 +15,7 @@ export const getConnectorTypeDisplay = (type: string): string => {
CLICKUP_CONNECTOR: "ClickUp", CLICKUP_CONNECTOR: "ClickUp",
GOOGLE_CALENDAR_CONNECTOR: "Google Calendar", GOOGLE_CALENDAR_CONNECTOR: "Google Calendar",
GOOGLE_GMAIL_CONNECTOR: "Google Gmail", GOOGLE_GMAIL_CONNECTOR: "Google Gmail",
GOOGLE_DRIVE_CONNECTOR: "Google Drive",
AIRTABLE_CONNECTOR: "Airtable", AIRTABLE_CONNECTOR: "Airtable",
LUMA_CONNECTOR: "Luma", LUMA_CONNECTOR: "Luma",
ELASTICSEARCH_CONNECTOR: "Elasticsearch", ELASTICSEARCH_CONNECTOR: "Elasticsearch",