mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-04-17 15:36:43 +00:00
Compare commits
3 Commits
refactor/m
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a18b896aa | ||
|
|
53e00c7989 | ||
|
|
50df53727a |
@@ -502,6 +502,9 @@ class GoogleDriveConnector(
|
||||
files: list[RetrievedDriveFile],
|
||||
seen_hierarchy_node_raw_ids: ThreadSafeSet[str],
|
||||
fully_walked_hierarchy_node_raw_ids: ThreadSafeSet[str],
|
||||
failed_folder_ids_by_email: (
|
||||
ThreadSafeDict[str, ThreadSafeSet[str]] | None
|
||||
) = None,
|
||||
permission_sync_context: PermissionSyncContext | None = None,
|
||||
add_prefix: bool = False,
|
||||
) -> list[HierarchyNode]:
|
||||
@@ -525,6 +528,9 @@ class GoogleDriveConnector(
|
||||
seen_hierarchy_node_raw_ids: Set of already-yielded node IDs (modified in place)
|
||||
fully_walked_hierarchy_node_raw_ids: Set of node IDs where the walk to root
|
||||
succeeded (modified in place)
|
||||
failed_folder_ids_by_email: Map of email → folder IDs where that email
|
||||
previously confirmed no accessible parent. Skips the API call if the same
|
||||
(folder, email) is encountered again (modified in place).
|
||||
permission_sync_context: If provided, permissions will be fetched for hierarchy nodes.
|
||||
Contains google_domain and primary_admin_email needed for permission syncing.
|
||||
add_prefix: When True, prefix group IDs with source type (for indexing path).
|
||||
@@ -569,7 +575,7 @@ class GoogleDriveConnector(
|
||||
|
||||
# Fetch folder metadata
|
||||
folder = self._get_folder_metadata(
|
||||
current_id, file.user_email, field_type
|
||||
current_id, file.user_email, field_type, failed_folder_ids_by_email
|
||||
)
|
||||
if not folder:
|
||||
# Can't access this folder - stop climbing
|
||||
@@ -653,7 +659,13 @@ class GoogleDriveConnector(
|
||||
return new_nodes
|
||||
|
||||
def _get_folder_metadata(
|
||||
self, folder_id: str, retriever_email: str, field_type: DriveFileFieldType
|
||||
self,
|
||||
folder_id: str,
|
||||
retriever_email: str,
|
||||
field_type: DriveFileFieldType,
|
||||
failed_folder_ids_by_email: (
|
||||
ThreadSafeDict[str, ThreadSafeSet[str]] | None
|
||||
) = None,
|
||||
) -> GoogleDriveFileType | None:
|
||||
"""
|
||||
Fetch metadata for a folder by ID.
|
||||
@@ -667,6 +679,17 @@ class GoogleDriveConnector(
|
||||
|
||||
# Use a set to deduplicate if retriever_email == primary_admin_email
|
||||
for email in {retriever_email, self.primary_admin_email}:
|
||||
failed_ids = (
|
||||
failed_folder_ids_by_email.get(email)
|
||||
if failed_folder_ids_by_email
|
||||
else None
|
||||
)
|
||||
if failed_ids and folder_id in failed_ids:
|
||||
logger.debug(
|
||||
f"Skipping folder {folder_id} using {email} (previously confirmed no parents)"
|
||||
)
|
||||
continue
|
||||
|
||||
service = get_drive_service(self.creds, email)
|
||||
folder = get_folder_metadata(service, folder_id, field_type)
|
||||
|
||||
@@ -682,6 +705,10 @@ class GoogleDriveConnector(
|
||||
|
||||
# Folder has no parents - could be a root OR user lacks access to parent
|
||||
# Keep this as a fallback but try admin to see if they can see parents
|
||||
if failed_folder_ids_by_email is not None:
|
||||
failed_folder_ids_by_email.setdefault(email, ThreadSafeSet()).add(
|
||||
folder_id
|
||||
)
|
||||
if best_folder is None:
|
||||
best_folder = folder
|
||||
logger.debug(
|
||||
@@ -1090,6 +1117,13 @@ class GoogleDriveConnector(
|
||||
]
|
||||
yield from parallel_yield(user_retrieval_gens, max_workers=MAX_DRIVE_WORKERS)
|
||||
|
||||
# Free per-user cache entries now that this batch is done.
|
||||
# Skip the admin email — it is shared across all user batches and must
|
||||
# persist for the duration of the run.
|
||||
for email in non_completed_org_emails:
|
||||
if email != self.primary_admin_email:
|
||||
checkpoint.failed_folder_ids_by_email.pop(email, None)
|
||||
|
||||
# if there are more emails to process, don't mark as complete
|
||||
if not email_batch_takes_us_to_completion:
|
||||
return
|
||||
@@ -1546,6 +1580,7 @@ class GoogleDriveConnector(
|
||||
files=files_batch,
|
||||
seen_hierarchy_node_raw_ids=checkpoint.seen_hierarchy_node_raw_ids,
|
||||
fully_walked_hierarchy_node_raw_ids=checkpoint.fully_walked_hierarchy_node_raw_ids,
|
||||
failed_folder_ids_by_email=checkpoint.failed_folder_ids_by_email,
|
||||
permission_sync_context=permission_sync_context,
|
||||
add_prefix=True,
|
||||
)
|
||||
@@ -1782,6 +1817,7 @@ class GoogleDriveConnector(
|
||||
files=files_batch,
|
||||
seen_hierarchy_node_raw_ids=checkpoint.seen_hierarchy_node_raw_ids,
|
||||
fully_walked_hierarchy_node_raw_ids=checkpoint.fully_walked_hierarchy_node_raw_ids,
|
||||
failed_folder_ids_by_email=checkpoint.failed_folder_ids_by_email,
|
||||
permission_sync_context=permission_sync_context,
|
||||
)
|
||||
|
||||
|
||||
@@ -167,6 +167,13 @@ class GoogleDriveCheckpoint(ConnectorCheckpoint):
|
||||
default_factory=ThreadSafeSet
|
||||
)
|
||||
|
||||
# Maps email → set of IDs of folders where that email confirmed no accessible parent.
|
||||
# Avoids redundant API calls when the same (folder, email) pair is
|
||||
# encountered again within the same retrieval run.
|
||||
failed_folder_ids_by_email: ThreadSafeDict[str, ThreadSafeSet[str]] = Field(
|
||||
default_factory=ThreadSafeDict
|
||||
)
|
||||
|
||||
@field_serializer("completion_map")
|
||||
def serialize_completion_map(
|
||||
self, completion_map: ThreadSafeDict[str, StageCompletion], _info: Any
|
||||
@@ -211,3 +218,25 @@ class GoogleDriveCheckpoint(ConnectorCheckpoint):
|
||||
if isinstance(v, list):
|
||||
return ThreadSafeSet(set(v)) # ty: ignore[invalid-return-type]
|
||||
return ThreadSafeSet()
|
||||
|
||||
@field_serializer("failed_folder_ids_by_email")
|
||||
def serialize_failed_folder_ids_by_email(
|
||||
self,
|
||||
failed_folder_ids_by_email: ThreadSafeDict[str, ThreadSafeSet[str]],
|
||||
_info: Any,
|
||||
) -> dict[str, set[str]]:
|
||||
return {
|
||||
k: inner.copy() for k, inner in failed_folder_ids_by_email.copy().items()
|
||||
}
|
||||
|
||||
@field_validator("failed_folder_ids_by_email", mode="before")
|
||||
def validate_failed_folder_ids_by_email(
|
||||
cls, v: Any
|
||||
) -> ThreadSafeDict[str, ThreadSafeSet[str]]:
|
||||
if isinstance(v, ThreadSafeDict):
|
||||
return v
|
||||
if isinstance(v, dict):
|
||||
return ThreadSafeDict(
|
||||
{k: ThreadSafeSet(set(vals)) for k, vals in v.items()}
|
||||
)
|
||||
return ThreadSafeDict()
|
||||
|
||||
@@ -34,6 +34,7 @@ R = TypeVar("R")
|
||||
KT = TypeVar("KT") # Key type
|
||||
VT = TypeVar("VT") # Value type
|
||||
_T = TypeVar("_T") # Default type
|
||||
_MISSING: object = object()
|
||||
|
||||
|
||||
class ThreadSafeDict(MutableMapping[KT, VT]):
|
||||
@@ -117,10 +118,10 @@ class ThreadSafeDict(MutableMapping[KT, VT]):
|
||||
with self.lock:
|
||||
return self._dict.get(key, default)
|
||||
|
||||
def pop(self, key: KT, default: Any = None) -> Any:
|
||||
def pop(self, key: KT, default: Any = _MISSING) -> Any:
|
||||
"""Remove and return a value with optional default, atomically."""
|
||||
with self.lock:
|
||||
if default is None:
|
||||
if default is _MISSING:
|
||||
return self._dict.pop(key)
|
||||
return self._dict.pop(key, default)
|
||||
|
||||
|
||||
@@ -12,12 +12,14 @@ from unittest.mock import patch
|
||||
|
||||
from onyx.background.celery.celery_utils import extract_ids_from_runnable_connector
|
||||
from onyx.connectors.google_drive.connector import GoogleDriveConnector
|
||||
from onyx.connectors.google_drive.file_retrieval import DriveFileFieldType
|
||||
from onyx.connectors.google_drive.models import DriveRetrievalStage
|
||||
from onyx.connectors.google_drive.models import GoogleDriveCheckpoint
|
||||
from onyx.connectors.interfaces import SlimConnector
|
||||
from onyx.connectors.interfaces import SlimConnectorWithPermSync
|
||||
from onyx.connectors.models import SlimDocument
|
||||
from onyx.utils.threadpool_concurrency import ThreadSafeDict
|
||||
from onyx.utils.threadpool_concurrency import ThreadSafeSet
|
||||
|
||||
|
||||
def _make_done_checkpoint() -> GoogleDriveCheckpoint:
|
||||
@@ -198,3 +200,90 @@ class TestCeleryUtilsRouting:
|
||||
|
||||
mock_slim.assert_called_once()
|
||||
mock_perm_sync.assert_not_called()
|
||||
|
||||
|
||||
class TestFailedFolderIdsByEmail:
|
||||
def _make_failed_map(
|
||||
self, entries: dict[str, set[str]]
|
||||
) -> ThreadSafeDict[str, ThreadSafeSet[str]]:
|
||||
return ThreadSafeDict({k: ThreadSafeSet(v) for k, v in entries.items()})
|
||||
|
||||
def test_skips_api_call_for_known_failed_pair(self) -> None:
|
||||
"""_get_folder_metadata must skip the API call for a (folder, email) pair
|
||||
that previously confirmed no accessible parent."""
|
||||
connector = _make_connector()
|
||||
failed_map = self._make_failed_map(
|
||||
{
|
||||
"retriever@example.com": {"folder1"},
|
||||
"admin@example.com": {"folder1"},
|
||||
}
|
||||
)
|
||||
|
||||
with patch(
|
||||
"onyx.connectors.google_drive.connector.get_folder_metadata"
|
||||
) as mock_api:
|
||||
result = connector._get_folder_metadata(
|
||||
folder_id="folder1",
|
||||
retriever_email="retriever@example.com",
|
||||
field_type=DriveFileFieldType.SLIM,
|
||||
failed_folder_ids_by_email=failed_map,
|
||||
)
|
||||
|
||||
mock_api.assert_not_called()
|
||||
assert result is None
|
||||
|
||||
def test_records_failed_pair_when_no_parents(self) -> None:
|
||||
"""_get_folder_metadata must record (email → folder_id) in the map
|
||||
when the API returns a folder with no parents."""
|
||||
connector = _make_connector()
|
||||
failed_map: ThreadSafeDict[str, ThreadSafeSet[str]] = ThreadSafeDict()
|
||||
folder_no_parents: dict = {"id": "folder1", "name": "Orphaned"}
|
||||
|
||||
with (
|
||||
patch(
|
||||
"onyx.connectors.google_drive.connector.get_drive_service",
|
||||
return_value=MagicMock(),
|
||||
),
|
||||
patch(
|
||||
"onyx.connectors.google_drive.connector.get_folder_metadata",
|
||||
return_value=folder_no_parents,
|
||||
),
|
||||
):
|
||||
connector._get_folder_metadata(
|
||||
folder_id="folder1",
|
||||
retriever_email="retriever@example.com",
|
||||
field_type=DriveFileFieldType.SLIM,
|
||||
failed_folder_ids_by_email=failed_map,
|
||||
)
|
||||
|
||||
assert "folder1" in failed_map.get("retriever@example.com", ThreadSafeSet())
|
||||
assert "folder1" in failed_map.get("admin@example.com", ThreadSafeSet())
|
||||
|
||||
def test_does_not_record_when_parents_found(self) -> None:
|
||||
"""_get_folder_metadata must NOT record a pair when parents are found."""
|
||||
connector = _make_connector()
|
||||
failed_map: ThreadSafeDict[str, ThreadSafeSet[str]] = ThreadSafeDict()
|
||||
folder_with_parents: dict = {
|
||||
"id": "folder1",
|
||||
"name": "Normal",
|
||||
"parents": ["root"],
|
||||
}
|
||||
|
||||
with (
|
||||
patch(
|
||||
"onyx.connectors.google_drive.connector.get_drive_service",
|
||||
return_value=MagicMock(),
|
||||
),
|
||||
patch(
|
||||
"onyx.connectors.google_drive.connector.get_folder_metadata",
|
||||
return_value=folder_with_parents,
|
||||
),
|
||||
):
|
||||
connector._get_folder_metadata(
|
||||
folder_id="folder1",
|
||||
retriever_email="retriever@example.com",
|
||||
field_type=DriveFileFieldType.SLIM,
|
||||
failed_folder_ids_by_email=failed_map,
|
||||
)
|
||||
|
||||
assert len(failed_map) == 0
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { EmptyMessageCard } from "@opal/components";
|
||||
import { SvgSparkle, SvgUsers } from "@opal/icons";
|
||||
import { SvgActions, SvgServer, SvgSparkle, SvgUsers } from "@opal/icons";
|
||||
|
||||
const PADDING_VARIANTS = ["fit", "2xs", "xs", "sm", "md", "lg"] as const;
|
||||
|
||||
@@ -26,6 +26,22 @@ export const WithCustomIcon: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const MainUi: Story = {
|
||||
args: {
|
||||
sizePreset: "main-ui",
|
||||
title: "No Actions Found",
|
||||
icon: SvgActions,
|
||||
description: "Provide OpenAPI schema to preview actions here.",
|
||||
},
|
||||
};
|
||||
|
||||
export const MainUiNoDescription: Story = {
|
||||
args: {
|
||||
sizePreset: "main-ui",
|
||||
title: "No Knowledge",
|
||||
},
|
||||
};
|
||||
|
||||
export const PaddingVariants: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-col gap-4 w-96">
|
||||
@@ -46,6 +62,12 @@ export const Multiple: Story = {
|
||||
<EmptyMessageCard title="No models available." />
|
||||
<EmptyMessageCard icon={SvgSparkle} title="No agents selected." />
|
||||
<EmptyMessageCard icon={SvgUsers} title="No groups added." />
|
||||
<EmptyMessageCard
|
||||
sizePreset="main-ui"
|
||||
icon={SvgServer}
|
||||
title="No Discord servers configured yet"
|
||||
description="Create a server configuration to get started."
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
@@ -6,25 +6,44 @@ A pre-configured Card for empty states. Renders a transparent card with a dashed
|
||||
|
||||
## Props
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| --------- | --------------------------- | ---------- | -------------------------------- |
|
||||
| `icon` | `IconFunctionComponent` | `SvgEmpty` | Icon displayed alongside the title |
|
||||
| `title` | `string` | — | Primary message text (required) |
|
||||
| `padding` | `PaddingVariants` | `"sm"` | Padding preset for the card |
|
||||
| `ref` | `React.Ref<HTMLDivElement>` | — | Ref forwarded to the root div |
|
||||
### Base props (all presets)
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ------------ | ----------------------------- | ------------- | ---------------------------------- |
|
||||
| `sizePreset` | `"secondary" \| "main-ui"` | `"secondary"` | Controls layout and text sizing |
|
||||
| `icon` | `IconFunctionComponent` | `SvgEmpty` | Icon displayed alongside the title |
|
||||
| `title` | `string \| RichStr` | — | Primary message text (required) |
|
||||
| `padding` | `PaddingVariants` | `"md"` | Padding preset for the card |
|
||||
| `ref` | `React.Ref<HTMLDivElement>` | — | Ref forwarded to the root div |
|
||||
|
||||
### `sizePreset="main-ui"` only
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ------------- | ------------------- | ------- | ------------------------ |
|
||||
| `description` | `string \| RichStr` | — | Optional description text |
|
||||
|
||||
> `description` is **not accepted** when `sizePreset` is `"secondary"` (the default).
|
||||
|
||||
## Usage
|
||||
|
||||
```tsx
|
||||
import { EmptyMessageCard } from "@opal/components";
|
||||
import { SvgSparkle, SvgFileText } from "@opal/icons";
|
||||
import { SvgSparkle, SvgFileText, SvgActions } from "@opal/icons";
|
||||
|
||||
// Default empty state
|
||||
// Default empty state (secondary)
|
||||
<EmptyMessageCard title="No items yet." />
|
||||
|
||||
// With custom icon
|
||||
<EmptyMessageCard icon={SvgSparkle} title="No agents selected." />
|
||||
|
||||
// With custom padding
|
||||
// main-ui with description
|
||||
<EmptyMessageCard
|
||||
sizePreset="main-ui"
|
||||
icon={SvgActions}
|
||||
title="No Actions Found"
|
||||
description="Provide OpenAPI schema to preview actions here."
|
||||
/>
|
||||
|
||||
// Custom padding
|
||||
<EmptyMessageCard padding="xs" icon={SvgFileText} title="No documents available." />
|
||||
```
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Card } from "@opal/components/cards/card/components";
|
||||
import { Content, SizePreset } from "@opal/layouts";
|
||||
import { Content } from "@opal/layouts";
|
||||
import { SvgEmpty } from "@opal/icons";
|
||||
import type {
|
||||
IconFunctionComponent,
|
||||
@@ -32,7 +32,7 @@ type EmptyMessageCardProps =
|
||||
})
|
||||
| (EmptyMessageCardBaseProps & {
|
||||
sizePreset: "main-ui";
|
||||
/** Description text. Only supported when `sizePreset` is `"main-ui"`. */
|
||||
/** Optional description text. */
|
||||
description?: string | RichStr;
|
||||
});
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import { DeleteButton } from "@/components/DeleteButton";
|
||||
import { Button } from "@opal/components";
|
||||
import Switch from "@/refresh-components/inputs/Switch";
|
||||
import { SvgEdit, SvgServer } from "@opal/icons";
|
||||
import EmptyMessage from "@/refresh-components/EmptyMessage";
|
||||
import { EmptyMessageCard } from "@opal/components";
|
||||
import { DiscordGuildConfig } from "@/app/admin/discord-bot/types";
|
||||
import {
|
||||
deleteGuildConfig,
|
||||
@@ -81,7 +81,8 @@ export function DiscordGuildsTable({ guilds, onRefresh }: Props) {
|
||||
|
||||
if (guilds.length === 0) {
|
||||
return (
|
||||
<EmptyMessage
|
||||
<EmptyMessageCard
|
||||
sizePreset="main-ui"
|
||||
icon={SvgServer}
|
||||
title="No Discord servers configured yet"
|
||||
description="Create a server configuration to get started."
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from "@/components/ui/table";
|
||||
import Switch from "@/refresh-components/inputs/Switch";
|
||||
import InputSelect from "@/refresh-components/inputs/InputSelect";
|
||||
import EmptyMessage from "@/refresh-components/EmptyMessage";
|
||||
import { EmptyMessageCard } from "@opal/components";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import { Section } from "@/layouts/general-layouts";
|
||||
import {
|
||||
@@ -61,7 +61,8 @@ export function DiscordChannelsTable({
|
||||
}: Props) {
|
||||
if (channels.length === 0) {
|
||||
return (
|
||||
<EmptyMessage
|
||||
<EmptyMessageCard
|
||||
sizePreset="main-ui"
|
||||
title="No channels configured"
|
||||
description="Run !sync-channels in Discord to add channels."
|
||||
/>
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from "@/lib/search/interfaces";
|
||||
import SearchCard from "@/ee/sections/SearchCard";
|
||||
import { Divider, Pagination } from "@opal/components";
|
||||
import EmptyMessage from "@/refresh-components/EmptyMessage";
|
||||
import { EmptyMessageCard } from "@opal/components";
|
||||
import { IllustrationContent } from "@opal/layouts";
|
||||
import SvgNoResult from "@opal/illustrations/no-result";
|
||||
import { getSourceMetadata } from "@/lib/sources";
|
||||
@@ -334,7 +334,11 @@ export default function SearchUI({ onDocumentClick }: SearchResultsProps) {
|
||||
)}
|
||||
>
|
||||
{error ? (
|
||||
<EmptyMessage title="Search failed" description={error} />
|
||||
<EmptyMessageCard
|
||||
sizePreset="main-ui"
|
||||
title="Search failed"
|
||||
description={error}
|
||||
/>
|
||||
) : paginatedResults.length > 0 ? (
|
||||
<>
|
||||
{paginatedResults.map((doc) => (
|
||||
|
||||
261
web/src/layouts/actions-layouts.tsx
Normal file
261
web/src/layouts/actions-layouts.tsx
Normal file
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* Actions Layout Components
|
||||
*
|
||||
* A namespaced collection of components for building consistent action cards
|
||||
* (MCP servers, OpenAPI tools, etc.). These components provide a standardized
|
||||
* layout that separates presentation from business logic, making it easier to
|
||||
* build and maintain action-related UIs.
|
||||
*
|
||||
* Built on top of ExpandableCard layouts for the underlying card structure.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import * as ActionsLayouts from "@/layouts/actions-layouts";
|
||||
* import * as ExpandableCard from "@/layouts/expandable-card-layouts";
|
||||
* import { SvgServer } from "@opal/icons";
|
||||
* import Switch from "@/components/ui/switch";
|
||||
*
|
||||
* function MyActionCard() {
|
||||
* return (
|
||||
* <ExpandableCard.Root>
|
||||
* <ActionsLayouts.Header
|
||||
* title="My MCP Server"
|
||||
* description="A powerful MCP server for automation"
|
||||
* icon={SvgServer}
|
||||
* rightChildren={
|
||||
* <Button onClick={handleDisconnect}>Disconnect</Button>
|
||||
* }
|
||||
* />
|
||||
* <ActionsLayouts.Content>
|
||||
* <ActionsLayouts.Tool
|
||||
* title="File Reader"
|
||||
* description="Read files from the filesystem"
|
||||
* icon={SvgFile}
|
||||
* rightChildren={
|
||||
* <Switch checked={enabled} onCheckedChange={setEnabled} />
|
||||
* }
|
||||
* />
|
||||
* <ActionsLayouts.Tool
|
||||
* title="Web Search"
|
||||
* description="Search the web"
|
||||
* icon={SvgGlobe}
|
||||
* disabled={true}
|
||||
* rightChildren={
|
||||
* <Switch checked={false} disabled />
|
||||
* }
|
||||
* />
|
||||
* </ActionsLayouts.Content>
|
||||
* </ExpandableCard.Root>
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import React, { HtmlHTMLAttributes } from "react";
|
||||
import type { IconProps } from "@opal/types";
|
||||
import { WithoutStyles } from "@/types";
|
||||
import { ContentAction } from "@opal/layouts";
|
||||
import * as ExpandableCard from "@/layouts/expandable-card-layouts";
|
||||
import { Card } from "@/refresh-components/cards";
|
||||
import { Label } from "@opal/layouts";
|
||||
|
||||
/**
|
||||
* Actions Header Component
|
||||
*
|
||||
* The header section of an action card. Displays icon, title, description,
|
||||
* and optional right-aligned actions.
|
||||
*
|
||||
* Features:
|
||||
* - Icon, title, and description display
|
||||
* - Custom right-aligned actions via rightChildren
|
||||
* - Responsive layout with truncated text
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // Basic header
|
||||
* <ActionsLayouts.Header
|
||||
* title="File Server"
|
||||
* description="Manage local files"
|
||||
* icon={SvgFolder}
|
||||
* />
|
||||
*
|
||||
* // With actions
|
||||
* <ActionsLayouts.Header
|
||||
* title="API Server"
|
||||
* description="RESTful API integration"
|
||||
* icon={SvgCloud}
|
||||
* rightChildren={
|
||||
* <div className="flex gap-2">
|
||||
* <Button onClick={handleEdit}>Edit</Button>
|
||||
* <Button onClick={handleDelete}>Delete</Button>
|
||||
* </div>
|
||||
* }
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export interface ActionsHeaderProps
|
||||
extends WithoutStyles<HtmlHTMLAttributes<HTMLDivElement>> {
|
||||
// Core content
|
||||
name?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
icon: React.FunctionComponent<IconProps>;
|
||||
|
||||
// Custom content
|
||||
rightChildren?: React.ReactNode;
|
||||
}
|
||||
function ActionsHeader({
|
||||
name,
|
||||
title,
|
||||
description,
|
||||
icon: Icon,
|
||||
rightChildren,
|
||||
...props
|
||||
}: ActionsHeaderProps) {
|
||||
return (
|
||||
<ExpandableCard.Header>
|
||||
<div className="flex flex-col gap-2 pt-4 pb-2">
|
||||
<div className="px-4">
|
||||
<Label label={name}>
|
||||
<ContentAction
|
||||
icon={Icon}
|
||||
title={title}
|
||||
description={description}
|
||||
sizePreset="section"
|
||||
variant="section"
|
||||
rightChildren={rightChildren}
|
||||
paddingVariant="fit"
|
||||
/>
|
||||
</Label>
|
||||
</div>
|
||||
<div {...props} className="px-2" />
|
||||
</div>
|
||||
</ExpandableCard.Header>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Actions Content Component
|
||||
*
|
||||
* A container for the content area of an action card.
|
||||
* Use this to wrap tools, settings, or other expandable content.
|
||||
* Features a maximum height with scrollable overflow.
|
||||
*
|
||||
* IMPORTANT: Only ONE ActionsContent should be used within a single ExpandableCard.Root.
|
||||
* This component self-registers with the ActionsLayout context to inform
|
||||
* ActionsHeader whether content exists (for border-radius styling). Using
|
||||
* multiple ActionsContent components will cause incorrect unmount behavior -
|
||||
* when any one unmounts, it will incorrectly signal that no content exists,
|
||||
* even if other ActionsContent components remain mounted.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <ActionsLayouts.Content>
|
||||
* <ActionsLayouts.Tool {...} />
|
||||
* <ActionsLayouts.Tool {...} />
|
||||
* </ActionsLayouts.Content>
|
||||
* ```
|
||||
*/
|
||||
function ActionsContent({
|
||||
children,
|
||||
...props
|
||||
}: WithoutStyles<React.HTMLAttributes<HTMLDivElement>>) {
|
||||
return (
|
||||
<ExpandableCard.Content {...props}>
|
||||
<div className="flex flex-col gap-2 p-2">{children}</div>
|
||||
</ExpandableCard.Content>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Actions Tool Component
|
||||
*
|
||||
* Represents a single tool within an actions content area. Displays the tool's
|
||||
* title, description, and icon. The component provides a label wrapper for
|
||||
* custom right-aligned controls (like toggle switches).
|
||||
*
|
||||
* Features:
|
||||
* - Tool title and description
|
||||
* - Custom icon
|
||||
* - Disabled state (applies strikethrough to title)
|
||||
* - Custom right-aligned content via rightChildren
|
||||
* - Responsive layout with truncated text
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // Basic tool with switch
|
||||
* <ActionsLayouts.Tool
|
||||
* title="File Reader"
|
||||
* description="Read files from the filesystem"
|
||||
* icon={SvgFile}
|
||||
* rightChildren={
|
||||
* <Switch checked={enabled} onCheckedChange={setEnabled} />
|
||||
* }
|
||||
* />
|
||||
*
|
||||
* // Disabled tool
|
||||
* <ActionsLayouts.Tool
|
||||
* title="Premium Feature"
|
||||
* description="This feature requires a premium subscription"
|
||||
* icon={SvgLock}
|
||||
* disabled={true}
|
||||
* rightChildren={
|
||||
* <Switch checked={false} disabled />
|
||||
* }
|
||||
* />
|
||||
*
|
||||
* // Tool with custom action
|
||||
* <ActionsLayouts.Tool
|
||||
* name="config_tool"
|
||||
* title="Configuration"
|
||||
* description="Configure system settings"
|
||||
* icon={SvgSettings}
|
||||
* rightChildren={
|
||||
* <Button onClick={openSettings}>Configure</Button>
|
||||
* }
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export type ActionsToolProps = WithoutStyles<{
|
||||
// Core content
|
||||
name?: string;
|
||||
title: string;
|
||||
description: string;
|
||||
icon?: React.FunctionComponent<IconProps>;
|
||||
|
||||
// State
|
||||
disabled?: boolean;
|
||||
rightChildren?: React.ReactNode;
|
||||
}>;
|
||||
function ActionsTool({
|
||||
name,
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
disabled,
|
||||
rightChildren,
|
||||
}: ActionsToolProps) {
|
||||
return (
|
||||
<Card padding={0.75} variant={disabled ? "disabled" : undefined}>
|
||||
<Label label={name} disabled={disabled}>
|
||||
<ContentAction
|
||||
icon={icon}
|
||||
title={title}
|
||||
description={description}
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
rightChildren={rightChildren}
|
||||
paddingVariant="fit"
|
||||
/>
|
||||
</Label>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
ActionsHeader as Header,
|
||||
ActionsContent as Content,
|
||||
ActionsTool as Tool,
|
||||
};
|
||||
291
web/src/layouts/expandable-card-layouts.tsx
Normal file
291
web/src/layouts/expandable-card-layouts.tsx
Normal file
@@ -0,0 +1,291 @@
|
||||
/**
|
||||
* Expandable Card Layout Components
|
||||
*
|
||||
* A namespaced collection of components for building expandable cards with
|
||||
* collapsible content sections. These provide the structural foundation
|
||||
* without opinionated content styling - just pure containers.
|
||||
*
|
||||
* Use these components when you need:
|
||||
* - A card with a header that can have expandable content below it
|
||||
* - Automatic border-radius handling based on whether content exists/is folded
|
||||
* - Controlled or uncontrolled folding state
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import * as ExpandableCard from "@/layouts/expandable-card-layouts";
|
||||
*
|
||||
* // Uncontrolled — Root manages its own state
|
||||
* function MyCard() {
|
||||
* return (
|
||||
* <ExpandableCard.Root>
|
||||
* <ExpandableCard.Header>
|
||||
* <div className="p-4">
|
||||
* <h3>My Header</h3>
|
||||
* </div>
|
||||
* </ExpandableCard.Header>
|
||||
* <ExpandableCard.Content>
|
||||
* <div className="p-4">
|
||||
* <p>Expandable content goes here</p>
|
||||
* </div>
|
||||
* </ExpandableCard.Content>
|
||||
* </ExpandableCard.Root>
|
||||
* );
|
||||
* }
|
||||
*
|
||||
* // Controlled — consumer owns the state
|
||||
* function MyControlledCard() {
|
||||
* const [isFolded, setIsFolded] = useState(false);
|
||||
*
|
||||
* return (
|
||||
* <ExpandableCard.Root isFolded={isFolded} onFoldedChange={setIsFolded}>
|
||||
* <ExpandableCard.Header>
|
||||
* <button onClick={() => setIsFolded(!isFolded)}>Toggle</button>
|
||||
* </ExpandableCard.Header>
|
||||
* <ExpandableCard.Content>
|
||||
* <p>Content here</p>
|
||||
* </ExpandableCard.Content>
|
||||
* </ExpandableCard.Root>
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useMemo,
|
||||
useLayoutEffect,
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
} from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { WithoutStyles } from "@/types";
|
||||
import ShadowDiv from "@/refresh-components/ShadowDiv";
|
||||
import { Section, SectionProps } from "@/layouts/general-layouts";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
} from "@/refresh-components/Collapsible";
|
||||
|
||||
/**
|
||||
* Expandable Card Context
|
||||
*
|
||||
* Provides folding state management for expandable cards without prop drilling.
|
||||
* Also tracks whether content is present via self-registration.
|
||||
*/
|
||||
interface ExpandableCardContextValue {
|
||||
isFolded: boolean;
|
||||
setIsFolded: Dispatch<SetStateAction<boolean>>;
|
||||
hasContent: boolean;
|
||||
registerContent: () => () => void;
|
||||
}
|
||||
|
||||
const ExpandableCardContext = createContext<
|
||||
ExpandableCardContextValue | undefined
|
||||
>(undefined);
|
||||
|
||||
function useExpandableCardContext() {
|
||||
const context = useContext(ExpandableCardContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"ExpandableCard components must be used within an ExpandableCard.Root"
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Expandable Card Root Component
|
||||
*
|
||||
* The root container and context provider for an expandable card. Provides a
|
||||
* flex column layout with no gap or padding by default.
|
||||
*
|
||||
* Supports both controlled and uncontrolled folding state:
|
||||
* - **Uncontrolled**: Manages its own state. Use `defaultFolded` to set the
|
||||
* initial folding state (defaults to `false`, i.e. expanded).
|
||||
* - **Controlled**: Pass `isFolded` and `onFoldedChange` to manage folding
|
||||
* state externally.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // Uncontrolled
|
||||
* <ExpandableCard.Root>
|
||||
* <ExpandableCard.Header>...</ExpandableCard.Header>
|
||||
* <ExpandableCard.Content>...</ExpandableCard.Content>
|
||||
* </ExpandableCard.Root>
|
||||
*
|
||||
* // Uncontrolled, starts folded
|
||||
* <ExpandableCard.Root defaultFolded>
|
||||
* ...
|
||||
* </ExpandableCard.Root>
|
||||
*
|
||||
* // Controlled
|
||||
* const [isFolded, setIsFolded] = useState(false);
|
||||
* <ExpandableCard.Root isFolded={isFolded} onFoldedChange={setIsFolded}>
|
||||
* ...
|
||||
* </ExpandableCard.Root>
|
||||
* ```
|
||||
*/
|
||||
export interface ExpandableCardRootProps extends SectionProps {
|
||||
/** Controlled folding state. When provided, the component is controlled. */
|
||||
isFolded?: boolean;
|
||||
/** Callback when folding state changes. Required for controlled usage. */
|
||||
onFoldedChange?: Dispatch<SetStateAction<boolean>>;
|
||||
/** Initial folding state for uncontrolled usage. Defaults to `false`. */
|
||||
defaultFolded?: boolean;
|
||||
}
|
||||
|
||||
function ExpandableCardRoot({
|
||||
isFolded: controlledFolded,
|
||||
onFoldedChange,
|
||||
defaultFolded = false,
|
||||
...props
|
||||
}: ExpandableCardRootProps) {
|
||||
const [uncontrolledFolded, setUncontrolledFolded] = useState(defaultFolded);
|
||||
const isControlled = controlledFolded !== undefined;
|
||||
const isFolded = isControlled ? controlledFolded : uncontrolledFolded;
|
||||
const setIsFolded = isControlled
|
||||
? onFoldedChange ?? (() => {})
|
||||
: setUncontrolledFolded;
|
||||
|
||||
const [hasContent, setHasContent] = useState(false);
|
||||
|
||||
// Registration function for Content to announce its presence
|
||||
const registerContent = useMemo(
|
||||
() => () => {
|
||||
setHasContent(true);
|
||||
return () => setHasContent(false);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({ isFolded, setIsFolded, hasContent, registerContent }),
|
||||
[isFolded, setIsFolded, hasContent, registerContent]
|
||||
);
|
||||
|
||||
return (
|
||||
<ExpandableCardContext.Provider value={contextValue}>
|
||||
<Section gap={0} padding={0} {...props} />
|
||||
</ExpandableCardContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expandable Card Header Component
|
||||
*
|
||||
* The header section of an expandable card. This is a pure container that:
|
||||
* - Has a border and neutral background
|
||||
* - Automatically handles border-radius based on content state:
|
||||
* - Fully rounded when no content exists or when content is folded
|
||||
* - Only top-rounded when content is visible
|
||||
*
|
||||
* You are responsible for adding your own padding, layout, and content inside.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <ExpandableCard.Header>
|
||||
* <div className="flex items-center justify-between p-4">
|
||||
* <h3>My Title</h3>
|
||||
* <button>Action</button>
|
||||
* </div>
|
||||
* </ExpandableCard.Header>
|
||||
* ```
|
||||
*/
|
||||
export interface ExpandableCardHeaderProps
|
||||
extends WithoutStyles<React.HTMLAttributes<HTMLDivElement>> {
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
function ExpandableCardHeader({
|
||||
children,
|
||||
...props
|
||||
}: ExpandableCardHeaderProps) {
|
||||
const { isFolded, hasContent } = useExpandableCardContext();
|
||||
|
||||
// Round all corners if there's no content, or if content exists but is folded
|
||||
const shouldFullyRound = !hasContent || isFolded;
|
||||
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={cn(
|
||||
"border bg-background-neutral-00 w-full transition-[border-radius] duration-200 ease-out",
|
||||
shouldFullyRound ? "rounded-16" : "rounded-t-16"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expandable Card Content Component
|
||||
*
|
||||
* The expandable content section of the card. This is a pure container that:
|
||||
* - Self-registers with context to inform Header about its presence
|
||||
* - Animates open/closed using Radix Collapsible (slide down/up)
|
||||
* - Has side and bottom borders that connect to the header
|
||||
* - Has a max-height with scrollable overflow via ShadowDiv
|
||||
*
|
||||
* You are responsible for adding your own content inside.
|
||||
*
|
||||
* IMPORTANT: Only ONE Content component should be used within a single Root.
|
||||
* This component self-registers with the context to inform Header whether
|
||||
* content exists (for border-radius styling). Using multiple Content components
|
||||
* will cause incorrect unmount behavior.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <ExpandableCard.Content>
|
||||
* <div className="p-4">
|
||||
* <p>Your expandable content here</p>
|
||||
* </div>
|
||||
* </ExpandableCard.Content>
|
||||
* ```
|
||||
*/
|
||||
export interface ExpandableCardContentProps
|
||||
extends WithoutStyles<React.HTMLAttributes<HTMLDivElement>> {
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
function ExpandableCardContent({
|
||||
children,
|
||||
...props
|
||||
}: ExpandableCardContentProps) {
|
||||
const { isFolded, registerContent } = useExpandableCardContext();
|
||||
|
||||
// Self-register with context to inform Header that content exists
|
||||
useLayoutEffect(() => {
|
||||
return registerContent();
|
||||
}, [registerContent]);
|
||||
|
||||
return (
|
||||
<Collapsible open={!isFolded} className="w-full">
|
||||
<CollapsibleContent>
|
||||
<div
|
||||
className={cn(
|
||||
"border-x border-b rounded-b-16 overflow-hidden w-full transition-opacity duration-200 ease-out",
|
||||
isFolded ? "opacity-0" : "opacity-100"
|
||||
)}
|
||||
>
|
||||
<ShadowDiv
|
||||
className="flex flex-col rounded-b-16 max-h-[20rem]"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ShadowDiv>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
ExpandableCardRoot as Root,
|
||||
ExpandableCardHeader as Header,
|
||||
ExpandableCardContent as Content,
|
||||
};
|
||||
@@ -1,42 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import EmptyMessage from "./EmptyMessage";
|
||||
import { SvgFileText, SvgUsers } from "@opal/icons";
|
||||
|
||||
const meta: Meta<typeof EmptyMessage> = {
|
||||
title: "refresh-components/messages/EmptyMessage",
|
||||
component: EmptyMessage,
|
||||
tags: ["autodocs"],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof EmptyMessage>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
title: "No items found",
|
||||
},
|
||||
};
|
||||
|
||||
export const WithDescription: Story = {
|
||||
args: {
|
||||
title: "No connectors configured",
|
||||
description:
|
||||
"Set up a connector to start indexing documents from your data sources.",
|
||||
},
|
||||
};
|
||||
|
||||
export const WithCustomIcon: Story = {
|
||||
args: {
|
||||
icon: SvgFileText,
|
||||
title: "No documents available",
|
||||
description: "Upload documents or connect a data source to get started.",
|
||||
},
|
||||
};
|
||||
|
||||
export const UsersEmpty: Story = {
|
||||
args: {
|
||||
icon: SvgUsers,
|
||||
title: "No users in this group",
|
||||
description: "Add users to this group to grant them access.",
|
||||
},
|
||||
};
|
||||
@@ -1,64 +0,0 @@
|
||||
/**
|
||||
* EmptyMessage - A component for displaying empty state messages
|
||||
*
|
||||
* Displays a translucent card with an icon and message text to indicate
|
||||
* when no data or content is available.
|
||||
*
|
||||
* Features:
|
||||
* - Translucent card background with dashed border
|
||||
* - Horizontal layout with icon on left, text on right
|
||||
* - 0.5rem gap between icon and text
|
||||
* - Accepts string children for the message text
|
||||
* - Customizable icon
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import EmptyMessage from "@/refresh-components/EmptyMessage";
|
||||
* import { SvgActivity } from "@opal/icons";
|
||||
*
|
||||
* // Basic usage
|
||||
* <EmptyMessage icon={SvgActivity}>
|
||||
* No connectors set up for your organization.
|
||||
* </EmptyMessage>
|
||||
*
|
||||
* // With different icon
|
||||
* <EmptyMessage icon={SvgFileText}>
|
||||
* No documents available.
|
||||
* </EmptyMessage>
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { SvgEmpty } from "@opal/icons";
|
||||
import Card from "@/refresh-components/cards/Card";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import { Content } from "@opal/layouts";
|
||||
import { IconProps } from "@opal/types";
|
||||
|
||||
export interface EmptyMessageProps {
|
||||
icon?: React.FunctionComponent<IconProps>;
|
||||
title: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export default function EmptyMessage({
|
||||
icon: Icon = SvgEmpty,
|
||||
title,
|
||||
description,
|
||||
}: EmptyMessageProps) {
|
||||
return (
|
||||
<Card variant="tertiary">
|
||||
<Content
|
||||
icon={Icon}
|
||||
title={title}
|
||||
sizePreset="main-ui"
|
||||
variant="body"
|
||||
prominence="muted"
|
||||
/>
|
||||
{description && (
|
||||
<Text secondaryBody text03>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -82,6 +82,7 @@ export interface LineItemProps
|
||||
|
||||
selected?: boolean;
|
||||
icon?: React.FunctionComponent<IconProps>;
|
||||
strokeIcon?: boolean;
|
||||
description?: string;
|
||||
rightChildren?: React.ReactNode;
|
||||
href?: string;
|
||||
@@ -154,6 +155,7 @@ export default function LineItem({
|
||||
skeleton,
|
||||
emphasized,
|
||||
icon: Icon,
|
||||
strokeIcon = true,
|
||||
description,
|
||||
children,
|
||||
rightChildren,
|
||||
@@ -245,7 +247,12 @@ export default function LineItem({
|
||||
!!(children && description) && "mt-0.5"
|
||||
)}
|
||||
>
|
||||
<Icon className={cn("h-[1rem] w-[1rem]", iconClassNames[variant])} />
|
||||
<Icon
|
||||
className={cn(
|
||||
"h-[1rem] w-[1rem]",
|
||||
strokeIcon && iconClassNames[variant]
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Section alignItems="start" gap={0}>
|
||||
|
||||
@@ -99,6 +99,7 @@ export default function SwitchList({
|
||||
item.leading) as React.FunctionComponent<IconProps>)
|
||||
: undefined
|
||||
}
|
||||
strokeIcon={false}
|
||||
rightChildren={
|
||||
<Switch
|
||||
checked={item.isEnabled}
|
||||
|
||||
@@ -172,6 +172,7 @@ export default function ModelListContent({
|
||||
<LineItem
|
||||
muted
|
||||
icon={group.Icon}
|
||||
strokeIcon={false}
|
||||
rightChildren={
|
||||
open ? (
|
||||
<SvgChevronDown className="h-4 w-4 stroke-text-04 shrink-0" />
|
||||
|
||||
@@ -53,7 +53,7 @@ import CharacterCount from "@/refresh-components/CharacterCount";
|
||||
import { InputPrompt } from "@/app/app/interfaces";
|
||||
import usePromptShortcuts from "@/hooks/usePromptShortcuts";
|
||||
import ColorSwatch from "@/refresh-components/ColorSwatch";
|
||||
import EmptyMessage from "@/refresh-components/EmptyMessage";
|
||||
import { EmptyMessageCard } from "@opal/components";
|
||||
import Memories from "@/sections/settings/Memories";
|
||||
import { FederatedConnectorOAuthStatus } from "@/components/chat/FederatedOAuthModal";
|
||||
import {
|
||||
@@ -1701,7 +1701,10 @@ function ConnectorsSettings() {
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<EmptyMessage title="No connectors set up for your organization." />
|
||||
<EmptyMessageCard
|
||||
sizePreset="main-ui"
|
||||
title="No connectors set up for your organization."
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
</Section>
|
||||
|
||||
@@ -26,19 +26,14 @@ import {
|
||||
SvgRefreshCw,
|
||||
} from "@opal/icons";
|
||||
import { ADMIN_ROUTES } from "@/lib/admin-routes";
|
||||
import {
|
||||
Card as CardLayout,
|
||||
Content,
|
||||
InputHorizontal,
|
||||
InputVertical,
|
||||
} from "@opal/layouts";
|
||||
import { Content, InputHorizontal, InputVertical } from "@opal/layouts";
|
||||
import {
|
||||
useSettingsContext,
|
||||
useVectorDbEnabled,
|
||||
} from "@/providers/SettingsProvider";
|
||||
import useCCPairs from "@/hooks/useCCPairs";
|
||||
import { getSourceMetadata } from "@/lib/sources";
|
||||
import EmptyMessage from "@/refresh-components/EmptyMessage";
|
||||
import { EmptyMessageCard } from "@opal/components";
|
||||
import { Settings } from "@/interfaces/settings";
|
||||
import { toast } from "@/hooks/useToast";
|
||||
import { useAvailableTools } from "@/hooks/useAvailableTools";
|
||||
@@ -54,6 +49,8 @@ import Modal from "@/refresh-components/Modal";
|
||||
import Switch from "@/refresh-components/inputs/Switch";
|
||||
import useMcpServersForAgentEditor from "@/hooks/useMcpServersForAgentEditor";
|
||||
import useOpenApiTools from "@/hooks/useOpenApiTools";
|
||||
import * as ExpandableCard from "@/layouts/expandable-card-layouts";
|
||||
import * as ActionsLayouts from "@/layouts/actions-layouts";
|
||||
import { getActionIcon } from "@/lib/tools/mcpUtils";
|
||||
import { Disabled, Hoverable } from "@opal/core";
|
||||
import IconButton from "@/refresh-components/buttons/IconButton";
|
||||
@@ -106,60 +103,13 @@ function MCPServerCard({
|
||||
? "Authenticate this MCP server before enabling its tools."
|
||||
: undefined;
|
||||
|
||||
const expanded = !isFolded;
|
||||
const hasContent = tools.length > 0 && filteredTools.length > 0;
|
||||
|
||||
return (
|
||||
<OpalCard
|
||||
expandable
|
||||
expanded={expanded}
|
||||
border="solid"
|
||||
rounding="lg"
|
||||
padding="sm"
|
||||
expandedContent={
|
||||
hasContent ? (
|
||||
<div className="flex flex-col gap-2 p-2">
|
||||
{filteredTools.map((tool) => (
|
||||
<OpalCard key={tool.id} border="solid" rounding="lg" padding="sm">
|
||||
<CardLayout.Header
|
||||
headerChildren={
|
||||
<Content
|
||||
icon={tool.icon}
|
||||
title={tool.name}
|
||||
description={tool.description}
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
/>
|
||||
}
|
||||
topRightChildren={
|
||||
<Tooltip tooltip={authTooltip} side="top">
|
||||
<Switch
|
||||
checked={isToolEnabled(tool.id)}
|
||||
onCheckedChange={(checked) =>
|
||||
onToggleTool(tool.id, checked)
|
||||
}
|
||||
disabled={needsAuth}
|
||||
/>
|
||||
</Tooltip>
|
||||
}
|
||||
/>
|
||||
</OpalCard>
|
||||
))}
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<CardLayout.Header
|
||||
headerChildren={
|
||||
<Content
|
||||
icon={getActionIcon(server.server_url, server.name)}
|
||||
title={server.name}
|
||||
description={server.description}
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
/>
|
||||
}
|
||||
topRightChildren={
|
||||
<ExpandableCard.Root isFolded={isFolded} onFoldedChange={setIsFolded}>
|
||||
<ActionsLayouts.Header
|
||||
title={server.name}
|
||||
description={server.description}
|
||||
icon={getActionIcon(server.server_url, server.name)}
|
||||
rightChildren={
|
||||
<Tooltip tooltip={authTooltip} side="top">
|
||||
<Switch
|
||||
checked={serverEnabled}
|
||||
@@ -168,29 +118,53 @@ function MCPServerCard({
|
||||
/>
|
||||
</Tooltip>
|
||||
}
|
||||
bottomChildren={
|
||||
tools.length > 0 ? (
|
||||
<Section flexDirection="row" gap={0.5}>
|
||||
<InputTypeIn
|
||||
placeholder="Search tools..."
|
||||
variant="internal"
|
||||
leftSearchIcon
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
>
|
||||
{tools.length > 0 && (
|
||||
<Section flexDirection="row" gap={0.5}>
|
||||
<InputTypeIn
|
||||
placeholder="Search tools..."
|
||||
variant="internal"
|
||||
leftSearchIcon
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
rightIcon={isFolded ? SvgExpand : SvgFold}
|
||||
onClick={() => setIsFolded((prev) => !prev)}
|
||||
prominence="internal"
|
||||
size="lg"
|
||||
>
|
||||
{isFolded ? "Expand" : "Fold"}
|
||||
</Button>
|
||||
</Section>
|
||||
)}
|
||||
</ActionsLayouts.Header>
|
||||
{tools.length > 0 && filteredTools.length > 0 && (
|
||||
<ActionsLayouts.Content>
|
||||
<div className="flex flex-col gap-2">
|
||||
{filteredTools.map((tool) => (
|
||||
<ActionsLayouts.Tool
|
||||
key={tool.id}
|
||||
title={tool.name}
|
||||
description={tool.description}
|
||||
icon={tool.icon}
|
||||
rightChildren={
|
||||
<Tooltip tooltip={authTooltip} side="top">
|
||||
<Switch
|
||||
checked={isToolEnabled(tool.id)}
|
||||
onCheckedChange={(checked) =>
|
||||
onToggleTool(tool.id, checked)
|
||||
}
|
||||
disabled={needsAuth}
|
||||
/>
|
||||
</Tooltip>
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
rightIcon={isFolded ? SvgExpand : SvgFold}
|
||||
onClick={() => setIsFolded((prev) => !prev)}
|
||||
prominence="internal"
|
||||
size="lg"
|
||||
>
|
||||
{isFolded ? "Expand" : "Fold"}
|
||||
</Button>
|
||||
</Section>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</OpalCard>
|
||||
))}
|
||||
</div>
|
||||
</ActionsLayouts.Content>
|
||||
)}
|
||||
</ExpandableCard.Root>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -698,7 +672,10 @@ function ChatPreferencesForm() {
|
||||
gap={0.25}
|
||||
>
|
||||
{uniqueSources.length === 0 ? (
|
||||
<EmptyMessage title="No connectors set up" />
|
||||
<EmptyMessageCard
|
||||
sizePreset="main-ui"
|
||||
title="No connectors set up"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Section
|
||||
@@ -884,23 +861,12 @@ function ChatPreferencesForm() {
|
||||
/>
|
||||
))}
|
||||
{openApiTools.map((tool) => (
|
||||
<OpalCard
|
||||
key={tool.id}
|
||||
border="solid"
|
||||
rounding="lg"
|
||||
padding="sm"
|
||||
>
|
||||
<CardLayout.Header
|
||||
headerChildren={
|
||||
<Content
|
||||
icon={SvgActions}
|
||||
title={tool.display_name || tool.name}
|
||||
description={tool.description}
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
/>
|
||||
}
|
||||
topRightChildren={
|
||||
<ExpandableCard.Root key={tool.id} defaultFolded>
|
||||
<ActionsLayouts.Header
|
||||
title={tool.display_name || tool.name}
|
||||
description={tool.description}
|
||||
icon={SvgActions}
|
||||
rightChildren={
|
||||
<Switch
|
||||
checked={isToolEnabled(tool.id)}
|
||||
onCheckedChange={(checked) =>
|
||||
@@ -909,7 +875,7 @@ function ChatPreferencesForm() {
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</OpalCard>
|
||||
</ExpandableCard.Root>
|
||||
))}
|
||||
</Section>
|
||||
</SimpleCollapsible.Content>
|
||||
|
||||
@@ -146,6 +146,7 @@ function SharedGroupResources({
|
||||
interactive={!dimmed}
|
||||
muted={dimmed}
|
||||
icon={getSourceMetadata(p.connector.source).icon}
|
||||
strokeIcon={false}
|
||||
rightChildren={
|
||||
p.groups.length > 0 || dimmed ? <SharedBadge /> : undefined
|
||||
}
|
||||
|
||||
@@ -186,6 +186,7 @@ export default function UserFilters({
|
||||
<LineItem
|
||||
key={role}
|
||||
icon={isSelected ? SvgCheck : roleIcon}
|
||||
strokeIcon={isSelected || role !== UserRole.SLACK_USER}
|
||||
selected={isSelected}
|
||||
emphasized={isSelected}
|
||||
onClick={() => toggleRole(role)}
|
||||
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
import InfoBlock from "@/refresh-components/messages/InfoBlock";
|
||||
import { getActionIcon } from "@/lib/tools/mcpUtils";
|
||||
import { Section } from "@/layouts/general-layouts";
|
||||
import EmptyMessage from "@/refresh-components/EmptyMessage";
|
||||
import { EmptyMessageCard } from "@opal/components";
|
||||
|
||||
interface AddOpenAPIActionModalProps {
|
||||
skipOverlay?: boolean;
|
||||
@@ -312,7 +312,8 @@ function FormContent({
|
||||
</Section>
|
||||
</>
|
||||
) : (
|
||||
<EmptyMessage
|
||||
<EmptyMessageCard
|
||||
sizePreset="main-ui"
|
||||
title="No Actions Found"
|
||||
icon={SvgActions}
|
||||
description="Provide OpenAPI schema to preview actions here."
|
||||
|
||||
@@ -129,6 +129,7 @@ function KnowledgeSidebar({
|
||||
<LineItem
|
||||
key={connectedSource.source}
|
||||
icon={sourceMetadata.icon}
|
||||
strokeIcon={false}
|
||||
onClick={() => onNavigateToSource(connectedSource.source)}
|
||||
selected={isActive}
|
||||
emphasized={isActive || isSelected || selectionCount > 0}
|
||||
@@ -718,6 +719,7 @@ const KnowledgeAddView = memo(function KnowledgeAddView({
|
||||
<LineItem
|
||||
key={connectedSource.source}
|
||||
icon={sourceMetadata.icon}
|
||||
strokeIcon={false}
|
||||
onClick={() => onNavigateToSource(connectedSource.source)}
|
||||
emphasized={isSelected || selectionCount > 0}
|
||||
aria-label={`knowledge-add-source-${connectedSource.source}`}
|
||||
|
||||
@@ -7,15 +7,10 @@ import { FullPersona } from "@/app/admin/agents/interfaces";
|
||||
import { useModal } from "@/refresh-components/contexts/ModalContext";
|
||||
import Modal from "@/refresh-components/Modal";
|
||||
import { Section } from "@/layouts/general-layouts";
|
||||
import {
|
||||
Card as CardLayout,
|
||||
Content,
|
||||
ContentAction,
|
||||
InputHorizontal,
|
||||
} from "@opal/layouts";
|
||||
import { Content, ContentAction, InputHorizontal } from "@opal/layouts";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import AgentAvatar from "@/refresh-components/avatars/AgentAvatar";
|
||||
import { Card, Divider } from "@opal/components";
|
||||
import { Divider } from "@opal/components";
|
||||
import SimpleCollapsible from "@/refresh-components/SimpleCollapsible";
|
||||
import {
|
||||
SvgActions,
|
||||
@@ -26,10 +21,12 @@ import {
|
||||
SvgStar,
|
||||
SvgUser,
|
||||
} from "@opal/icons";
|
||||
import * as ExpandableCard from "@/layouts/expandable-card-layouts";
|
||||
import * as ActionsLayouts from "@/layouts/actions-layouts";
|
||||
import useMcpServersForAgentEditor from "@/hooks/useMcpServersForAgentEditor";
|
||||
import { getActionIcon } from "@/lib/tools/mcpUtils";
|
||||
import { MCPServer, ToolSnapshot } from "@/lib/tools/interfaces";
|
||||
import EmptyMessage from "@/refresh-components/EmptyMessage";
|
||||
import { EmptyMessageCard } from "@opal/components";
|
||||
import Switch from "@/refresh-components/inputs/Switch";
|
||||
import { Button } from "@opal/components";
|
||||
import { SEARCH_PARAM_NAMES } from "@/app/app/services/searchParams";
|
||||
@@ -53,55 +50,46 @@ interface ViewerMCPServerCardProps {
|
||||
}
|
||||
|
||||
function ViewerMCPServerCard({ server, tools }: ViewerMCPServerCardProps) {
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const [folded, setFolded] = useState(false);
|
||||
const serverIcon = getActionIcon(server.server_url, server.name);
|
||||
|
||||
return (
|
||||
<Card
|
||||
expandable
|
||||
expanded={expanded}
|
||||
border="solid"
|
||||
rounding="lg"
|
||||
padding="sm"
|
||||
expandedContent={
|
||||
tools.length > 0 ? (
|
||||
<div className="flex flex-col gap-2 p-2">
|
||||
{tools.map((tool) => (
|
||||
<Section key={tool.id} padding={0.25}>
|
||||
<Content
|
||||
title={tool.display_name}
|
||||
description={tool.description}
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
/>
|
||||
</Section>
|
||||
))}
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<CardLayout.Header
|
||||
headerChildren={
|
||||
<ExpandableCard.Root isFolded={folded} onFoldedChange={setFolded}>
|
||||
<ExpandableCard.Header>
|
||||
<div className="p-2">
|
||||
<ContentAction
|
||||
icon={serverIcon}
|
||||
title={server.name}
|
||||
description={server.description}
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
paddingVariant="fit"
|
||||
rightChildren={
|
||||
<Button
|
||||
prominence="internal"
|
||||
rightIcon={folded ? SvgExpand : SvgFold}
|
||||
onClick={() => setFolded((prev) => !prev)}
|
||||
>
|
||||
{folded ? "Expand" : "Fold"}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
}
|
||||
topRightChildren={
|
||||
<Button
|
||||
prominence="internal"
|
||||
rightIcon={expanded ? SvgFold : SvgExpand}
|
||||
onClick={() => setExpanded((prev) => !prev)}
|
||||
>
|
||||
{expanded ? "Fold" : "Expand"}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
</ExpandableCard.Header>
|
||||
{tools.length > 0 && (
|
||||
<ActionsLayouts.Content>
|
||||
{tools.map((tool) => (
|
||||
<Section key={tool.id} padding={0.25}>
|
||||
<Content
|
||||
title={tool.display_name}
|
||||
description={tool.description}
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
/>
|
||||
</Section>
|
||||
))}
|
||||
</ActionsLayouts.Content>
|
||||
)}
|
||||
</ExpandableCard.Root>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -111,9 +99,9 @@ function ViewerMCPServerCard({ server, tools }: ViewerMCPServerCardProps) {
|
||||
*/
|
||||
function ViewerOpenApiToolCard({ tool }: { tool: ToolSnapshot }) {
|
||||
return (
|
||||
<Card border="solid" rounding="lg" padding="sm">
|
||||
<CardLayout.Header
|
||||
headerChildren={
|
||||
<ExpandableCard.Root>
|
||||
<ExpandableCard.Header>
|
||||
<div className="p-2">
|
||||
<Content
|
||||
icon={SvgActions}
|
||||
title={tool.display_name}
|
||||
@@ -121,9 +109,9 @@ function ViewerOpenApiToolCard({ tool }: { tool: ToolSnapshot }) {
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
</ExpandableCard.Header>
|
||||
</ExpandableCard.Root>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -319,7 +307,7 @@ export default function AgentViewerModal({ agent }: AgentViewerModalProps) {
|
||||
})}
|
||||
</Section>
|
||||
) : (
|
||||
<EmptyMessage title="No Knowledge" />
|
||||
<EmptyMessageCard sizePreset="main-ui" title="No Knowledge" />
|
||||
)}
|
||||
</Section>
|
||||
|
||||
@@ -341,7 +329,7 @@ export default function AgentViewerModal({ agent }: AgentViewerModalProps) {
|
||||
))}
|
||||
</Section>
|
||||
) : (
|
||||
<EmptyMessage title="No Actions" />
|
||||
<EmptyMessageCard sizePreset="main-ui" title="No Actions" />
|
||||
)}
|
||||
</SimpleCollapsible.Content>
|
||||
</SimpleCollapsible>
|
||||
|
||||
@@ -342,19 +342,22 @@ test.describe("Default Agent MCP Integration", () => {
|
||||
// Scroll to the Actions & Tools section (open by default)
|
||||
await scrollToBottom(page);
|
||||
|
||||
// Find the MCP server card by name text (expandable card)
|
||||
const serverCard = page
|
||||
.locator(".opal-card-expandable")
|
||||
.filter({ hasText: serverName })
|
||||
.first();
|
||||
await expect(serverCard).toBeVisible({ timeout: 10000 });
|
||||
// Find the MCP server card by name text
|
||||
// The server name appears inside a label within the ActionsLayouts.Header
|
||||
const serverLabel = page
|
||||
.locator("label")
|
||||
.filter({ has: page.getByText(serverName, { exact: true }) });
|
||||
await expect(serverLabel.first()).toBeVisible({ timeout: 10000 });
|
||||
console.log(`[test] MCP server card found for server: ${serverName}`);
|
||||
|
||||
// Scroll server card into view
|
||||
await serverCard.scrollIntoViewIfNeeded();
|
||||
await serverLabel.first().scrollIntoViewIfNeeded();
|
||||
|
||||
// The server-level Switch in the header toggles ALL tools
|
||||
const serverSwitch = serverCard.getByRole("switch").first();
|
||||
const serverSwitch = serverLabel
|
||||
.first()
|
||||
.locator('button[role="switch"]')
|
||||
.first();
|
||||
await expect(serverSwitch).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Enable all tools by toggling the server switch ON
|
||||
@@ -640,13 +643,12 @@ test.describe("Default Agent MCP Integration", () => {
|
||||
// Scroll to Actions & Tools section
|
||||
await scrollToBottom(page);
|
||||
|
||||
// Find the MCP server card by name (expandable card)
|
||||
const serverCard = page
|
||||
.locator(".opal-card-expandable")
|
||||
.filter({ hasText: serverName })
|
||||
.first();
|
||||
await expect(serverCard).toBeVisible({ timeout: 10000 });
|
||||
await serverCard.scrollIntoViewIfNeeded();
|
||||
// Find the MCP server card by name
|
||||
const serverLabel = page
|
||||
.locator("label")
|
||||
.filter({ has: page.getByText(serverName, { exact: true }) });
|
||||
await expect(serverLabel.first()).toBeVisible({ timeout: 10000 });
|
||||
await serverLabel.first().scrollIntoViewIfNeeded();
|
||||
|
||||
// Click "Expand" to reveal individual tools
|
||||
const expandButton = page.getByRole("button", { name: "Expand" }).first();
|
||||
@@ -658,11 +660,14 @@ test.describe("Default Agent MCP Integration", () => {
|
||||
}
|
||||
|
||||
// Find a specific tool by name inside the expanded card content
|
||||
const toolCard = page
|
||||
.locator(".opal-card")
|
||||
.filter({ hasText: "tool_0" })
|
||||
// Individual tools are rendered as ActionsLayouts.Tool with their own Card > Label
|
||||
const toolLabel = page
|
||||
.locator("label")
|
||||
.filter({ has: page.getByText("tool_0", { exact: true }) });
|
||||
const firstToolSwitch = toolLabel
|
||||
.first()
|
||||
.locator('button[role="switch"]')
|
||||
.first();
|
||||
const firstToolSwitch = toolCard.getByRole("switch").first();
|
||||
|
||||
await expect(firstToolSwitch).toBeVisible({ timeout: 5000 });
|
||||
await firstToolSwitch.scrollIntoViewIfNeeded();
|
||||
@@ -683,13 +688,12 @@ test.describe("Default Agent MCP Integration", () => {
|
||||
await page.waitForURL("**/admin/configuration/chat-preferences**");
|
||||
await scrollToBottom(page);
|
||||
|
||||
// Re-find the server card (expandable card)
|
||||
const serverCardAfter = page
|
||||
.locator(".opal-card-expandable")
|
||||
.filter({ hasText: serverName })
|
||||
.first();
|
||||
await expect(serverCardAfter).toBeVisible({ timeout: 10000 });
|
||||
await serverCardAfter.scrollIntoViewIfNeeded();
|
||||
// Re-find the server card
|
||||
const serverLabelAfter = page
|
||||
.locator("label")
|
||||
.filter({ has: page.getByText(serverName, { exact: true }) });
|
||||
await expect(serverLabelAfter.first()).toBeVisible({ timeout: 10000 });
|
||||
await serverLabelAfter.first().scrollIntoViewIfNeeded();
|
||||
|
||||
// Re-expand the card
|
||||
const expandButtonAfter = page
|
||||
@@ -704,11 +708,13 @@ test.describe("Default Agent MCP Integration", () => {
|
||||
}
|
||||
|
||||
// Verify the tool state persisted
|
||||
const toolCardAfter = page
|
||||
.locator(".opal-card")
|
||||
.filter({ hasText: "tool_0" })
|
||||
const toolLabelAfter = page
|
||||
.locator("label")
|
||||
.filter({ has: page.getByText("tool_0", { exact: true }) });
|
||||
const firstToolSwitchAfter = toolLabelAfter
|
||||
.first()
|
||||
.locator('button[role="switch"]')
|
||||
.first();
|
||||
const firstToolSwitchAfter = toolCardAfter.getByRole("switch").first();
|
||||
await expect(firstToolSwitchAfter).toBeVisible({ timeout: 5000 });
|
||||
const finalChecked =
|
||||
await firstToolSwitchAfter.getAttribute("aria-checked");
|
||||
|
||||
@@ -42,6 +42,7 @@ for (const theme of THEMES) {
|
||||
page
|
||||
.locator(".opal-content-md-header")
|
||||
.filter({ hasText: expectedHeader })
|
||||
.first()
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
} else {
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
Reference in New Issue
Block a user