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[]}
indexingConnectorIds={indexingConnectorIds}
logsSummary={logsSummary}
documentTypeCounts={documentTypeCounts}
onBack={handleBackFromAccountsList}
onManage={handleStartEdit}
onAddAccount={() => {
@ -317,18 +316,19 @@ export const ConnectorIndicator: FC = () => {
/>
</TabsContent>
<ActiveConnectorsTab
searchQuery={searchQuery}
hasSources={hasSources}
totalSourceCount={totalSourceCount}
activeDocumentTypes={activeDocumentTypes}
connectors={connectors as SearchSourceConnector[]}
indexingConnectorIds={indexingConnectorIds}
logsSummary={logsSummary}
searchSpaceId={searchSpaceId}
onTabChange={handleTabChange}
onManage={handleStartEdit}
/>
<ActiveConnectorsTab
searchQuery={searchQuery}
hasSources={hasSources}
totalSourceCount={totalSourceCount}
activeDocumentTypes={activeDocumentTypes}
connectors={connectors as SearchSourceConnector[]}
indexingConnectorIds={indexingConnectorIds}
logsSummary={logsSummary}
searchSpaceId={searchSpaceId}
onTabChange={handleTabChange}
onManage={handleStartEdit}
onViewAccountsList={handleViewAccountsList}
/>
</div>
</div>
{/* Bottom fade shadow */}

View file

@ -679,19 +679,20 @@ export const useConnectorDialog = () => {
// Handle viewing accounts list for OAuth connector type
const handleViewAccountsList = useCallback(
(connector: (typeof OAUTH_CONNECTORS)[number]) => {
(connectorType: string, connectorTitle: string) => {
if (!searchSpaceId) return;
setViewingAccountsType({
connectorType: connector.connectorType,
connectorTitle: connector.title,
connectorType,
connectorTitle,
});
// Update URL to show accounts view
// Update URL to show accounts view, preserving current tab
const url = new URL(window.location.href);
url.searchParams.set("modal", "connectors");
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());
},
[searchSpaceId]
@ -702,7 +703,7 @@ export const useConnectorDialog = () => {
setViewingAccountsType(null);
const url = new URL(window.location.href);
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("connectorType");
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 { LogActiveTask, LogSummary } from "@/contracts/types/log.types";
import { cn } from "@/lib/utils";
import { OAUTH_CONNECTORS } from "../constants/connector-constants";
import { getDocumentCountForConnector } from "../utils/connector-document-mapping";
import { getConnectorDisplayName } from "./all-connectors-tab";
interface ActiveConnectorsTabProps {
searchQuery: string;
@ -25,6 +25,7 @@ interface ActiveConnectorsTabProps {
searchSpaceId: string;
onTabChange: (value: string) => void;
onManage?: (connector: SearchSourceConnector) => void;
onViewAccountsList?: (connectorType: string, connectorTitle: string) => void;
}
export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
@ -37,6 +38,7 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
searchSpaceId,
onTabChange,
onManage,
onViewAccountsList,
}) => {
const router = useRouter();
@ -72,38 +74,24 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
const minutesAgo = differenceInMinutes(now, date);
const daysAgo = differenceInDays(now, date);
// Just now (within last minute)
if (minutesAgo < 1) {
return "Just now";
}
// 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
if (minutesAgo < 1) return "Just now";
if (minutesAgo < 60) return `${minutesAgo} ${minutesAgo === 1 ? "minute" : "minutes"} ago`;
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`;
return format(date, "MMM d, yyyy");
};
// Document types that should be shown as cards (not from connectors)
// These are: EXTENSION (browser extension), FILE (uploaded files), NOTE (editor notes),
// YOUTUBE_VIDEO (YouTube videos), and CRAWLED_URL (web pages - shown separately even though it can come from WEBCRAWLER_CONNECTOR)
// Get most recent last indexed date from a list of connectors
const getMostRecentLastIndexed = (connectorsList: SearchSourceConnector[]): string | undefined => {
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"];
// 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());
});
// Filter connectors based on search query
const filteredConnectors = connectors.filter((connector) => {
// Get OAuth connector types set for quick lookup
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;
const searchLower = searchQuery.toLowerCase();
return (
@ -129,18 +156,98 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
);
});
const hasActiveConnectors = filteredOAuthConnectorTypes.length > 0 || filteredNonOAuthConnectors.length > 0;
return (
<TabsContent value="active" className="m-0">
{hasSources ? (
<div className="space-y-6">
{/* Active Connectors Section */}
{filteredConnectors.length > 0 && (
{hasActiveConnectors && (
<div className="space-y-4">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold text-muted-foreground">Active Connectors</h3>
</div>
<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 activeTask = logsSummary?.active_tasks?.find(
(task: LogActiveTask) => task.connector_id === connector.id
@ -162,7 +269,7 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
>
<div
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
? "bg-primary/10 border-primary/20"
: "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 className="flex-1 min-w-0">
<p className="text-[14px] font-semibold leading-tight truncate">
{getConnectorDisplayName(connector.name)}
{connector.name}
</p>
{isIndexing ? (
<p className="text-[11px] text-primary mt-1 flex items-center gap-1.5">
@ -198,7 +305,7 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
<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"
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}
>
Manage

View file

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

View file

@ -8,7 +8,6 @@ import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import type { LogActiveTask, LogSummary } from "@/contracts/types/log.types";
import { cn } from "@/lib/utils";
import { getDocumentCountForConnector } from "../utils/connector-document-mapping";
import { getConnectorDisplayName } from "../tabs/all-connectors-tab";
interface ConnectorAccountsListViewProps {
@ -17,27 +16,12 @@ interface ConnectorAccountsListViewProps {
connectors: SearchSourceConnector[];
indexingConnectorIds: Set<number>;
logsSummary: LogSummary | undefined;
documentTypeCounts?: Record<string, number>;
onBack: () => void;
onManage: (connector: SearchSourceConnector) => void;
onAddAccount: () => void;
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
*/
@ -76,7 +60,6 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
connectors,
indexingConnectorIds,
logsSummary,
documentTypeCounts,
onBack,
onManage,
onAddAccount,
@ -145,10 +128,6 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
const activeTask = logsSummary?.active_tasks?.find(
(task: LogActiveTask) => task.connector_id === connector.id
);
const documentCount = getDocumentCountForConnector(
connector.connector_type,
documentTypeCounts
);
return (
<div
@ -191,9 +170,6 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
: "Never indexed"}
</p>
)}
<p className="text-[10px] text-muted-foreground mt-0.5">
{formatDocumentCount(documentCount)}
</p>
</div>
<Button
variant="secondary"