This commit is contained in:
pablonyx 2025-03-04 17:10:25 -08:00
parent 4142ae0afd
commit da17b4aa36
5 changed files with 857 additions and 319 deletions

View File

@ -26,6 +26,8 @@ import {
FiUnlock,
FiRefreshCw,
FiPauseCircle,
FiFilter,
FiX,
} from "react-icons/fi";
import {
Tooltip,
@ -41,7 +43,7 @@ import Cookies from "js-cookie";
import { TOGGLED_CONNECTORS_COOKIE_NAME } from "@/lib/constants";
import { usePaidEnterpriseFeaturesEnabled } from "@/components/settings/usePaidEnterpriseFeaturesEnabled";
import { ConnectorCredentialPairStatus } from "../../connector/[ccPairId]/types";
import { SortIcon } from "@/components/icons/icons";
import { FilterComponent, FilterOptions } from "./FilterComponent";
function SummaryRow({
source,
@ -286,7 +288,26 @@ export function CCPairIndexingStatusTable({
return savedState ? JSON.parse(savedState) : {};
});
const { groupedStatuses, sortedSources, groupSummaries } = useMemo(() => {
const [filterOptions, setFilterOptions] = useState<FilterOptions>({
accessType: null,
docsCountFilter: {
operator: null,
value: null,
},
lastStatus: null,
});
// Reference to the FilterComponent for resetting its state
const filterComponentRef = useRef<{
resetFilters: () => void;
} | null>(null);
const {
groupedStatuses,
sortedSources,
groupSummaries,
filteredGroupedStatuses,
} = useMemo(() => {
const grouped: Record<ValidSources, ConnectorIndexingStatus<any, any>[]> =
{} as Record<ValidSources, ConnectorIndexingStatus<any, any>[]>;
@ -338,12 +359,139 @@ export function CCPairIndexingStatusTable({
};
});
// Apply filters to create filtered grouped statuses
const filteredGrouped: Record<
ValidSources,
ConnectorIndexingStatus<any, any>[]
> = {} as Record<ValidSources, ConnectorIndexingStatus<any, any>[]>;
sorted.forEach((source) => {
const statuses = grouped[source];
// Apply filters
const filteredStatuses = statuses.filter((status) => {
// Filter by access type
if (filterOptions.accessType && filterOptions.accessType.length > 0) {
if (!filterOptions.accessType.includes(status.access_type)) {
return false;
}
}
// Filter by last status
if (filterOptions.lastStatus && filterOptions.lastStatus.length > 0) {
if (
!filterOptions.lastStatus.includes(
status.last_finished_status as any
)
) {
return false;
}
}
// Filter by docs count
if (filterOptions.docsCountFilter.operator) {
const { operator, value } = filterOptions.docsCountFilter;
// If only operator is selected (no value), show all
if (value === null) {
return true;
}
if (operator === ">" && !(status.docs_indexed > value)) {
return false;
} else if (operator === "<" && !(status.docs_indexed < value)) {
return false;
} else if (operator === "=" && status.docs_indexed !== value) {
return false;
}
}
return true;
});
if (filteredStatuses.length > 0) {
filteredGrouped[source] = filteredStatuses;
}
});
return {
groupedStatuses: grouped,
sortedSources: sorted,
groupSummaries: summaries,
filteredGroupedStatuses: filteredGrouped,
};
}, [ccPairsIndexingStatuses, editableCcPairsIndexingStatuses]);
}, [ccPairsIndexingStatuses, editableCcPairsIndexingStatuses, filterOptions]);
// Determine which sources to display based on filters and search
const displaySources = useMemo(() => {
const hasActiveFilters =
(filterOptions.accessType && filterOptions.accessType.length > 0) ||
(filterOptions.lastStatus && filterOptions.lastStatus.length > 0) ||
filterOptions.docsCountFilter.operator !== null;
if (hasActiveFilters) {
return Object.keys(filteredGroupedStatuses) as ValidSources[];
}
return sortedSources;
}, [sortedSources, filteredGroupedStatuses, filterOptions]);
const handleFilterChange = (newFilters: FilterOptions) => {
setFilterOptions(newFilters);
// Auto-expand sources when filters are applied
if (
(newFilters.accessType && newFilters.accessType.length > 0) ||
(newFilters.lastStatus && newFilters.lastStatus.length > 0) ||
newFilters.docsCountFilter.operator !== null
) {
// We need to wait for the filteredGroupedStatuses to be updated
// before we can expand the sources
setTimeout(() => {
const sourcesToExpand = Object.keys(
filteredGroupedStatuses
) as ValidSources[];
const newConnectorsToggled = { ...connectorsToggled };
sourcesToExpand.forEach((source) => {
newConnectorsToggled[source] = true;
});
setConnectorsToggled(newConnectorsToggled);
Cookies.set(
TOGGLED_CONNECTORS_COOKIE_NAME,
JSON.stringify(newConnectorsToggled)
);
}, 0);
}
};
const clearAllFilters = () => {
const emptyFilters: FilterOptions = {
accessType: null,
docsCountFilter: {
operator: null,
value: null,
},
lastStatus: null,
};
setFilterOptions(emptyFilters);
// Reset the FilterComponent's internal state
if (filterComponentRef.current) {
filterComponentRef.current.resetFilters();
}
};
// Check if filters are active
const hasActiveFilters = useMemo(() => {
return (
(filterOptions.accessType && filterOptions.accessType.length > 0) ||
(filterOptions.lastStatus && filterOptions.lastStatus.length > 0) ||
filterOptions.docsCountFilter.operator !== null
);
}, [filterOptions]);
const toggleSource = (
source: ValidSources,
@ -377,130 +525,194 @@ export function CCPairIndexingStatusTable({
sortedSources.length;
return (
<Table>
<TableHeader>
<ConnectorRow
invisible
ccPairsIndexingStatus={{
cc_pair_id: 1,
name: "Sample File Connector",
cc_pair_status: ConnectorCredentialPairStatus.ACTIVE,
last_status: "success",
connector: {
<>
<Table>
<TableHeader>
<ConnectorRow
invisible
ccPairsIndexingStatus={{
cc_pair_id: 1,
name: "Sample File Connector",
source: ValidSources.File,
input_type: "poll",
connector_specific_config: {
file_locations: ["/path/to/sample/file.txt"],
cc_pair_status: ConnectorCredentialPairStatus.ACTIVE,
last_status: "success",
connector: {
name: "Sample File Connector",
source: ValidSources.File,
input_type: "poll",
connector_specific_config: {
file_locations: ["/path/to/sample/file.txt"],
},
refresh_freq: 86400,
prune_freq: null,
indexing_start: new Date("2023-07-01T12:00:00Z"),
id: 1,
credential_ids: [],
access_type: "public",
time_created: "2023-07-01T12:00:00Z",
time_updated: "2023-07-01T12:00:00Z",
},
credential: {
id: 1,
name: "Sample Credential",
source: ValidSources.File,
user_id: "1",
time_created: "2023-07-01T12:00:00Z",
time_updated: "2023-07-01T12:00:00Z",
credential_json: {},
admin_public: false,
},
refresh_freq: 86400,
prune_freq: null,
indexing_start: new Date("2023-07-01T12:00:00Z"),
id: 1,
credential_ids: [],
access_type: "public",
time_created: "2023-07-01T12:00:00Z",
time_updated: "2023-07-01T12:00:00Z",
},
credential: {
id: 1,
name: "Sample Credential",
source: ValidSources.File,
user_id: "1",
time_created: "2023-07-01T12:00:00Z",
time_updated: "2023-07-01T12:00:00Z",
credential_json: {},
admin_public: false,
},
access_type: "public",
docs_indexed: 1000,
last_success: "2023-07-01T12:00:00Z",
last_finished_status: "success",
latest_index_attempt: null,
groups: [], // Add this line
}}
isEditable={false}
/>
</TableHeader>
<div className="flex -mt-12 items-center w-0 m4 gap-x-2">
<input
type="text"
ref={searchInputRef}
placeholder="Search connectors..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="ml-1 w-96 h-9 border border-border flex-none rounded-md bg-background-50 px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
docs_indexed: 1000,
last_success: "2023-07-01T12:00:00Z",
last_finished_status: "success",
latest_index_attempt: null,
groups: [], // Add this line
}}
isEditable={false}
/>
</TableHeader>
<div className="flex -mt-12 items-center w-0 m4 gap-x-2">
<input
type="text"
ref={searchInputRef}
placeholder="Search connectors..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="ml-1 w-96 h-9 border border-border flex-none rounded-md bg-background-50 px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
<Button className="h-9" onClick={() => toggleSources()}>
{!shouldExpand ? "Collapse All" : "Expand All"}
</Button>
<button className="p-1 rounded-md borde border-neutral- ">
<SortIcon size={24} className="text-neutral-800" />
</button>
</div>
<TableBody>
{sortedSources
.filter(
(source) => source != "not_applicable" && source != "ingestion_api"
)
.map((source, ind) => {
const sourceMatches = source
.toLowerCase()
.includes(searchTerm.toLowerCase());
const matchingConnectors = groupedStatuses[source].filter(
(status) =>
<Button className="h-9" onClick={() => toggleSources()}>
{!shouldExpand ? "Collapse All" : "Expand All"}
</Button>
<div className="flex items-center gap-2">
<FilterComponent
onFilterChange={handleFilterChange}
ref={filterComponentRef}
/>
{hasActiveFilters && (
<div className="flex flex-none items-center gap-1 ml-2 max-w-[500px]">
{filterOptions.accessType &&
filterOptions.accessType.length > 0 && (
<Badge variant="secondary" className="px-2 py-0.5 text-xs">
Access: {filterOptions.accessType.join(", ")}
</Badge>
)}
{filterOptions.lastStatus &&
filterOptions.lastStatus.length > 0 && (
<Badge variant="secondary" className="px-2 py-0.5 text-xs">
Status:{" "}
{filterOptions.lastStatus
.map((s) => s.replace(/_/g, " "))
.join(", ")}
</Badge>
)}
{filterOptions.docsCountFilter.operator &&
filterOptions.docsCountFilter.value !== null && (
<Badge variant="secondary" className="px-2 py-0.5 text-xs">
Docs {filterOptions.docsCountFilter.operator}{" "}
{filterOptions.docsCountFilter.value}
</Badge>
)}
{filterOptions.docsCountFilter.operator &&
filterOptions.docsCountFilter.value === null && (
<Badge variant="secondary" className="px-2 py-0.5 text-xs">
Docs {filterOptions.docsCountFilter.operator} any
</Badge>
)}
<Badge
variant="outline"
className="px-2 py-0.5 text-xs border-red-400 bg-red-100 hover:border-red-600 cursor-pointer hover:bg-red-100 dark:hover:bg-red-900"
onClick={() => {
if (filterComponentRef.current) {
filterComponentRef.current.resetFilters();
setFilterOptions({
accessType: null,
docsCountFilter: {
operator: null,
value: null,
},
lastStatus: null,
});
}
}}
>
<span className="text-red-500 dark:text-red-400">Clear</span>
</Badge>
</div>
)}
</div>
</div>
<TableBody>
{displaySources
.filter(
(source) =>
source != "not_applicable" && source != "ingestion_api"
)
.map((source, ind) => {
const sourceMatches = source
.toLowerCase()
.includes(searchTerm.toLowerCase());
const statuses =
filteredGroupedStatuses[source] || groupedStatuses[source];
const matchingConnectors = statuses.filter((status) =>
(status.name || "")
.toLowerCase()
.includes(searchTerm.toLowerCase())
);
if (sourceMatches || matchingConnectors.length > 0) {
return (
<React.Fragment key={ind}>
<br className="mt-4" />
<SummaryRow
source={source}
summary={groupSummaries[source]}
isOpen={connectorsToggled[source] || false}
onToggle={() => toggleSource(source)}
/>
{connectorsToggled[source] && (
<>
<TableRow
noHover
className="border ! border-border dark:border-neutral-700"
>
<TableHead>Name</TableHead>
<TableHead>Last Indexed</TableHead>
<TableHead>Activity</TableHead>
{isPaidEnterpriseFeaturesEnabled && (
<TableHead>Permissions</TableHead>
)}
<TableHead>Total Docs</TableHead>
<TableHead>Last Status</TableHead>
<TableHead></TableHead>
</TableRow>
{(sourceMatches
? groupedStatuses[source]
: matchingConnectors
).map((ccPairsIndexingStatus) => (
<ConnectorRow
key={ccPairsIndexingStatus.cc_pair_id}
ccPairsIndexingStatus={ccPairsIndexingStatus}
isEditable={editableCcPairsIndexingStatuses.some(
(e) =>
e.cc_pair_id === ccPairsIndexingStatus.cc_pair_id
)}
/>
))}
</>
)}
</React.Fragment>
);
}
return null;
})}
</TableBody>
</Table>
if (sourceMatches || matchingConnectors.length > 0) {
return (
<React.Fragment key={ind}>
<br className="mt-4" />
<SummaryRow
source={source}
summary={groupSummaries[source]}
isOpen={connectorsToggled[source] || false}
onToggle={() => toggleSource(source)}
/>
{connectorsToggled[source] && (
<>
<TableRow className="border border-border dark:border-neutral-700">
<TableHead>Name</TableHead>
<TableHead>Last Indexed</TableHead>
<TableHead>Activity</TableHead>
{isPaidEnterpriseFeaturesEnabled && (
<TableHead>Permissions</TableHead>
)}
<TableHead>Total Docs</TableHead>
<TableHead>Last Status</TableHead>
<TableHead></TableHead>
</TableRow>
{(sourceMatches ? statuses : matchingConnectors).map(
(ccPairsIndexingStatus) => (
<ConnectorRow
key={ccPairsIndexingStatus.cc_pair_id}
ccPairsIndexingStatus={ccPairsIndexingStatus}
isEditable={editableCcPairsIndexingStatuses.some(
(e) =>
e.cc_pair_id ===
ccPairsIndexingStatus.cc_pair_id
)}
/>
)
)}
</>
)}
</React.Fragment>
);
}
return null;
})}
</TableBody>
</Table>
</>
);
}

View File

@ -0,0 +1,385 @@
"use client";
import React, { useState, useImperativeHandle, forwardRef } from "react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
} from "@/components/ui/dropdown-menu";
import { SortIcon } from "@/components/icons/icons";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { AccessType, ValidStatuses } from "@/lib/types";
import { FiFilter, FiX, FiCheck } from "react-icons/fi";
export interface FilterOptions {
accessType: AccessType[] | null;
docsCountFilter: {
operator: ">" | "<" | "=" | null;
value: number | null;
};
lastStatus: ValidStatuses[] | null;
}
interface FilterComponentProps {
onFilterChange: (filters: FilterOptions) => void;
}
export const FilterComponent = forwardRef<
{ resetFilters: () => void },
FilterComponentProps
>(({ onFilterChange }, ref) => {
const [isOpen, setIsOpen] = useState(false);
const [filters, setFilters] = useState<FilterOptions>({
accessType: null,
docsCountFilter: {
operator: null,
value: null,
},
lastStatus: null,
});
// Local state for tracking selected filters before applying
const [docsOperator, setDocsOperator] = useState<">" | "<" | "=" | null>(
null
);
const [docsValue, setDocsValue] = useState<string>("");
const [selectedAccessTypes, setSelectedAccessTypes] = useState<AccessType[]>(
[]
);
const [selectedStatuses, setSelectedStatuses] = useState<ValidStatuses[]>([]);
// Expose resetFilters method via ref
useImperativeHandle(ref, () => ({
resetFilters: () => {
setDocsOperator(null);
setDocsValue("");
setSelectedAccessTypes([]);
setSelectedStatuses([]);
setFilters({
accessType: null,
docsCountFilter: {
operator: null,
value: null,
},
lastStatus: null,
});
},
}));
const handleAccessTypeChange = (accessType: AccessType) => {
const newAccessTypes = selectedAccessTypes.includes(accessType)
? selectedAccessTypes.filter((type) => type !== accessType)
: [...selectedAccessTypes, accessType];
setSelectedAccessTypes(newAccessTypes);
};
const handleStatusChange = (status: ValidStatuses) => {
const newStatuses = selectedStatuses.includes(status)
? selectedStatuses.filter((s) => s !== status)
: [...selectedStatuses, status];
setSelectedStatuses(newStatuses);
};
const handleDocsFilterChange = () => {
if (docsOperator && docsValue) {
const newFilters = {
...filters,
accessType: selectedAccessTypes.length > 0 ? selectedAccessTypes : null,
lastStatus: selectedStatuses.length > 0 ? selectedStatuses : null,
docsCountFilter: {
operator: docsOperator,
value: parseInt(docsValue),
},
};
setFilters(newFilters);
onFilterChange(newFilters);
setIsOpen(false);
}
};
const applyFilters = () => {
const newFilters = {
...filters,
accessType: selectedAccessTypes.length > 0 ? selectedAccessTypes : null,
lastStatus: selectedStatuses.length > 0 ? selectedStatuses : null,
docsCountFilter: {
operator: docsOperator,
value: docsValue ? parseInt(docsValue) : null,
},
};
setFilters(newFilters);
onFilterChange(newFilters);
setIsOpen(false);
};
const clearFilters = () => {
setSelectedAccessTypes([]);
setSelectedStatuses([]);
setDocsOperator(null);
setDocsValue("");
const newFilters = {
accessType: null,
docsCountFilter: {
operator: null,
value: null,
},
lastStatus: null,
};
setFilters(newFilters);
onFilterChange(newFilters);
};
// Sync local state with filters when dropdown opens
const handleOpenChange = (open: boolean) => {
if (open) {
// When opening, initialize local state from current filters
setSelectedAccessTypes(filters.accessType || []);
setSelectedStatuses(filters.lastStatus || []);
setDocsOperator(filters.docsCountFilter.operator);
setDocsValue(
filters.docsCountFilter.value !== null
? filters.docsCountFilter.value.toString()
: ""
);
}
setIsOpen(open);
};
const hasActiveFilters =
(filters.accessType && filters.accessType.length > 0) ||
(filters.lastStatus && filters.lastStatus.length > 0) ||
filters.docsCountFilter.operator !== null;
// Get active filter count for badge
const getActiveFilterCount = () => {
let count = 0;
if (filters.accessType && filters.accessType.length > 0) count++;
if (filters.lastStatus && filters.lastStatus.length > 0) count++;
if (filters.docsCountFilter.operator !== null) count++;
return count;
};
return (
<div className="relative">
<DropdownMenu open={isOpen} onOpenChange={handleOpenChange}>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className={`p-2 h-9 ${
hasActiveFilters ? "border-primary bg-primary/5" : ""
}`}
>
<SortIcon size={20} className="text-neutral-800" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-72"
onCloseAutoFocus={(e) => e.preventDefault()}
>
<div className="flex items-center justify-between px-2 py-1.5">
<DropdownMenuLabel className="text-base font-medium">
Filter Connectors
</DropdownMenuLabel>
{hasActiveFilters && (
<Button
variant="ghost"
size="sm"
className="h-8 px-2 text-xs"
onClick={clearFilters}
>
Clear All
</Button>
)}
</div>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel className="px-2 py-1.5 text-xs text-muted-foreground">
Access Type
</DropdownMenuLabel>
<div onClick={(e) => e.stopPropagation()}>
<DropdownMenuCheckboxItem
checked={selectedAccessTypes.includes("public")}
onCheckedChange={() => handleAccessTypeChange("public")}
className="flex items-center justify-between"
onSelect={(e) => e.preventDefault()}
>
Public
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={selectedAccessTypes.includes("private")}
onCheckedChange={() => handleAccessTypeChange("private")}
className="flex items-center justify-between"
onSelect={(e) => e.preventDefault()}
>
Private
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={selectedAccessTypes.includes("sync")}
onCheckedChange={() => handleAccessTypeChange("sync")}
className="flex items-center justify-between"
onSelect={(e) => e.preventDefault()}
>
Auto-Sync
</DropdownMenuCheckboxItem>
</div>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel className="px-2 py-1.5 text-xs text-muted-foreground">
Last Status
</DropdownMenuLabel>
<div onClick={(e) => e.stopPropagation()}>
<DropdownMenuCheckboxItem
checked={selectedStatuses.includes("success")}
onCheckedChange={() => handleStatusChange("success")}
className="flex items-center justify-between"
onSelect={(e) => e.preventDefault()}
>
Success
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={selectedStatuses.includes("failed")}
onCheckedChange={() => handleStatusChange("failed")}
className="flex items-center justify-between"
onSelect={(e) => e.preventDefault()}
>
Failed
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={selectedStatuses.includes("in_progress")}
onCheckedChange={() => handleStatusChange("in_progress")}
className="flex items-center justify-between"
onSelect={(e) => e.preventDefault()}
>
In Progress
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={selectedStatuses.includes("not_started")}
onCheckedChange={() => handleStatusChange("not_started")}
className="flex items-center justify-between"
onSelect={(e) => e.preventDefault()}
>
Not Started
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={selectedStatuses.includes("completed_with_errors")}
onCheckedChange={() =>
handleStatusChange("completed_with_errors")
}
className="flex items-center justify-between"
onSelect={(e) => e.preventDefault()}
>
Completed with Errors
</DropdownMenuCheckboxItem>
</div>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel className="px-2 py-1.5 text-xs text-muted-foreground">
Document Count
</DropdownMenuLabel>
<div
className="flex items-center px-2 py-2 gap-2"
onClick={(e) => e.stopPropagation()}
>
<div className="flex gap-2">
<Button
variant={docsOperator === ">" ? "default" : "outline"}
size="sm"
className="h-8 px-2"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setDocsOperator(docsOperator === ">" ? null : ">");
}}
type="button"
>
&gt;
</Button>
<Button
variant={docsOperator === "<" ? "default" : "outline"}
size="sm"
className="h-8 px-2"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setDocsOperator(docsOperator === "<" ? null : "<");
}}
type="button"
>
&lt;
</Button>
<Button
variant={docsOperator === "=" ? "default" : "outline"}
size="sm"
className="h-8 px-2"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setDocsOperator(docsOperator === "=" ? null : "=");
}}
type="button"
>
=
</Button>
</div>
<Input
type="number"
placeholder="Count"
value={docsValue}
onChange={(e) => setDocsValue(e.target.value)}
className="h-8 w-full"
onClick={(e) => e.stopPropagation()}
/>
</div>
<div className="px-2 py-1.5">
<Button
size="sm"
className="w-full h-8"
disabled={false}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
applyFilters();
}}
type="button"
>
Apply
</Button>
</div>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
{hasActiveFilters && (
<div className="absolute -top-1 -right-1">
<Badge className="h-2 bg-red-400 border-red-400 w-2 p-0 border-2 flex items-center justify-center" />
</div>
)}
</div>
);
});
FilterComponent.displayName = "FilterComponent";

View File

@ -1,5 +1,5 @@
import { ConnectorIndexingStatus, ConnectorStatus } from "@/lib/types";
import { ConnectorTitle } from "@/components/admin/connectors/ConnectorTitle";
import { ConnectorStatus } from "@/lib/types";
import { ConnectorMultiSelect } from "@/components/ConnectorMultiSelect";
interface ConnectorEditorProps {
selectedCCPairIds: number[];
@ -12,55 +12,20 @@ export const ConnectorEditor = ({
setSetCCPairIds,
allCCPairs,
}: ConnectorEditorProps) => {
// Filter out public docs, since they don't make sense as part of a group
const privateCCPairs = allCCPairs.filter(
(ccPair) => ccPair.access_type === "private"
);
return (
<div className="mb-3 flex gap-2 flex-wrap">
{allCCPairs
// remove public docs, since they don't make sense as part of a group
.filter((ccPair) => !(ccPair.access_type === "public"))
.map((ccPair) => {
const ind = selectedCCPairIds.indexOf(ccPair.cc_pair_id);
const isSelected = ind !== -1;
return (
<div
key={`${ccPair.connector.id}-${ccPair.credential.id}`}
className={
`
px-3
py-1
rounded-lg
border
border-border
w-fit
flex
cursor-pointer ` +
(isSelected
? " bg-accent-background-hovered"
: " hover:bg-accent-background")
}
onClick={() => {
if (isSelected) {
setSetCCPairIds(
selectedCCPairIds.filter(
(ccPairId) => ccPairId !== ccPair.cc_pair_id
)
);
} else {
setSetCCPairIds([...selectedCCPairIds, ccPair.cc_pair_id]);
}
}}
>
<div className="my-auto">
<ConnectorTitle
connector={ccPair.connector}
ccPairId={ccPair.cc_pair_id}
ccPairName={ccPair.name}
isLink={false}
showMetadata={false}
/>
</div>
</div>
);
})}
</div>
<ConnectorMultiSelect
name="connectors"
label="Connectors"
connectors={privateCCPairs}
selectedIds={selectedCCPairIds}
onChange={setSetCCPairIds}
placeholder="Search for connectors..."
showError={true}
/>
);
};

View File

@ -1,13 +1,15 @@
import { Button } from "@/components/Button";
import { SearchMultiSelectDropdown } from "@/components/Dropdown";
import { Modal } from "@/components/Modal";
import { useState } from "react";
import { FiPlus, FiX } from "react-icons/fi";
import { FiX } from "react-icons/fi";
import { updateUserGroup } from "./lib";
import { PopupSpec } from "@/components/admin/connectors/Popup";
import { ConnectorStatus, UserGroup } from "@/lib/types";
import { ConnectorTitle } from "@/components/admin/connectors/ConnectorTitle";
import { Connector } from "@/lib/connectors/connectors";
import { ConnectorMultiSelect } from "@/components/ConnectorMultiSelect";
import { Form } from "formik";
interface AddConnectorFormProps {
ccPairs: ConnectorStatus<any, any>[];
userGroup: UserGroup;
@ -23,132 +25,64 @@ export const AddConnectorForm: React.FC<AddConnectorFormProps> = ({
}) => {
const [selectedCCPairIds, setSelectedCCPairIds] = useState<number[]>([]);
const selectedCCPairs = ccPairs.filter((ccPair) =>
selectedCCPairIds.includes(ccPair.cc_pair_id)
);
// Filter out ccPairs that are already in the user group and are not private
const availableCCPairs = ccPairs
.filter(
(ccPair) =>
!userGroup.cc_pairs
.map((userGroupCCPair) => userGroupCCPair.id)
.includes(ccPair.cc_pair_id)
)
.filter((ccPair) => ccPair.access_type === "private");
return (
<Modal title="Add New Connector" onOutsideClick={() => onClose()}>
<div className="px-6 pt-4 pb-12">
<div className="mb-2 flex flex-wrap gap-x-2">
{selectedCCPairs.length > 0 &&
selectedCCPairs.map((ccPair) => (
<div
key={ccPair.cc_pair_id}
onClick={() => {
setSelectedCCPairIds(
selectedCCPairIds.filter(
(ccPairId) => ccPairId !== ccPair.cc_pair_id
)
);
}}
className={`
flex
rounded-lg
px-2
py-1
my-1
border
border-border
hover:bg-accent-background-hovered
cursor-pointer`}
>
<ConnectorTitle
ccPairId={ccPair.cc_pair_id}
ccPairName={ccPair.name}
connector={ccPair.connector}
isLink={false}
showMetadata={false}
/>
<FiX className="ml-1 my-auto" />
</div>
))}
</div>
<ConnectorMultiSelect
name="connectors"
label="Select Connectors"
connectors={availableCCPairs}
selectedIds={selectedCCPairIds}
onChange={setSelectedCCPairIds}
placeholder="Search for connectors to add..."
showError={false}
/>
<div className="flex">
<SearchMultiSelectDropdown
options={ccPairs
.filter(
(ccPair) =>
!selectedCCPairIds.includes(ccPair.cc_pair_id) &&
!userGroup.cc_pairs
.map((userGroupCCPair) => userGroupCCPair.id)
.includes(ccPair.cc_pair_id)
)
// remove public and synced docs, since they don't make sense as part of a group
.filter((ccPair) => ccPair.access_type === "private")
.map((ccPair) => {
return {
name: ccPair.name?.toString() || "",
value: ccPair.cc_pair_id?.toString(),
metadata: {
ccPairId: ccPair.cc_pair_id,
connector: ccPair.connector,
},
};
})}
onSelect={(option) => {
setSelectedCCPairIds([
...Array.from(
new Set([
...selectedCCPairIds,
parseInt(option.value as string),
])
),
]);
}}
itemComponent={({ option }) => (
<div className="flex px-4 py-2.5 hover:bg-accent-background-hovered cursor-pointer">
<div className="my-auto">
<ConnectorTitle
ccPairId={option?.metadata?.ccPairId as number}
ccPairName={option.name}
connector={option?.metadata?.connector as Connector<any>}
isLink={false}
showMetadata={false}
/>
</div>
<div className="ml-auto my-auto">
<FiPlus />
</div>
</div>
)}
/>
<Button
className="ml-3 flex-nowrap w-48"
onClick={async () => {
const newCCPairIds = [
...Array.from(
new Set(
userGroup.cc_pairs
.map((ccPair) => ccPair.id)
.concat(selectedCCPairIds)
)
),
];
const response = await updateUserGroup(userGroup.id, {
user_ids: userGroup.users.map((user) => user.id),
cc_pair_ids: newCCPairIds,
<Button
className="mt-4 flex-nowrap w-48"
onClick={async () => {
const newCCPairIds = [
...Array.from(
new Set(
userGroup.cc_pairs
.map((ccPair) => ccPair.id)
.concat(selectedCCPairIds)
)
),
];
const response = await updateUserGroup(userGroup.id, {
user_ids: userGroup.users.map((user) => user.id),
cc_pair_ids: newCCPairIds,
});
if (response.ok) {
setPopup({
message: "Successfully added connectors to group",
type: "success",
});
if (response.ok) {
setPopup({
message: "Successfully added users to group",
type: "success",
});
onClose();
} else {
const responseJson = await response.json();
const errorMsg = responseJson.detail || responseJson.message;
setPopup({
message: `Failed to add users to group - ${errorMsg}`,
type: "error",
});
onClose();
}
}}
>
Add Connectors
</Button>
</div>
onClose();
} else {
const responseJson = await response.json();
const errorMsg = responseJson.detail || responseJson.message;
setPopup({
message: `Failed to add connectors to group - ${errorMsg}`,
type: "error",
});
onClose();
}
}}
>
Add Connectors
</Button>
</div>
</Modal>
);

View File

@ -15,6 +15,7 @@ interface ConnectorMultiSelectProps {
onChange: (selectedIds: number[]) => void;
disabled?: boolean;
placeholder?: string;
showError?: boolean;
}
export const ConnectorMultiSelect = ({
@ -25,6 +26,7 @@ export const ConnectorMultiSelect = ({
onChange,
disabled = false,
placeholder = "Search connectors...",
showError = false,
}: ConnectorMultiSelectProps) => {
const [open, setOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
@ -40,6 +42,9 @@ export const ConnectorMultiSelect = ({
(connector) => !selectedIds.includes(connector.cc_pair_id)
);
// Check if all connectors are selected
const allConnectorsSelected = unselectedConnectors.length === 0;
// Filter unselected connectors based on search query
const filteredUnselectedConnectors = unselectedConnectors.filter(
(connector) => {
@ -48,15 +53,40 @@ export const ConnectorMultiSelect = ({
}
);
// Close dropdown if there are no more connectors to select
useEffect(() => {
if (allConnectorsSelected && open) {
setOpen(false);
// Blur the input to remove focus when all connectors are selected
inputRef.current?.blur();
// Clear search query when all connectors are selected
setSearchQuery("");
}
}, [allConnectorsSelected, open]);
// Also check when selectedIds changes to handle the case when the last connector is selected
useEffect(() => {
if (allConnectorsSelected) {
inputRef.current?.blur();
setSearchQuery("");
}
}, [allConnectorsSelected, selectedIds]);
// Handle selection
const selectConnector = (connectorId: number) => {
onChange([...selectedIds, connectorId]);
const newSelectedIds = [...selectedIds, connectorId];
onChange(newSelectedIds);
setSearchQuery(""); // Clear search after selection
// Focus back on input after selection
setTimeout(() => {
inputRef.current?.focus();
}, 0);
// Check if this was the last connector to select
const willAllBeSelected = connectors.length === newSelectedIds.length;
// Only focus back on input if there are still connectors to select
if (!willAllBeSelected) {
setTimeout(() => {
inputRef.current?.focus();
}, 0);
}
};
// Remove a selected connector
@ -90,8 +120,16 @@ export const ConnectorMultiSelect = ({
}
};
// Determine the placeholder text based on whether all connectors are selected
const effectivePlaceholder = allConnectorsSelected
? "All connectors selected"
: placeholder;
// Determine if the input should be disabled
const isInputDisabled = disabled || allConnectorsSelected;
return (
<div className="flex flex-col max-w-md space-y-2 mb-4">
<div className="flex flex-col w-full space-y-2 mb-4">
{label && <Label className="text-base font-medium">{label}</Label>}
<p className="text-xs text-neutral-500 ">
@ -100,7 +138,11 @@ export const ConnectorMultiSelect = ({
</p>
{/* Persistent search bar */}
<div className="relative">
<div className="flex items-center border border-input rounded-md border border-neutral-200 focus-within:ring-1 focus-within:ring-ring focus-within:border-neutral-400 transition-colors">
<div
className={`flex items-center border border-input rounded-md border border-neutral-200 ${
allConnectorsSelected ? "bg-neutral-50" : ""
} focus-within:ring-1 focus-within:ring-ring focus-within:border-neutral-400 transition-colors`}
>
<Search className="absolute left-3 h-4 w-4 text-neutral-500" />
<input
ref={inputRef}
@ -110,16 +152,22 @@ export const ConnectorMultiSelect = ({
setSearchQuery(e.target.value);
setOpen(true);
}}
onFocus={() => setOpen(true)}
onFocus={() => {
if (!allConnectorsSelected) {
setOpen(true);
}
}}
onKeyDown={handleKeyDown}
placeholder={placeholder}
className="h-9 w-full pl-9 pr-10 py-2 bg-transparent text-sm outline-none disabled:cursor-not-allowed disabled:opacity-50"
disabled={disabled}
placeholder={effectivePlaceholder}
className={`h-9 w-full pl-9 pr-10 py-2 bg-transparent text-sm outline-none disabled:cursor-not-allowed disabled:opacity-50 ${
allConnectorsSelected ? "text-neutral-500" : ""
}`}
disabled={isInputDisabled}
/>
</div>
{/* Dropdown for unselected connectors */}
{open && (
{open && !allConnectorsSelected && (
<div
ref={dropdownRef}
className="absolute z-50 w-full mt-1 rounded-md border border-neutral-200 bg-white shadow-md default-scrollbar max-h-[300px] overflow-auto"
@ -147,9 +195,6 @@ export const ConnectorMultiSelect = ({
showMetadata={false}
/>
</div>
{/* <div className="flex-shrink-0 text-neutral-400 hover:text-blue-500">
<Check className="h-3.5 w-3.5" />
</div> */}
</div>
))}
</div>
@ -158,13 +203,8 @@ export const ConnectorMultiSelect = ({
)}
</div>
{/* Selected connectors display */}
{selectedConnectors.length > 0 ? (
// <div className="mt-3 p-3 border border-neutral-200 rounded-md bg-background-50">
<div className="mt-3 ">
{/* <div className="text-xs font-medium text-neutral-700 mb-2">
Selected:
</div> */}
<div className="flex flex-wrap gap-1.5">
{selectedConnectors.map((connector) => (
<div
@ -199,11 +239,13 @@ export const ConnectorMultiSelect = ({
</div>
)}
<ErrorMessage
name={name}
component="div"
className="text-red-500 text-xs mt-1"
/>
{showError && (
<ErrorMessage
name={name}
component="div"
className="text-red-500 text-xs mt-1"
/>
)}
</div>
);
};