mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-04-06 15:32:43 +00:00
Compare commits
2 Commits
feat/resol
...
jr_dr_test
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a462678ddd | ||
|
|
c50d2739b8 |
@@ -42,9 +42,6 @@ from onyx.connectors.google_drive.file_retrieval import (
|
||||
get_all_files_in_my_drive_and_shared,
|
||||
)
|
||||
from onyx.connectors.google_drive.file_retrieval import get_external_access_for_folder
|
||||
from onyx.connectors.google_drive.file_retrieval import (
|
||||
get_files_by_web_view_links_batch,
|
||||
)
|
||||
from onyx.connectors.google_drive.file_retrieval import get_files_in_shared_drive
|
||||
from onyx.connectors.google_drive.file_retrieval import get_folder_metadata
|
||||
from onyx.connectors.google_drive.file_retrieval import get_root_folder_id
|
||||
@@ -73,13 +70,11 @@ from onyx.connectors.interfaces import CheckpointedConnectorWithPermSync
|
||||
from onyx.connectors.interfaces import CheckpointOutput
|
||||
from onyx.connectors.interfaces import GenerateSlimDocumentOutput
|
||||
from onyx.connectors.interfaces import NormalizationResult
|
||||
from onyx.connectors.interfaces import Resolver
|
||||
from onyx.connectors.interfaces import SecondsSinceUnixEpoch
|
||||
from onyx.connectors.interfaces import SlimConnectorWithPermSync
|
||||
from onyx.connectors.models import ConnectorFailure
|
||||
from onyx.connectors.models import ConnectorMissingCredentialError
|
||||
from onyx.connectors.models import Document
|
||||
from onyx.connectors.models import DocumentFailure
|
||||
from onyx.connectors.models import EntityFailure
|
||||
from onyx.connectors.models import HierarchyNode
|
||||
from onyx.connectors.models import SlimDocument
|
||||
@@ -207,9 +202,7 @@ class DriveIdStatus(Enum):
|
||||
|
||||
|
||||
class GoogleDriveConnector(
|
||||
SlimConnectorWithPermSync,
|
||||
CheckpointedConnectorWithPermSync[GoogleDriveCheckpoint],
|
||||
Resolver,
|
||||
SlimConnectorWithPermSync, CheckpointedConnectorWithPermSync[GoogleDriveCheckpoint]
|
||||
):
|
||||
def __init__(
|
||||
self,
|
||||
@@ -1672,82 +1665,6 @@ class GoogleDriveConnector(
|
||||
start, end, checkpoint, include_permissions=True
|
||||
)
|
||||
|
||||
@override
|
||||
def resolve_errors(
|
||||
self,
|
||||
errors: list[ConnectorFailure],
|
||||
include_permissions: bool = False,
|
||||
) -> Generator[Document | ConnectorFailure | HierarchyNode, None, None]:
|
||||
if self._creds is None or self._primary_admin_email is None:
|
||||
raise RuntimeError(
|
||||
"Credentials missing, should not call this method before calling load_credentials"
|
||||
)
|
||||
|
||||
logger.info(f"Resolving {len(errors)} errors")
|
||||
doc_ids = [
|
||||
failure.failed_document.document_id
|
||||
for failure in errors
|
||||
if failure.failed_document
|
||||
]
|
||||
service = get_drive_service(self.creds, self.primary_admin_email)
|
||||
field_type = (
|
||||
DriveFileFieldType.WITH_PERMISSIONS
|
||||
if include_permissions or self.exclude_domain_link_only
|
||||
else DriveFileFieldType.STANDARD
|
||||
)
|
||||
batch_result = get_files_by_web_view_links_batch(service, doc_ids, field_type)
|
||||
|
||||
for doc_id, error in batch_result.errors.items():
|
||||
yield ConnectorFailure(
|
||||
failed_document=DocumentFailure(
|
||||
document_id=doc_id,
|
||||
document_link=doc_id,
|
||||
),
|
||||
failure_message=f"Failed to retrieve file during error resolution: {error}",
|
||||
exception=error,
|
||||
)
|
||||
|
||||
permission_sync_context = (
|
||||
PermissionSyncContext(
|
||||
primary_admin_email=self.primary_admin_email,
|
||||
google_domain=self.google_domain,
|
||||
)
|
||||
if include_permissions
|
||||
else None
|
||||
)
|
||||
|
||||
retrieved_files = [
|
||||
RetrievedDriveFile(
|
||||
drive_file=file,
|
||||
user_email=self.primary_admin_email,
|
||||
completion_stage=DriveRetrievalStage.DONE,
|
||||
)
|
||||
for file in batch_result.files.values()
|
||||
]
|
||||
|
||||
yield from self._get_new_ancestors_for_files(
|
||||
files=retrieved_files,
|
||||
seen_hierarchy_node_raw_ids=ThreadSafeSet(),
|
||||
fully_walked_hierarchy_node_raw_ids=ThreadSafeSet(),
|
||||
permission_sync_context=permission_sync_context,
|
||||
add_prefix=True,
|
||||
)
|
||||
|
||||
func_with_args = [
|
||||
(
|
||||
self._convert_retrieved_file_to_document,
|
||||
(rf, permission_sync_context),
|
||||
)
|
||||
for rf in retrieved_files
|
||||
]
|
||||
results = cast(
|
||||
list[Document | ConnectorFailure | None],
|
||||
run_functions_tuples_in_parallel(func_with_args, max_workers=8),
|
||||
)
|
||||
for result in results:
|
||||
if result is not None:
|
||||
yield result
|
||||
|
||||
def _extract_slim_docs_from_google_drive(
|
||||
self,
|
||||
checkpoint: GoogleDriveCheckpoint,
|
||||
|
||||
@@ -9,7 +9,6 @@ from urllib.parse import urlparse
|
||||
|
||||
from googleapiclient.discovery import Resource # type: ignore
|
||||
from googleapiclient.errors import HttpError # type: ignore
|
||||
from googleapiclient.http import BatchHttpRequest # type: ignore
|
||||
|
||||
from onyx.access.models import ExternalAccess
|
||||
from onyx.connectors.google_drive.constants import DRIVE_FOLDER_TYPE
|
||||
@@ -61,8 +60,6 @@ SLIM_FILE_FIELDS = (
|
||||
)
|
||||
FOLDER_FIELDS = "nextPageToken, files(id, name, permissions, modifiedTime, webViewLink, shortcutDetails)"
|
||||
|
||||
MAX_BATCH_SIZE = 100
|
||||
|
||||
HIERARCHY_FIELDS = "id, name, parents, webViewLink, mimeType, driveId"
|
||||
|
||||
HIERARCHY_FIELDS_WITH_PERMISSIONS = (
|
||||
@@ -219,7 +216,7 @@ def get_external_access_for_folder(
|
||||
|
||||
|
||||
def _get_fields_for_file_type(field_type: DriveFileFieldType) -> str:
|
||||
"""Get the appropriate fields string for files().list() based on the field type enum."""
|
||||
"""Get the appropriate fields string based on the field type enum"""
|
||||
if field_type == DriveFileFieldType.SLIM:
|
||||
return SLIM_FILE_FIELDS
|
||||
elif field_type == DriveFileFieldType.WITH_PERMISSIONS:
|
||||
@@ -228,25 +225,6 @@ def _get_fields_for_file_type(field_type: DriveFileFieldType) -> str:
|
||||
return FILE_FIELDS
|
||||
|
||||
|
||||
def _extract_single_file_fields(list_fields: str) -> str:
|
||||
"""Convert a files().list() fields string to one suitable for files().get().
|
||||
|
||||
List fields look like "nextPageToken, files(field1, field2, ...)"
|
||||
Single-file fields should be just "field1, field2, ..."
|
||||
"""
|
||||
start = list_fields.find("files(")
|
||||
if start == -1:
|
||||
return list_fields
|
||||
inner_start = start + len("files(")
|
||||
inner_end = list_fields.rfind(")")
|
||||
return list_fields[inner_start:inner_end]
|
||||
|
||||
|
||||
def _get_single_file_fields(field_type: DriveFileFieldType) -> str:
|
||||
"""Get the appropriate fields string for files().get() based on the field type enum."""
|
||||
return _extract_single_file_fields(_get_fields_for_file_type(field_type))
|
||||
|
||||
|
||||
def _get_files_in_parent(
|
||||
service: Resource,
|
||||
parent_id: str,
|
||||
@@ -558,74 +536,3 @@ def get_file_by_web_view_link(
|
||||
)
|
||||
.execute()
|
||||
)
|
||||
|
||||
|
||||
class BatchRetrievalResult:
|
||||
"""Result of a batch file retrieval, separating successes from errors."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.files: dict[str, GoogleDriveFileType] = {}
|
||||
self.errors: dict[str, Exception] = {}
|
||||
|
||||
|
||||
def get_files_by_web_view_links_batch(
|
||||
service: GoogleDriveService,
|
||||
web_view_links: list[str],
|
||||
field_type: DriveFileFieldType,
|
||||
) -> BatchRetrievalResult:
|
||||
"""Retrieve multiple Google Drive files by webViewLink using the batch API.
|
||||
|
||||
Returns a BatchRetrievalResult containing successful file retrievals
|
||||
and errors for any files that could not be fetched.
|
||||
Automatically splits into chunks of MAX_BATCH_SIZE.
|
||||
"""
|
||||
fields = _get_single_file_fields(field_type)
|
||||
if len(web_view_links) <= MAX_BATCH_SIZE:
|
||||
return _get_files_by_web_view_links_batch(service, web_view_links, fields)
|
||||
|
||||
combined = BatchRetrievalResult()
|
||||
for i in range(0, len(web_view_links), MAX_BATCH_SIZE):
|
||||
chunk = web_view_links[i : i + MAX_BATCH_SIZE]
|
||||
chunk_result = _get_files_by_web_view_links_batch(service, chunk, fields)
|
||||
combined.files.update(chunk_result.files)
|
||||
combined.errors.update(chunk_result.errors)
|
||||
return combined
|
||||
|
||||
|
||||
def _get_files_by_web_view_links_batch(
|
||||
service: GoogleDriveService,
|
||||
web_view_links: list[str],
|
||||
fields: str,
|
||||
) -> BatchRetrievalResult:
|
||||
"""Single-batch implementation."""
|
||||
|
||||
result = BatchRetrievalResult()
|
||||
|
||||
def callback(
|
||||
request_id: str,
|
||||
response: GoogleDriveFileType,
|
||||
exception: Exception | None,
|
||||
) -> None:
|
||||
if exception:
|
||||
logger.warning(f"Error retrieving file {request_id}: {exception}")
|
||||
result.errors[request_id] = exception
|
||||
else:
|
||||
result.files[request_id] = response
|
||||
|
||||
batch = cast(BatchHttpRequest, service.new_batch_http_request(callback=callback))
|
||||
|
||||
for web_view_link in web_view_links:
|
||||
try:
|
||||
file_id = _extract_file_id_from_web_view_link(web_view_link)
|
||||
request = service.files().get(
|
||||
fileId=file_id,
|
||||
supportsAllDrives=True,
|
||||
fields=fields,
|
||||
)
|
||||
batch.add(request, request_id=web_view_link)
|
||||
except ValueError as e:
|
||||
logger.warning(f"Failed to extract file ID from {web_view_link}: {e}")
|
||||
result.errors[web_view_link] = e
|
||||
|
||||
batch.execute()
|
||||
return result
|
||||
|
||||
@@ -298,22 +298,6 @@ class CheckpointedConnectorWithPermSync(CheckpointedConnector[CT]):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class Resolver(BaseConnector):
|
||||
@abc.abstractmethod
|
||||
def resolve_errors(
|
||||
self,
|
||||
errors: list[ConnectorFailure],
|
||||
include_permissions: bool = False,
|
||||
) -> Generator[Document | ConnectorFailure | HierarchyNode, None, None]:
|
||||
"""Attempts to yield back ALL the documents described by the errors, no checkpointing.
|
||||
|
||||
Caller's responsibility is to delete the old ConnectorFailures and replace with the new ones.
|
||||
If include_permissions is True, the documents will have permissions synced.
|
||||
May also yield HierarchyNode objects for ancestor folders of resolved documents.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class HierarchyConnector(BaseConnector):
|
||||
@abc.abstractmethod
|
||||
def load_hierarchy(
|
||||
|
||||
@@ -1,28 +1,110 @@
|
||||
from pydantic import BaseModel
|
||||
from pydantic import Field
|
||||
|
||||
from onyx.db.enums import HookFailStrategy
|
||||
from onyx.db.enums import HookPoint
|
||||
from onyx.hooks.points.base import HookPointSpec
|
||||
|
||||
|
||||
# TODO(@Bo-Onyx): define payload and response fields
|
||||
class DocumentIngestionSection(BaseModel):
|
||||
"""Represents a single section of a document — either text or image, not both.
|
||||
|
||||
Text section: set `text`, leave `image_file_id` null.
|
||||
Image section: set `image_file_id`, leave `text` null.
|
||||
"""
|
||||
|
||||
text: str | None = Field(
|
||||
default=None,
|
||||
description="Text content of this section. Set for text sections, null for image sections.",
|
||||
)
|
||||
link: str | None = Field(
|
||||
default=None,
|
||||
description="Optional URL associated with this section. Preserve the original link from the payload if you want it retained.",
|
||||
)
|
||||
image_file_id: str | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Opaque identifier for an image stored in the file store. "
|
||||
"The image content is not included — this field signals that the section is an image. "
|
||||
"Hooks can use its presence to reorder or drop image sections, but cannot read or modify the image itself."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class DocumentIngestionOwner(BaseModel):
|
||||
display_name: str | None = Field(
|
||||
default=None,
|
||||
description="Human-readable name of the owner.",
|
||||
)
|
||||
email: str | None = Field(
|
||||
default=None,
|
||||
description="Email address of the owner.",
|
||||
)
|
||||
|
||||
|
||||
class DocumentIngestionPayload(BaseModel):
|
||||
pass
|
||||
document_id: str = Field(
|
||||
description="Unique identifier for the document. Read-only — changes are ignored."
|
||||
)
|
||||
title: str | None = Field(description="Title of the document.")
|
||||
semantic_identifier: str = Field(
|
||||
description="Human-readable identifier used for display (e.g. file name, page title)."
|
||||
)
|
||||
source: str = Field(
|
||||
description=(
|
||||
"Connector source type (e.g. confluence, slack, google_drive). "
|
||||
"Read-only — changes are ignored. "
|
||||
"Full list of values: https://github.com/onyx-dot-app/onyx/blob/main/backend/onyx/configs/constants.py#L195"
|
||||
)
|
||||
)
|
||||
sections: list[DocumentIngestionSection] = Field(
|
||||
description="Sections of the document. Includes both text sections (text set, image_file_id null) and image sections (image_file_id set, text null)."
|
||||
)
|
||||
metadata: dict[str, list[str]] = Field(
|
||||
description="Key-value metadata attached to the document. Values are always a list of strings."
|
||||
)
|
||||
doc_updated_at: str | None = Field(
|
||||
description="ISO 8601 UTC timestamp of the last update at the source, or null if unknown. Example: '2024-03-15T10:30:00+00:00'."
|
||||
)
|
||||
primary_owners: list[DocumentIngestionOwner] | None = Field(
|
||||
description="Primary owners of the document, or null if not available."
|
||||
)
|
||||
secondary_owners: list[DocumentIngestionOwner] | None = Field(
|
||||
description="Secondary owners of the document, or null if not available."
|
||||
)
|
||||
|
||||
|
||||
class DocumentIngestionResponse(BaseModel):
|
||||
pass
|
||||
# Intentionally permissive — customer endpoints may return extra fields.
|
||||
sections: list[DocumentIngestionSection] | None = Field(
|
||||
description="The sections to index, in the desired order. Reorder, drop, or modify sections freely. Null or empty list drops the document."
|
||||
)
|
||||
rejection_reason: str | None = Field(
|
||||
default=None,
|
||||
description="Logged when sections is null or empty. Falls back to a generic message if omitted.",
|
||||
)
|
||||
|
||||
|
||||
class DocumentIngestionSpec(HookPointSpec):
|
||||
"""Hook point that runs during document ingestion.
|
||||
"""Hook point that runs on every document before it enters the indexing pipeline.
|
||||
|
||||
# TODO(@Bo-Onyx): define call site, input/output schema, and timeout budget.
|
||||
Call site: immediately after Onyx's internal validation and before the
|
||||
indexing pipeline begins — no partial writes have occurred yet.
|
||||
|
||||
If a Document Ingestion hook is configured, it takes precedence —
|
||||
Document Ingestion Light will not run. Configure only one per deployment.
|
||||
|
||||
Supported use cases:
|
||||
- Document filtering: drop documents based on content or metadata
|
||||
- Content rewriting: redact PII or normalize text before indexing
|
||||
"""
|
||||
|
||||
hook_point = HookPoint.DOCUMENT_INGESTION
|
||||
display_name = "Document Ingestion"
|
||||
description = "Runs during document ingestion. Allows filtering or transforming documents before indexing."
|
||||
description = (
|
||||
"Runs on every document before it enters the indexing pipeline. "
|
||||
"Allows filtering, rewriting, or dropping documents."
|
||||
)
|
||||
default_timeout_seconds = 30.0
|
||||
fail_hard_description = "The document will not be indexed."
|
||||
default_fail_strategy = HookFailStrategy.HARD
|
||||
|
||||
@@ -33,6 +33,7 @@ from onyx.connectors.models import TextSection
|
||||
from onyx.db.document import get_documents_by_ids
|
||||
from onyx.db.document import upsert_document_by_connector_credential_pair
|
||||
from onyx.db.document import upsert_documents
|
||||
from onyx.db.enums import HookPoint
|
||||
from onyx.db.hierarchy import link_hierarchy_nodes_to_documents
|
||||
from onyx.db.models import Document as DBDocument
|
||||
from onyx.db.models import IndexModelStatus
|
||||
@@ -47,6 +48,13 @@ from onyx.document_index.interfaces import DocumentMetadata
|
||||
from onyx.document_index.interfaces import IndexBatchParams
|
||||
from onyx.file_processing.image_summarization import summarize_image_with_error_handling
|
||||
from onyx.file_store.file_store import get_default_file_store
|
||||
from onyx.hooks.executor import execute_hook
|
||||
from onyx.hooks.executor import HookSkipped
|
||||
from onyx.hooks.executor import HookSoftFailed
|
||||
from onyx.hooks.points.document_ingestion import DocumentIngestionOwner
|
||||
from onyx.hooks.points.document_ingestion import DocumentIngestionPayload
|
||||
from onyx.hooks.points.document_ingestion import DocumentIngestionResponse
|
||||
from onyx.hooks.points.document_ingestion import DocumentIngestionSection
|
||||
from onyx.indexing.chunk_batch_store import ChunkBatchStore
|
||||
from onyx.indexing.chunker import Chunker
|
||||
from onyx.indexing.embedder import embed_chunks_with_failure_handling
|
||||
@@ -297,6 +305,7 @@ def index_doc_batch_with_handler(
|
||||
document_batch: list[Document],
|
||||
request_id: str | None,
|
||||
tenant_id: str,
|
||||
db_session: Session,
|
||||
adapter: IndexingBatchAdapter,
|
||||
ignore_time_skip: bool = False,
|
||||
enable_contextual_rag: bool = False,
|
||||
@@ -310,6 +319,7 @@ def index_doc_batch_with_handler(
|
||||
document_batch=document_batch,
|
||||
request_id=request_id,
|
||||
tenant_id=tenant_id,
|
||||
db_session=db_session,
|
||||
adapter=adapter,
|
||||
ignore_time_skip=ignore_time_skip,
|
||||
enable_contextual_rag=enable_contextual_rag,
|
||||
@@ -785,6 +795,132 @@ def _verify_indexing_completeness(
|
||||
)
|
||||
|
||||
|
||||
def _apply_document_ingestion_hook(
|
||||
documents: list[Document],
|
||||
db_session: Session,
|
||||
) -> list[Document]:
|
||||
"""Apply the Document Ingestion hook to each document in the batch.
|
||||
|
||||
- HookSkipped / HookSoftFailed → document passes through unchanged.
|
||||
- Response with sections=None → document is dropped (logged).
|
||||
- Response with sections → document sections are replaced with the hook's output.
|
||||
"""
|
||||
|
||||
def _build_payload(doc: Document) -> DocumentIngestionPayload:
|
||||
return DocumentIngestionPayload(
|
||||
document_id=doc.id or "",
|
||||
title=doc.title,
|
||||
semantic_identifier=doc.semantic_identifier,
|
||||
source=doc.source.value if doc.source is not None else "",
|
||||
sections=[
|
||||
DocumentIngestionSection(
|
||||
text=s.text if isinstance(s, TextSection) else None,
|
||||
link=s.link,
|
||||
image_file_id=(
|
||||
s.image_file_id if isinstance(s, ImageSection) else None
|
||||
),
|
||||
)
|
||||
for s in doc.sections
|
||||
],
|
||||
metadata={
|
||||
k: v if isinstance(v, list) else [v] for k, v in doc.metadata.items()
|
||||
},
|
||||
doc_updated_at=(
|
||||
doc.doc_updated_at.isoformat() if doc.doc_updated_at else None
|
||||
),
|
||||
primary_owners=(
|
||||
[
|
||||
DocumentIngestionOwner(
|
||||
display_name=o.get_semantic_name() or None,
|
||||
email=o.email,
|
||||
)
|
||||
for o in doc.primary_owners
|
||||
]
|
||||
if doc.primary_owners
|
||||
else None
|
||||
),
|
||||
secondary_owners=(
|
||||
[
|
||||
DocumentIngestionOwner(
|
||||
display_name=o.get_semantic_name() or None,
|
||||
email=o.email,
|
||||
)
|
||||
for o in doc.secondary_owners
|
||||
]
|
||||
if doc.secondary_owners
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
def _apply_result(
|
||||
doc: Document,
|
||||
hook_result: DocumentIngestionResponse | HookSkipped | HookSoftFailed,
|
||||
) -> Document | None:
|
||||
"""Return the modified doc, original doc (skip/soft-fail), or None (drop)."""
|
||||
if isinstance(hook_result, (HookSkipped, HookSoftFailed)):
|
||||
return doc
|
||||
if not hook_result.sections:
|
||||
reason = hook_result.rejection_reason or "Document rejected by hook"
|
||||
logger.info(
|
||||
f"Document ingestion hook dropped document doc_id={doc.id!r}: {reason}"
|
||||
)
|
||||
return None
|
||||
new_sections: list[TextSection | ImageSection] = []
|
||||
for s in hook_result.sections:
|
||||
if s.image_file_id is not None:
|
||||
new_sections.append(
|
||||
ImageSection(image_file_id=s.image_file_id, link=s.link)
|
||||
)
|
||||
elif s.text is not None:
|
||||
new_sections.append(TextSection(text=s.text, link=s.link))
|
||||
else:
|
||||
logger.warning(
|
||||
f"Document ingestion hook returned a section with neither text nor "
|
||||
f"image_file_id for doc_id={doc.id!r} — skipping section."
|
||||
)
|
||||
if not new_sections:
|
||||
logger.info(
|
||||
f"Document ingestion hook produced no valid sections for doc_id={doc.id!r} — dropping document."
|
||||
)
|
||||
return None
|
||||
return doc.model_copy(update={"sections": new_sections})
|
||||
|
||||
if not documents:
|
||||
return documents
|
||||
|
||||
# Run the hook for the first document. If it returns HookSkipped the hook
|
||||
# is not configured — skip the remaining N-1 DB lookups.
|
||||
first_doc = documents[0]
|
||||
first_payload = _build_payload(first_doc).model_dump()
|
||||
first_hook_result = execute_hook(
|
||||
db_session=db_session,
|
||||
hook_point=HookPoint.DOCUMENT_INGESTION,
|
||||
payload=first_payload,
|
||||
response_type=DocumentIngestionResponse,
|
||||
)
|
||||
if isinstance(first_hook_result, HookSkipped):
|
||||
return documents
|
||||
|
||||
result: list[Document] = []
|
||||
first_applied = _apply_result(first_doc, first_hook_result)
|
||||
if first_applied is not None:
|
||||
result.append(first_applied)
|
||||
|
||||
for doc in documents[1:]:
|
||||
payload = _build_payload(doc).model_dump()
|
||||
hook_result = execute_hook(
|
||||
db_session=db_session,
|
||||
hook_point=HookPoint.DOCUMENT_INGESTION,
|
||||
payload=payload,
|
||||
response_type=DocumentIngestionResponse,
|
||||
)
|
||||
applied = _apply_result(doc, hook_result)
|
||||
if applied is not None:
|
||||
result.append(applied)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@log_function_time(debug_only=True)
|
||||
def index_doc_batch(
|
||||
*,
|
||||
@@ -794,6 +930,7 @@ def index_doc_batch(
|
||||
document_indices: list[DocumentIndex],
|
||||
request_id: str | None,
|
||||
tenant_id: str,
|
||||
db_session: Session,
|
||||
adapter: IndexingBatchAdapter,
|
||||
enable_contextual_rag: bool = False,
|
||||
llm: LLM | None = None,
|
||||
@@ -818,6 +955,7 @@ def index_doc_batch(
|
||||
)
|
||||
|
||||
filtered_documents = filter_fnc(document_batch)
|
||||
filtered_documents = _apply_document_ingestion_hook(filtered_documents, db_session)
|
||||
context = adapter.prepare(filtered_documents, ignore_time_skip)
|
||||
if not context:
|
||||
return IndexingPipelineResult.empty(len(filtered_documents))
|
||||
@@ -1005,6 +1143,7 @@ def run_indexing_pipeline(
|
||||
document_batch=document_batch,
|
||||
request_id=request_id,
|
||||
tenant_id=tenant_id,
|
||||
db_session=db_session,
|
||||
adapter=adapter,
|
||||
enable_contextual_rag=enable_contextual_rag,
|
||||
llm=llm,
|
||||
|
||||
@@ -1,239 +0,0 @@
|
||||
"""Tests for GoogleDriveConnector.resolve_errors against real Google Drive."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from collections.abc import Callable
|
||||
from unittest.mock import patch
|
||||
|
||||
from onyx.connectors.google_drive.connector import GoogleDriveConnector
|
||||
from onyx.connectors.models import ConnectorFailure
|
||||
from onyx.connectors.models import Document
|
||||
from onyx.connectors.models import DocumentFailure
|
||||
from onyx.connectors.models import HierarchyNode
|
||||
from tests.daily.connectors.google_drive.consts_and_utils import ADMIN_EMAIL
|
||||
from tests.daily.connectors.google_drive.consts_and_utils import (
|
||||
ALL_EXPECTED_HIERARCHY_NODES,
|
||||
)
|
||||
from tests.daily.connectors.google_drive.consts_and_utils import FOLDER_1_ID
|
||||
from tests.daily.connectors.google_drive.consts_and_utils import SHARED_DRIVE_1_ID
|
||||
|
||||
_DRIVE_ID_MAPPING_PATH = os.path.join(
|
||||
os.path.dirname(__file__), "drive_id_mapping.json"
|
||||
)
|
||||
|
||||
|
||||
def _load_web_view_links(file_ids: list[int]) -> list[str]:
|
||||
with open(_DRIVE_ID_MAPPING_PATH) as f:
|
||||
mapping: dict[str, str] = json.load(f)
|
||||
return [mapping[str(fid)] for fid in file_ids]
|
||||
|
||||
|
||||
def _build_failures(web_view_links: list[str]) -> list[ConnectorFailure]:
|
||||
return [
|
||||
ConnectorFailure(
|
||||
failed_document=DocumentFailure(
|
||||
document_id=link,
|
||||
document_link=link,
|
||||
),
|
||||
failure_message=f"Synthetic failure for {link}",
|
||||
)
|
||||
for link in web_view_links
|
||||
]
|
||||
|
||||
|
||||
@patch("onyx.file_processing.extract_file_text.get_unstructured_api_key")
|
||||
def test_resolve_single_file(
|
||||
mock_api_key: None, # noqa: ARG001
|
||||
google_drive_service_acct_connector_factory: Callable[..., GoogleDriveConnector],
|
||||
) -> None:
|
||||
"""Resolve a single known file and verify we get back exactly one Document."""
|
||||
connector = google_drive_service_acct_connector_factory(
|
||||
primary_admin_email=ADMIN_EMAIL,
|
||||
include_shared_drives=True,
|
||||
shared_drive_urls=None,
|
||||
include_my_drives=True,
|
||||
my_drive_emails=None,
|
||||
shared_folder_urls=None,
|
||||
include_files_shared_with_me=False,
|
||||
)
|
||||
|
||||
web_view_links = _load_web_view_links([0])
|
||||
failures = _build_failures(web_view_links)
|
||||
|
||||
results = list(connector.resolve_errors(failures))
|
||||
|
||||
docs = [r for r in results if isinstance(r, Document)]
|
||||
new_failures = [r for r in results if isinstance(r, ConnectorFailure)]
|
||||
hierarchy_nodes = [r for r in results if isinstance(r, HierarchyNode)]
|
||||
|
||||
assert len(docs) == 1
|
||||
assert len(new_failures) == 0
|
||||
assert docs[0].semantic_identifier == "file_0.txt"
|
||||
|
||||
# Should yield at least one hierarchy node (the file's parent folder chain)
|
||||
assert len(hierarchy_nodes) > 0
|
||||
|
||||
|
||||
@patch("onyx.file_processing.extract_file_text.get_unstructured_api_key")
|
||||
def test_resolve_multiple_files(
|
||||
mock_api_key: None, # noqa: ARG001
|
||||
google_drive_service_acct_connector_factory: Callable[..., GoogleDriveConnector],
|
||||
) -> None:
|
||||
"""Resolve multiple files across different folders via batch API."""
|
||||
connector = google_drive_service_acct_connector_factory(
|
||||
primary_admin_email=ADMIN_EMAIL,
|
||||
include_shared_drives=True,
|
||||
shared_drive_urls=None,
|
||||
include_my_drives=True,
|
||||
my_drive_emails=None,
|
||||
shared_folder_urls=None,
|
||||
include_files_shared_with_me=False,
|
||||
)
|
||||
|
||||
# Pick files from different folders: admin files (0-4), shared drive 1 (20-24), folder_2 (45-49)
|
||||
file_ids = [0, 1, 20, 21, 45]
|
||||
web_view_links = _load_web_view_links(file_ids)
|
||||
failures = _build_failures(web_view_links)
|
||||
|
||||
results = list(connector.resolve_errors(failures))
|
||||
|
||||
docs = [r for r in results if isinstance(r, Document)]
|
||||
new_failures = [r for r in results if isinstance(r, ConnectorFailure)]
|
||||
hierarchy_nodes = [r for r in results if isinstance(r, HierarchyNode)]
|
||||
|
||||
assert len(new_failures) == 0
|
||||
retrieved_names = {doc.semantic_identifier for doc in docs}
|
||||
expected_names = {f"file_{fid}.txt" for fid in file_ids}
|
||||
assert expected_names == retrieved_names
|
||||
|
||||
# Files span multiple folders, so we should get hierarchy nodes
|
||||
assert len(hierarchy_nodes) > 0
|
||||
|
||||
|
||||
@patch("onyx.file_processing.extract_file_text.get_unstructured_api_key")
|
||||
def test_resolve_hierarchy_nodes_are_valid(
|
||||
mock_api_key: None, # noqa: ARG001
|
||||
google_drive_service_acct_connector_factory: Callable[..., GoogleDriveConnector],
|
||||
) -> None:
|
||||
"""Verify that hierarchy nodes from resolve_errors match expected structure."""
|
||||
connector = google_drive_service_acct_connector_factory(
|
||||
primary_admin_email=ADMIN_EMAIL,
|
||||
include_shared_drives=True,
|
||||
shared_drive_urls=None,
|
||||
include_my_drives=True,
|
||||
my_drive_emails=None,
|
||||
shared_folder_urls=None,
|
||||
include_files_shared_with_me=False,
|
||||
)
|
||||
|
||||
# File in folder_1 (inside shared_drive_1) — should walk up to shared_drive_1 root
|
||||
web_view_links = _load_web_view_links([25])
|
||||
failures = _build_failures(web_view_links)
|
||||
|
||||
results = list(connector.resolve_errors(failures))
|
||||
|
||||
hierarchy_nodes = [r for r in results if isinstance(r, HierarchyNode)]
|
||||
node_ids = {node.raw_node_id for node in hierarchy_nodes}
|
||||
|
||||
# File 25 is in folder_1 which is inside shared_drive_1.
|
||||
# The parent walk must yield at least these two ancestors.
|
||||
assert (
|
||||
FOLDER_1_ID in node_ids
|
||||
), f"Expected folder_1 ({FOLDER_1_ID}) in hierarchy nodes, got: {node_ids}"
|
||||
assert (
|
||||
SHARED_DRIVE_1_ID in node_ids
|
||||
), f"Expected shared_drive_1 ({SHARED_DRIVE_1_ID}) in hierarchy nodes, got: {node_ids}"
|
||||
|
||||
for node in hierarchy_nodes:
|
||||
if node.raw_node_id not in ALL_EXPECTED_HIERARCHY_NODES:
|
||||
continue
|
||||
expected = ALL_EXPECTED_HIERARCHY_NODES[node.raw_node_id]
|
||||
assert node.display_name == expected.display_name, (
|
||||
f"Display name mismatch for {node.raw_node_id}: "
|
||||
f"expected '{expected.display_name}', got '{node.display_name}'"
|
||||
)
|
||||
assert node.node_type == expected.node_type, (
|
||||
f"Node type mismatch for {node.raw_node_id}: "
|
||||
f"expected '{expected.node_type}', got '{node.node_type}'"
|
||||
)
|
||||
|
||||
|
||||
@patch("onyx.file_processing.extract_file_text.get_unstructured_api_key")
|
||||
def test_resolve_with_invalid_link(
|
||||
mock_api_key: None, # noqa: ARG001
|
||||
google_drive_service_acct_connector_factory: Callable[..., GoogleDriveConnector],
|
||||
) -> None:
|
||||
"""Resolve with a mix of valid and invalid links — invalid ones yield ConnectorFailure."""
|
||||
connector = google_drive_service_acct_connector_factory(
|
||||
primary_admin_email=ADMIN_EMAIL,
|
||||
include_shared_drives=True,
|
||||
shared_drive_urls=None,
|
||||
include_my_drives=True,
|
||||
my_drive_emails=None,
|
||||
shared_folder_urls=None,
|
||||
include_files_shared_with_me=False,
|
||||
)
|
||||
|
||||
valid_links = _load_web_view_links([0])
|
||||
invalid_link = "https://drive.google.com/file/d/NONEXISTENT_FILE_ID_12345"
|
||||
failures = _build_failures(valid_links + [invalid_link])
|
||||
|
||||
results = list(connector.resolve_errors(failures))
|
||||
|
||||
docs = [r for r in results if isinstance(r, Document)]
|
||||
new_failures = [r for r in results if isinstance(r, ConnectorFailure)]
|
||||
|
||||
assert len(docs) == 1
|
||||
assert docs[0].semantic_identifier == "file_0.txt"
|
||||
assert len(new_failures) == 1
|
||||
assert new_failures[0].failed_document is not None
|
||||
assert new_failures[0].failed_document.document_id == invalid_link
|
||||
|
||||
|
||||
@patch("onyx.file_processing.extract_file_text.get_unstructured_api_key")
|
||||
def test_resolve_empty_errors(
|
||||
mock_api_key: None, # noqa: ARG001
|
||||
google_drive_service_acct_connector_factory: Callable[..., GoogleDriveConnector],
|
||||
) -> None:
|
||||
"""Resolving an empty error list should yield nothing."""
|
||||
connector = google_drive_service_acct_connector_factory(
|
||||
primary_admin_email=ADMIN_EMAIL,
|
||||
include_shared_drives=True,
|
||||
shared_drive_urls=None,
|
||||
include_my_drives=True,
|
||||
my_drive_emails=None,
|
||||
shared_folder_urls=None,
|
||||
include_files_shared_with_me=False,
|
||||
)
|
||||
|
||||
results = list(connector.resolve_errors([]))
|
||||
|
||||
assert len(results) == 0
|
||||
|
||||
|
||||
@patch("onyx.file_processing.extract_file_text.get_unstructured_api_key")
|
||||
def test_resolve_entity_failures_are_skipped(
|
||||
mock_api_key: None, # noqa: ARG001
|
||||
google_drive_service_acct_connector_factory: Callable[..., GoogleDriveConnector],
|
||||
) -> None:
|
||||
"""Entity failures (not document failures) should be skipped by resolve_errors."""
|
||||
from onyx.connectors.models import EntityFailure
|
||||
|
||||
connector = google_drive_service_acct_connector_factory(
|
||||
primary_admin_email=ADMIN_EMAIL,
|
||||
include_shared_drives=True,
|
||||
shared_drive_urls=None,
|
||||
include_my_drives=True,
|
||||
my_drive_emails=None,
|
||||
shared_folder_urls=None,
|
||||
include_files_shared_with_me=False,
|
||||
)
|
||||
|
||||
entity_failure = ConnectorFailure(
|
||||
failed_entity=EntityFailure(entity_id="some_stage"),
|
||||
failure_message="retrieval failure",
|
||||
)
|
||||
|
||||
results = list(connector.resolve_errors([entity_failure]))
|
||||
|
||||
assert len(results) == 0
|
||||
@@ -2,6 +2,7 @@ import threading
|
||||
from typing import Any
|
||||
from typing import cast
|
||||
from typing import List
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import Mock
|
||||
from unittest.mock import patch
|
||||
|
||||
@@ -12,8 +13,13 @@ from onyx.connectors.models import Document
|
||||
from onyx.connectors.models import DocumentSource
|
||||
from onyx.connectors.models import ImageSection
|
||||
from onyx.connectors.models import TextSection
|
||||
from onyx.hooks.executor import HookSkipped
|
||||
from onyx.hooks.executor import HookSoftFailed
|
||||
from onyx.hooks.points.document_ingestion import DocumentIngestionResponse
|
||||
from onyx.hooks.points.document_ingestion import DocumentIngestionSection
|
||||
from onyx.indexing.chunker import Chunker
|
||||
from onyx.indexing.embedder import DefaultIndexingEmbedder
|
||||
from onyx.indexing.indexing_pipeline import _apply_document_ingestion_hook
|
||||
from onyx.indexing.indexing_pipeline import add_contextual_summaries
|
||||
from onyx.indexing.indexing_pipeline import filter_documents
|
||||
from onyx.indexing.indexing_pipeline import process_image_sections
|
||||
@@ -223,3 +229,148 @@ def test_contextual_rag(
|
||||
count += 1
|
||||
assert chunk.doc_summary == doc_summary
|
||||
assert chunk.chunk_context == chunk_context
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _apply_document_ingestion_hook
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_PATCH_EXECUTE_HOOK = "onyx.indexing.indexing_pipeline.execute_hook"
|
||||
|
||||
|
||||
def _make_doc(
|
||||
doc_id: str = "doc1",
|
||||
sections: list[TextSection | ImageSection] | None = None,
|
||||
) -> Document:
|
||||
if sections is None:
|
||||
sections = [TextSection(text="Hello", link="http://example.com")]
|
||||
return Document(
|
||||
id=doc_id,
|
||||
title="Test Doc",
|
||||
semantic_identifier="test-doc",
|
||||
sections=cast(list[TextSection | ImageSection], sections),
|
||||
source=DocumentSource.FILE,
|
||||
metadata={},
|
||||
)
|
||||
|
||||
|
||||
def test_document_ingestion_hook_skipped_passes_through() -> None:
|
||||
doc = _make_doc()
|
||||
with patch(_PATCH_EXECUTE_HOOK, return_value=HookSkipped()):
|
||||
result = _apply_document_ingestion_hook([doc], MagicMock())
|
||||
assert result == [doc]
|
||||
|
||||
|
||||
def test_document_ingestion_hook_soft_failed_passes_through() -> None:
|
||||
doc = _make_doc()
|
||||
with patch(_PATCH_EXECUTE_HOOK, return_value=HookSoftFailed()):
|
||||
result = _apply_document_ingestion_hook([doc], MagicMock())
|
||||
assert result == [doc]
|
||||
|
||||
|
||||
def test_document_ingestion_hook_none_sections_drops_document() -> None:
|
||||
doc = _make_doc()
|
||||
with patch(
|
||||
_PATCH_EXECUTE_HOOK,
|
||||
return_value=DocumentIngestionResponse(
|
||||
sections=None, rejection_reason="PII detected"
|
||||
),
|
||||
):
|
||||
result = _apply_document_ingestion_hook([doc], MagicMock())
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_document_ingestion_hook_all_invalid_sections_drops_document() -> None:
|
||||
"""A non-empty list where every section has neither text nor image_file_id drops the doc."""
|
||||
doc = _make_doc()
|
||||
with patch(
|
||||
_PATCH_EXECUTE_HOOK,
|
||||
return_value=DocumentIngestionResponse(sections=[DocumentIngestionSection()]),
|
||||
):
|
||||
result = _apply_document_ingestion_hook([doc], MagicMock())
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_document_ingestion_hook_empty_sections_drops_document() -> None:
|
||||
doc = _make_doc()
|
||||
with patch(
|
||||
_PATCH_EXECUTE_HOOK,
|
||||
return_value=DocumentIngestionResponse(sections=[]),
|
||||
):
|
||||
result = _apply_document_ingestion_hook([doc], MagicMock())
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_document_ingestion_hook_rewrites_text_sections() -> None:
|
||||
doc = _make_doc(sections=[TextSection(text="original", link="http://a.com")])
|
||||
with patch(
|
||||
_PATCH_EXECUTE_HOOK,
|
||||
return_value=DocumentIngestionResponse(
|
||||
sections=[DocumentIngestionSection(text="rewritten", link="http://b.com")]
|
||||
),
|
||||
):
|
||||
result = _apply_document_ingestion_hook([doc], MagicMock())
|
||||
assert len(result) == 1
|
||||
assert len(result[0].sections) == 1
|
||||
section = result[0].sections[0]
|
||||
assert isinstance(section, TextSection)
|
||||
assert section.text == "rewritten"
|
||||
assert section.link == "http://b.com"
|
||||
|
||||
|
||||
def test_document_ingestion_hook_preserves_image_section_order() -> None:
|
||||
"""Hook receives all sections including images and controls final ordering."""
|
||||
image = ImageSection(image_file_id="img-1", link=None)
|
||||
doc = _make_doc(
|
||||
sections=cast(
|
||||
list[TextSection | ImageSection],
|
||||
[TextSection(text="original", link=None), image],
|
||||
)
|
||||
)
|
||||
# Hook moves the image before the text section
|
||||
with patch(
|
||||
_PATCH_EXECUTE_HOOK,
|
||||
return_value=DocumentIngestionResponse(
|
||||
sections=[
|
||||
DocumentIngestionSection(image_file_id="img-1", link=None),
|
||||
DocumentIngestionSection(text="rewritten", link=None),
|
||||
]
|
||||
),
|
||||
):
|
||||
result = _apply_document_ingestion_hook([doc], MagicMock())
|
||||
assert len(result) == 1
|
||||
sections = result[0].sections
|
||||
assert len(sections) == 2
|
||||
assert (
|
||||
isinstance(sections[0], ImageSection) and sections[0].image_file_id == "img-1"
|
||||
)
|
||||
assert isinstance(sections[1], TextSection) and sections[1].text == "rewritten"
|
||||
|
||||
|
||||
def test_document_ingestion_hook_mixed_batch() -> None:
|
||||
"""Drop one doc, rewrite another, pass through a third."""
|
||||
doc_drop = _make_doc(doc_id="drop")
|
||||
doc_rewrite = _make_doc(doc_id="rewrite")
|
||||
doc_skip = _make_doc(doc_id="skip")
|
||||
|
||||
def _side_effect(**kwargs: Any) -> Any:
|
||||
doc_id = kwargs["payload"]["document_id"]
|
||||
if doc_id == "drop":
|
||||
return DocumentIngestionResponse(sections=None)
|
||||
if doc_id == "rewrite":
|
||||
return DocumentIngestionResponse(
|
||||
sections=[DocumentIngestionSection(text="new text", link=None)]
|
||||
)
|
||||
return HookSkipped()
|
||||
|
||||
with patch(_PATCH_EXECUTE_HOOK, side_effect=_side_effect):
|
||||
result = _apply_document_ingestion_hook(
|
||||
[doc_drop, doc_rewrite, doc_skip], MagicMock()
|
||||
)
|
||||
|
||||
assert len(result) == 2
|
||||
ids = {d.id for d in result}
|
||||
assert ids == {"rewrite", "skip"}
|
||||
rewritten = next(d for d in result if d.id == "rewrite")
|
||||
assert isinstance(rewritten.sections[0], TextSection)
|
||||
assert rewritten.sections[0].text == "new text"
|
||||
|
||||
@@ -231,6 +231,23 @@ import { Hoverable } from "@opal/core";
|
||||
|
||||
# Best Practices
|
||||
|
||||
## 0. Size Variant Defaults
|
||||
|
||||
**When using `SizeVariants` (or any subset like `PaddingVariants`, `RoundingVariants`) as a prop
|
||||
type, always default to `"md"`.**
|
||||
|
||||
**Reason:** `"md"` is the standard middle-of-the-road preset across the design system. Consistent
|
||||
defaults make components predictable — callers only need to specify a size when they want something
|
||||
other than the norm.
|
||||
|
||||
```typescript
|
||||
// ✅ Good — default to "md"
|
||||
function MyCard({ padding = "md", rounding = "md" }: MyCardProps) { ... }
|
||||
|
||||
// ❌ Bad — arbitrary or inconsistent defaults
|
||||
function MyCard({ padding = "sm", rounding = "lg" }: MyCardProps) { ... }
|
||||
```
|
||||
|
||||
## 1. Tailwind Dark Mode
|
||||
|
||||
**Strictly forbid using the `dark:` modifier in Tailwind classes, except for logo icon handling.**
|
||||
|
||||
@@ -29,7 +29,7 @@ export const BackgroundVariants: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-col gap-4 w-96">
|
||||
{BACKGROUND_VARIANTS.map((bg) => (
|
||||
<Card key={bg} backgroundVariant={bg} borderVariant="solid">
|
||||
<Card key={bg} background={bg} border="solid">
|
||||
<p>backgroundVariant: {bg}</p>
|
||||
</Card>
|
||||
))}
|
||||
@@ -41,7 +41,7 @@ export const BorderVariants: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-col gap-4 w-96">
|
||||
{BORDER_VARIANTS.map((border) => (
|
||||
<Card key={border} borderVariant={border}>
|
||||
<Card key={border} border={border}>
|
||||
<p>borderVariant: {border}</p>
|
||||
</Card>
|
||||
))}
|
||||
@@ -53,7 +53,7 @@ export const PaddingVariants: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-col gap-4 w-96">
|
||||
{PADDING_VARIANTS.map((padding) => (
|
||||
<Card key={padding} paddingVariant={padding} borderVariant="solid">
|
||||
<Card key={padding} padding={padding} border="solid">
|
||||
<p>paddingVariant: {padding}</p>
|
||||
</Card>
|
||||
))}
|
||||
@@ -65,7 +65,7 @@ export const RoundingVariants: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-col gap-4 w-96">
|
||||
{ROUNDING_VARIANTS.map((rounding) => (
|
||||
<Card key={rounding} roundingVariant={rounding} borderVariant="solid">
|
||||
<Card key={rounding} rounding={rounding} border="solid">
|
||||
<p>roundingVariant: {rounding}</p>
|
||||
</Card>
|
||||
))}
|
||||
@@ -84,9 +84,9 @@ export const AllCombinations: Story = {
|
||||
BORDER_VARIANTS.map((border) => (
|
||||
<Card
|
||||
key={`${padding}-${bg}-${border}`}
|
||||
paddingVariant={padding}
|
||||
backgroundVariant={bg}
|
||||
borderVariant={border}
|
||||
padding={padding}
|
||||
background={bg}
|
||||
border={border}
|
||||
>
|
||||
<p className="text-xs">
|
||||
bg: {bg}, border: {border}
|
||||
|
||||
@@ -8,30 +8,30 @@ A plain container component with configurable background, border, padding, and r
|
||||
|
||||
Padding and rounding are controlled independently:
|
||||
|
||||
| `paddingVariant` | Class |
|
||||
|------------------|---------|
|
||||
| `"lg"` | `p-6` |
|
||||
| `"md"` | `p-4` |
|
||||
| `"sm"` | `p-2` |
|
||||
| `"xs"` | `p-1` |
|
||||
| `"2xs"` | `p-0.5` |
|
||||
| `"fit"` | `p-0` |
|
||||
| `padding` | Class |
|
||||
|-----------|---------|
|
||||
| `"lg"` | `p-6` |
|
||||
| `"md"` | `p-4` |
|
||||
| `"sm"` | `p-2` |
|
||||
| `"xs"` | `p-1` |
|
||||
| `"2xs"` | `p-0.5` |
|
||||
| `"fit"` | `p-0` |
|
||||
|
||||
| `roundingVariant` | Class |
|
||||
|-------------------|--------------|
|
||||
| `"xs"` | `rounded-04` |
|
||||
| `"sm"` | `rounded-08` |
|
||||
| `"md"` | `rounded-12` |
|
||||
| `"lg"` | `rounded-16` |
|
||||
| `rounding` | Class |
|
||||
|------------|--------------|
|
||||
| `"xs"` | `rounded-04` |
|
||||
| `"sm"` | `rounded-08` |
|
||||
| `"md"` | `rounded-12` |
|
||||
| `"lg"` | `rounded-16` |
|
||||
|
||||
## Props
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `paddingVariant` | `PaddingVariants` | `"sm"` | Padding preset |
|
||||
| `roundingVariant` | `RoundingVariants` | `"md"` | Border-radius preset |
|
||||
| `backgroundVariant` | `"none" \| "light" \| "heavy"` | `"light"` | Background fill intensity |
|
||||
| `borderVariant` | `"none" \| "dashed" \| "solid"` | `"none"` | Border style |
|
||||
| `padding` | `PaddingVariants` | `"sm"` | Padding preset |
|
||||
| `rounding` | `RoundingVariants` | `"md"` | Border-radius preset |
|
||||
| `background` | `"none" \| "light" \| "heavy"` | `"light"` | Background fill intensity |
|
||||
| `border` | `"none" \| "dashed" \| "solid"` | `"none"` | Border style |
|
||||
| `ref` | `React.Ref<HTMLDivElement>` | — | Ref forwarded to the root div |
|
||||
| `children` | `React.ReactNode` | — | Card content |
|
||||
|
||||
@@ -47,17 +47,17 @@ import { Card } from "@opal/components";
|
||||
</Card>
|
||||
|
||||
// Large padding + rounding with solid border
|
||||
<Card paddingVariant="lg" roundingVariant="lg" borderVariant="solid">
|
||||
<Card padding="lg" rounding="lg" border="solid">
|
||||
<p>Spacious card</p>
|
||||
</Card>
|
||||
|
||||
// Compact card with solid border
|
||||
<Card paddingVariant="xs" roundingVariant="sm" borderVariant="solid">
|
||||
<Card padding="xs" rounding="sm" border="solid">
|
||||
<p>Compact card</p>
|
||||
</Card>
|
||||
|
||||
// Empty state card
|
||||
<Card backgroundVariant="none" borderVariant="dashed">
|
||||
<Card background="none" border="dashed">
|
||||
<p>No items yet</p>
|
||||
</Card>
|
||||
```
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import "@opal/components/cards/card/styles.css";
|
||||
import type { PaddingVariants, RoundingVariants } from "@opal/types";
|
||||
import { cardPaddingVariants, cardRoundingVariants } from "@opal/shared";
|
||||
import { cn } from "@opal/utils";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -22,9 +23,9 @@ type CardProps = {
|
||||
* | `"2xs"` | `p-0.5` |
|
||||
* | `"fit"` | `p-0` |
|
||||
*
|
||||
* @default "sm"
|
||||
* @default "md"
|
||||
*/
|
||||
paddingVariant?: PaddingVariants;
|
||||
padding?: PaddingVariants;
|
||||
|
||||
/**
|
||||
* Border-radius preset.
|
||||
@@ -38,7 +39,7 @@ type CardProps = {
|
||||
*
|
||||
* @default "md"
|
||||
*/
|
||||
roundingVariant?: RoundingVariants;
|
||||
rounding?: RoundingVariants;
|
||||
|
||||
/**
|
||||
* Background fill intensity.
|
||||
@@ -48,7 +49,7 @@ type CardProps = {
|
||||
*
|
||||
* @default "light"
|
||||
*/
|
||||
backgroundVariant?: BackgroundVariant;
|
||||
background?: BackgroundVariant;
|
||||
|
||||
/**
|
||||
* Border style.
|
||||
@@ -58,7 +59,7 @@ type CardProps = {
|
||||
*
|
||||
* @default "none"
|
||||
*/
|
||||
borderVariant?: BorderVariant;
|
||||
border?: BorderVariant;
|
||||
|
||||
/** Ref forwarded to the root `<div>`. */
|
||||
ref?: React.Ref<HTMLDivElement>;
|
||||
@@ -66,47 +67,27 @@ type CardProps = {
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mappings
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const paddingForVariant: Record<PaddingVariants, string> = {
|
||||
lg: "p-6",
|
||||
md: "p-4",
|
||||
sm: "p-2",
|
||||
xs: "p-1",
|
||||
"2xs": "p-0.5",
|
||||
fit: "p-0",
|
||||
};
|
||||
|
||||
const roundingForVariant: Record<RoundingVariants, string> = {
|
||||
lg: "rounded-16",
|
||||
md: "rounded-12",
|
||||
sm: "rounded-08",
|
||||
xs: "rounded-04",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Card
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function Card({
|
||||
paddingVariant = "sm",
|
||||
roundingVariant = "md",
|
||||
backgroundVariant = "light",
|
||||
borderVariant = "none",
|
||||
padding: paddingProp = "md",
|
||||
rounding: roundingProp = "md",
|
||||
background = "light",
|
||||
border = "none",
|
||||
ref,
|
||||
children,
|
||||
}: CardProps) {
|
||||
const padding = paddingForVariant[paddingVariant];
|
||||
const rounding = roundingForVariant[roundingVariant];
|
||||
const padding = cardPaddingVariants[paddingProp];
|
||||
const rounding = cardRoundingVariants[roundingProp];
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("opal-card", padding, rounding)}
|
||||
data-background={backgroundVariant}
|
||||
data-border={borderVariant}
|
||||
data-background={background}
|
||||
data-border={border}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { EmptyMessageCard } from "@opal/components";
|
||||
import { SvgSparkle, SvgUsers } from "@opal/icons";
|
||||
|
||||
const SIZE_VARIANTS = ["lg", "md", "sm", "xs", "2xs", "fit"] as const;
|
||||
const PADDING_VARIANTS = ["fit", "2xs", "xs", "sm", "md", "lg"] as const;
|
||||
|
||||
const meta: Meta<typeof EmptyMessageCard> = {
|
||||
title: "opal/components/EmptyMessageCard",
|
||||
@@ -26,14 +26,14 @@ export const WithCustomIcon: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const SizeVariants: Story = {
|
||||
export const PaddingVariants: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-col gap-4 w-96">
|
||||
{SIZE_VARIANTS.map((size) => (
|
||||
{PADDING_VARIANTS.map((padding) => (
|
||||
<EmptyMessageCard
|
||||
key={size}
|
||||
sizeVariant={size}
|
||||
title={`sizeVariant: ${size}`}
|
||||
key={padding}
|
||||
padding={padding}
|
||||
title={`padding: ${padding}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -6,12 +6,12 @@ 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) |
|
||||
| `paddingVariant` | `PaddingVariants` | `"sm"` | Padding preset for the card |
|
||||
| `ref` | `React.Ref<HTMLDivElement>` | — | Ref forwarded to the root div |
|
||||
| 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 |
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -26,5 +26,5 @@ import { SvgSparkle, SvgFileText } from "@opal/icons";
|
||||
<EmptyMessageCard icon={SvgSparkle} title="No agents selected." />
|
||||
|
||||
// With custom padding
|
||||
<EmptyMessageCard paddingVariant="xs" icon={SvgFileText} title="No documents available." />
|
||||
<EmptyMessageCard padding="xs" icon={SvgFileText} title="No documents available." />
|
||||
```
|
||||
|
||||
@@ -14,8 +14,8 @@ type EmptyMessageCardProps = {
|
||||
/** Primary message text. */
|
||||
title: string;
|
||||
|
||||
/** Padding preset for the card. */
|
||||
paddingVariant?: PaddingVariants;
|
||||
/** Padding preset for the card. @default "md" */
|
||||
padding?: PaddingVariants;
|
||||
|
||||
/** Ref forwarded to the root Card div. */
|
||||
ref?: React.Ref<HTMLDivElement>;
|
||||
@@ -28,15 +28,16 @@ type EmptyMessageCardProps = {
|
||||
function EmptyMessageCard({
|
||||
icon = SvgEmpty,
|
||||
title,
|
||||
paddingVariant = "sm",
|
||||
padding = "md",
|
||||
ref,
|
||||
}: EmptyMessageCardProps) {
|
||||
return (
|
||||
<Card
|
||||
ref={ref}
|
||||
backgroundVariant="none"
|
||||
borderVariant="dashed"
|
||||
paddingVariant={paddingVariant}
|
||||
background="none"
|
||||
border="dashed"
|
||||
padding={padding}
|
||||
rounding="md"
|
||||
>
|
||||
<Content
|
||||
icon={icon}
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
**Import:** `import { SelectCard, type SelectCardProps } from "@opal/components";`
|
||||
|
||||
A stateful interactive card — the card counterpart to [`SelectButton`](../../buttons/select-button/README.md). Built on `Interactive.Stateful` (Slot) with a structural `<div>` that owns padding, rounding, border, and overflow.
|
||||
A stateful interactive card — the card counterpart to [`SelectButton`](../../buttons/select-button/README.md). Built on `Interactive.Stateful` (Slot) with a structural `<div>` that owns padding, rounding, border, and overflow. Always uses the `select-card` Interactive.Stateful variant internally.
|
||||
|
||||
## Relationship to Card
|
||||
|
||||
`Card` is a plain, non-interactive container. `SelectCard` adds stateful interactivity (hover, active, disabled, state-driven colors) by wrapping its root div with `Interactive.Stateful`. The relationship mirrors `Button` (stateless) vs `SelectButton` (stateful).
|
||||
`Card` is a plain, non-interactive container. `SelectCard` adds stateful interactivity (hover, active, disabled, state-driven colors) by wrapping its root div with `Interactive.Stateful`. Both share the same independent `padding` / `rounding` API.
|
||||
|
||||
## Relationship to SelectButton
|
||||
|
||||
@@ -18,15 +18,15 @@ Interactive.Stateful → structural element → content
|
||||
|
||||
The key differences:
|
||||
|
||||
- SelectCard renders a `<div>` (not `Interactive.Container`) — cards have their own rounding scale (one notch larger than buttons) and don't need Container's height/min-width.
|
||||
- SelectCard renders a `<div>` (not `Interactive.Container`) — cards have their own rounding scale and don't need Container's height/min-width.
|
||||
- SelectCard has no `foldable` prop — use `Interactive.Foldable` directly inside children.
|
||||
- SelectCard's children are fully composable — use `CardHeaderLayout`, `ContentAction`, `Content`, buttons, etc. inside.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Interactive.Stateful <- variant, state, interaction, disabled, onClick
|
||||
└─ div.opal-select-card <- padding, rounding, border, overflow
|
||||
Interactive.Stateful (variant="select-card") <- state, interaction, disabled, onClick
|
||||
└─ div.opal-select-card <- padding, rounding, border, overflow
|
||||
└─ children (composable)
|
||||
```
|
||||
|
||||
@@ -34,28 +34,36 @@ The `Interactive.Stateful` Slot merges onto the div, producing a single DOM elem
|
||||
|
||||
## Props
|
||||
|
||||
Inherits **all** props from `InteractiveStatefulProps` (variant, state, interaction, onClick, href, etc.) plus:
|
||||
Inherits **all** props from `InteractiveStatefulProps` (except `variant`, which is hardcoded to `select-card`) plus:
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `sizeVariant` | `ContainerSizeVariants` | `"lg"` | Controls padding and border-radius |
|
||||
| `padding` | `PaddingVariants` | `"sm"` | Padding preset |
|
||||
| `rounding` | `RoundingVariants` | `"lg"` | Border-radius preset |
|
||||
| `ref` | `React.Ref<HTMLDivElement>` | — | Ref forwarded to the root div |
|
||||
| `children` | `React.ReactNode` | — | Card content |
|
||||
|
||||
### Padding scale
|
||||
|
||||
| `padding` | Class |
|
||||
|-----------|---------|
|
||||
| `"lg"` | `p-6` |
|
||||
| `"md"` | `p-4` |
|
||||
| `"sm"` | `p-2` |
|
||||
| `"xs"` | `p-1` |
|
||||
| `"2xs"` | `p-0.5` |
|
||||
| `"fit"` | `p-0` |
|
||||
|
||||
### Rounding scale
|
||||
|
||||
Cards use a bumped-up rounding scale compared to buttons:
|
||||
| `rounding` | Class |
|
||||
|------------|--------------|
|
||||
| `"xs"` | `rounded-04` |
|
||||
| `"sm"` | `rounded-08` |
|
||||
| `"md"` | `rounded-12` |
|
||||
| `"lg"` | `rounded-16` |
|
||||
|
||||
| Size | Rounding | Effective radius |
|
||||
|---|---|---|
|
||||
| `lg` | `rounded-16` | 1rem (16px) |
|
||||
| `md`–`sm` | `rounded-12` | 0.75rem (12px) |
|
||||
| `xs`–`2xs` | `rounded-08` | 0.5rem (8px) |
|
||||
| `fit` | `rounded-16` | 1rem (16px) |
|
||||
|
||||
### Recommended variant: `select-card`
|
||||
|
||||
The `select-card` Interactive.Stateful variant is specifically designed for cards. Unlike `select-heavy` (which only changes foreground color between empty and filled), `select-card` gives the filled state a visible background — important on larger surfaces where background carries more of the visual distinction.
|
||||
### State colors (`select-card` variant)
|
||||
|
||||
| State | Rest background | Rest foreground |
|
||||
|---|---|---|
|
||||
@@ -82,7 +90,7 @@ All background and foreground colors come from the Interactive.Stateful CSS, not
|
||||
import { SelectCard } from "@opal/components";
|
||||
import { CardHeaderLayout } from "@opal/layouts";
|
||||
|
||||
<SelectCard variant="select-card" state="selected" onClick={handleClick}>
|
||||
<SelectCard state="selected" onClick={handleClick}>
|
||||
<CardHeaderLayout
|
||||
icon={SvgGlobe}
|
||||
title="Google"
|
||||
@@ -100,7 +108,7 @@ import { CardHeaderLayout } from "@opal/layouts";
|
||||
### Disconnected state (clickable)
|
||||
|
||||
```tsx
|
||||
<SelectCard variant="select-card" state="empty" onClick={handleConnect}>
|
||||
<SelectCard state="empty" onClick={handleConnect}>
|
||||
<CardHeaderLayout
|
||||
icon={SvgCloud}
|
||||
title="OpenAI"
|
||||
@@ -115,7 +123,7 @@ import { CardHeaderLayout } from "@opal/layouts";
|
||||
### With foldable hover-reveal
|
||||
|
||||
```tsx
|
||||
<SelectCard variant="select-card" state="filled">
|
||||
<SelectCard state="filled">
|
||||
<CardHeaderLayout
|
||||
icon={SvgCloud}
|
||||
title="OpenAI"
|
||||
|
||||
@@ -21,7 +21,8 @@ const withTooltipProvider: Decorator = (Story) => (
|
||||
);
|
||||
|
||||
const STATES = ["empty", "filled", "selected"] as const;
|
||||
const SIZE_VARIANTS = ["lg", "md", "sm", "xs", "2xs", "fit"] as const;
|
||||
const PADDING_VARIANTS = ["fit", "2xs", "xs", "sm", "md", "lg"] as const;
|
||||
const ROUNDING_VARIANTS = ["xs", "sm", "md", "lg"] as const;
|
||||
|
||||
const meta = {
|
||||
title: "opal/components/SelectCard",
|
||||
@@ -44,7 +45,7 @@ type Story = StoryObj<typeof meta>;
|
||||
export const Default: Story = {
|
||||
render: () => (
|
||||
<div className="w-96">
|
||||
<SelectCard variant="select-card" state="empty">
|
||||
<SelectCard state="empty">
|
||||
<div className="p-2">
|
||||
<Content
|
||||
sizePreset="main-ui"
|
||||
@@ -63,7 +64,7 @@ export const AllStates: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-col gap-4 w-96">
|
||||
{STATES.map((state) => (
|
||||
<SelectCard key={state} variant="select-card" state={state}>
|
||||
<SelectCard key={state} state={state}>
|
||||
<div className="p-2">
|
||||
<Content
|
||||
sizePreset="main-ui"
|
||||
@@ -82,11 +83,7 @@ export const AllStates: Story = {
|
||||
export const Clickable: Story = {
|
||||
render: () => (
|
||||
<div className="w-96">
|
||||
<SelectCard
|
||||
variant="select-card"
|
||||
state="empty"
|
||||
onClick={() => alert("Card clicked")}
|
||||
>
|
||||
<SelectCard state="empty" onClick={() => alert("Card clicked")}>
|
||||
<div className="p-2">
|
||||
<Content
|
||||
sizePreset="main-ui"
|
||||
@@ -105,7 +102,7 @@ export const WithActions: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-col gap-4 w-[28rem]">
|
||||
{/* Disconnected */}
|
||||
<SelectCard variant="select-card" state="empty" onClick={() => {}}>
|
||||
<SelectCard state="empty" onClick={() => {}}>
|
||||
<div className="flex flex-row items-stretch w-full">
|
||||
<div className="flex-1 p-2">
|
||||
<Content
|
||||
@@ -125,7 +122,7 @@ export const WithActions: Story = {
|
||||
</SelectCard>
|
||||
|
||||
{/* Connected with foldable */}
|
||||
<SelectCard variant="select-card" state="filled">
|
||||
<SelectCard state="filled">
|
||||
<div className="flex flex-row items-stretch w-full">
|
||||
<div className="flex-1 p-2">
|
||||
<Content
|
||||
@@ -163,7 +160,7 @@ export const WithActions: Story = {
|
||||
</SelectCard>
|
||||
|
||||
{/* Selected */}
|
||||
<SelectCard variant="select-card" state="selected">
|
||||
<SelectCard state="selected">
|
||||
<div className="flex flex-row items-stretch w-full">
|
||||
<div className="flex-1 p-2">
|
||||
<Content
|
||||
@@ -203,22 +200,17 @@ export const WithActions: Story = {
|
||||
),
|
||||
};
|
||||
|
||||
export const SizeVariants: Story = {
|
||||
export const PaddingVariants: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-col gap-4 w-96">
|
||||
{SIZE_VARIANTS.map((size) => (
|
||||
<SelectCard
|
||||
key={size}
|
||||
variant="select-card"
|
||||
state="filled"
|
||||
sizeVariant={size}
|
||||
>
|
||||
{PADDING_VARIANTS.map((padding) => (
|
||||
<SelectCard key={padding} state="filled" padding={padding}>
|
||||
<Content
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
icon={SvgGlobe}
|
||||
title={`sizeVariant: ${size}`}
|
||||
description="Shows padding and rounding differences."
|
||||
title={`paddingVariant: ${padding}`}
|
||||
description="Shows padding differences."
|
||||
/>
|
||||
</SelectCard>
|
||||
))}
|
||||
@@ -226,20 +218,18 @@ export const SizeVariants: Story = {
|
||||
),
|
||||
};
|
||||
|
||||
export const SelectHeavyVariant: Story = {
|
||||
export const RoundingVariants: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-col gap-4 w-96">
|
||||
{STATES.map((state) => (
|
||||
<SelectCard key={state} variant="select-heavy" state={state}>
|
||||
<div className="p-2">
|
||||
<Content
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
icon={SvgGlobe}
|
||||
title={`select-heavy / ${state}`}
|
||||
description="For comparison with select-card variant."
|
||||
/>
|
||||
</div>
|
||||
{ROUNDING_VARIANTS.map((rounding) => (
|
||||
<SelectCard key={rounding} state="filled" rounding={rounding}>
|
||||
<Content
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
icon={SvgGlobe}
|
||||
title={`roundingVariant: ${rounding}`}
|
||||
description="Shows rounding differences."
|
||||
/>
|
||||
</SelectCard>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import "@opal/components/cards/select-card/styles.css";
|
||||
import type { ContainerSizeVariants } from "@opal/types";
|
||||
import { containerSizeVariants } from "@opal/shared";
|
||||
import type { PaddingVariants, RoundingVariants } from "@opal/types";
|
||||
import { cardPaddingVariants, cardRoundingVariants } from "@opal/shared";
|
||||
import { cn } from "@opal/utils";
|
||||
import { Interactive, type InteractiveStatefulProps } from "@opal/core";
|
||||
|
||||
@@ -8,23 +8,36 @@ import { Interactive, type InteractiveStatefulProps } from "@opal/core";
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type SelectCardProps = InteractiveStatefulProps & {
|
||||
type SelectCardProps = Omit<InteractiveStatefulProps, "variant"> & {
|
||||
/**
|
||||
* Size preset — controls padding and border-radius.
|
||||
* Padding preset.
|
||||
*
|
||||
* Padding comes from the shared size scale. Rounding follows the same
|
||||
* mapping as `Card` / `Button` / `Interactive.Container`:
|
||||
* | Value | Class |
|
||||
* |---------|---------|
|
||||
* | `"lg"` | `p-6` |
|
||||
* | `"md"` | `p-4` |
|
||||
* | `"sm"` | `p-2` |
|
||||
* | `"xs"` | `p-1` |
|
||||
* | `"2xs"` | `p-0.5` |
|
||||
* | `"fit"` | `p-0` |
|
||||
*
|
||||
* | Size | Rounding |
|
||||
* |------------|--------------|
|
||||
* | `lg` | `rounded-16` |
|
||||
* | `md`–`sm` | `rounded-12` |
|
||||
* | `xs`–`2xs` | `rounded-08` |
|
||||
* | `fit` | `rounded-16` |
|
||||
*
|
||||
* @default "lg"
|
||||
* @default "md"
|
||||
*/
|
||||
sizeVariant?: ContainerSizeVariants;
|
||||
padding?: PaddingVariants;
|
||||
|
||||
/**
|
||||
* Border-radius preset.
|
||||
*
|
||||
* | Value | Class |
|
||||
* |--------|--------------|
|
||||
* | `"xs"` | `rounded-04` |
|
||||
* | `"sm"` | `rounded-08` |
|
||||
* | `"md"` | `rounded-12` |
|
||||
* | `"lg"` | `rounded-16` |
|
||||
*
|
||||
* @default "md"
|
||||
*/
|
||||
rounding?: RoundingVariants;
|
||||
|
||||
/** Ref forwarded to the root `<div>`. */
|
||||
ref?: React.Ref<HTMLDivElement>;
|
||||
@@ -32,19 +45,6 @@ type SelectCardProps = InteractiveStatefulProps & {
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rounding
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const roundingForSize: Record<ContainerSizeVariants, string> = {
|
||||
lg: "rounded-16",
|
||||
md: "rounded-12",
|
||||
sm: "rounded-12",
|
||||
xs: "rounded-08",
|
||||
"2xs": "rounded-08",
|
||||
fit: "rounded-16",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SelectCard
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -61,7 +61,7 @@ const roundingForSize: Record<ContainerSizeVariants, string> = {
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <SelectCard variant="select-card" state="selected" onClick={handleClick}>
|
||||
* <SelectCard state="selected" onClick={handleClick}>
|
||||
* <ContentAction
|
||||
* icon={SvgGlobe}
|
||||
* title="Google"
|
||||
@@ -72,16 +72,17 @@ const roundingForSize: Record<ContainerSizeVariants, string> = {
|
||||
* ```
|
||||
*/
|
||||
function SelectCard({
|
||||
sizeVariant = "lg",
|
||||
padding: paddingProp = "md",
|
||||
rounding: roundingProp = "md",
|
||||
ref,
|
||||
children,
|
||||
...statefulProps
|
||||
}: SelectCardProps) {
|
||||
const { padding } = containerSizeVariants[sizeVariant];
|
||||
const rounding = roundingForSize[sizeVariant];
|
||||
const padding = cardPaddingVariants[paddingProp];
|
||||
const rounding = cardRoundingVariants[roundingProp];
|
||||
|
||||
return (
|
||||
<Interactive.Stateful {...statefulProps}>
|
||||
<Interactive.Stateful {...statefulProps} variant="select-card">
|
||||
<div ref={ref} className={cn("opal-select-card", padding, rounding)}>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,8 @@ import type {
|
||||
OverridableExtremaSizeVariants,
|
||||
ContainerSizeVariants,
|
||||
ExtremaSizeVariants,
|
||||
PaddingVariants,
|
||||
RoundingVariants,
|
||||
} from "@opal/types";
|
||||
|
||||
/**
|
||||
@@ -88,12 +90,40 @@ const heightVariants: Record<ExtremaSizeVariants, string> = {
|
||||
full: "h-full",
|
||||
} as const;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Card Variants
|
||||
//
|
||||
// Shared padding and rounding scales for card components (Card, SelectCard).
|
||||
//
|
||||
// Consumers:
|
||||
// - Card (paddingVariant, roundingVariant)
|
||||
// - SelectCard (paddingVariant, roundingVariant)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const cardPaddingVariants: Record<PaddingVariants, string> = {
|
||||
lg: "p-6",
|
||||
md: "p-4",
|
||||
sm: "p-2",
|
||||
xs: "p-1",
|
||||
"2xs": "p-0.5",
|
||||
fit: "p-0",
|
||||
};
|
||||
|
||||
const cardRoundingVariants: Record<RoundingVariants, string> = {
|
||||
lg: "rounded-16",
|
||||
md: "rounded-12",
|
||||
sm: "rounded-08",
|
||||
xs: "rounded-04",
|
||||
};
|
||||
|
||||
export {
|
||||
type ExtremaSizeVariants,
|
||||
type ContainerSizeVariants,
|
||||
type OverridableExtremaSizeVariants,
|
||||
type SizeVariants,
|
||||
containerSizeVariants,
|
||||
cardPaddingVariants,
|
||||
cardRoundingVariants,
|
||||
widthVariants,
|
||||
heightVariants,
|
||||
};
|
||||
|
||||
@@ -1008,7 +1008,7 @@ function ChatPreferencesForm() {
|
||||
)}
|
||||
</Text>
|
||||
</Section>
|
||||
<OpalCard backgroundVariant="none" borderVariant="solid">
|
||||
<OpalCard background="none" border="solid" padding="sm">
|
||||
<Content
|
||||
sizePreset="main-ui"
|
||||
icon={SvgAlertCircle}
|
||||
|
||||
@@ -112,7 +112,7 @@ export default function CodeInterpreterPage() {
|
||||
<SettingsLayouts.Body>
|
||||
{isEnabled || isLoading ? (
|
||||
<Hoverable.Root group="code-interpreter/Card">
|
||||
<SelectCard variant="select-card" state="filled" sizeVariant="lg">
|
||||
<SelectCard state="filled" padding="sm" rounding="lg">
|
||||
<CardHeaderLayout
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
@@ -157,9 +157,9 @@ export default function CodeInterpreterPage() {
|
||||
</Hoverable.Root>
|
||||
) : (
|
||||
<SelectCard
|
||||
variant="select-card"
|
||||
state="empty"
|
||||
sizeVariant="lg"
|
||||
padding="sm"
|
||||
rounding="lg"
|
||||
onClick={() => handleToggle(true)}
|
||||
>
|
||||
<CardHeaderLayout
|
||||
|
||||
@@ -248,9 +248,9 @@ export default function ImageGenerationContent() {
|
||||
group="image-gen/ProviderCard"
|
||||
>
|
||||
<SelectCard
|
||||
variant="select-card"
|
||||
state={STATUS_TO_STATE[status]}
|
||||
sizeVariant="lg"
|
||||
padding="sm"
|
||||
rounding="lg"
|
||||
aria-label={`image-gen-provider-${provider.image_provider_id}`}
|
||||
onClick={
|
||||
isDisconnected
|
||||
|
||||
@@ -212,9 +212,9 @@ function ExistingProviderCard({
|
||||
|
||||
<Hoverable.Root group="ExistingProviderCard">
|
||||
<SelectCard
|
||||
variant="select-card"
|
||||
state="filled"
|
||||
sizeVariant="lg"
|
||||
padding="sm"
|
||||
rounding="lg"
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
<CardHeaderLayout
|
||||
@@ -287,9 +287,9 @@ function NewProviderCard({
|
||||
|
||||
return (
|
||||
<SelectCard
|
||||
variant="select-card"
|
||||
state="empty"
|
||||
sizeVariant="lg"
|
||||
padding="sm"
|
||||
rounding="lg"
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
<CardHeaderLayout
|
||||
@@ -331,9 +331,9 @@ function NewCustomProviderCard({
|
||||
|
||||
return (
|
||||
<SelectCard
|
||||
variant="select-card"
|
||||
state="empty"
|
||||
sizeVariant="lg"
|
||||
padding="sm"
|
||||
rounding="lg"
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
<CardHeaderLayout
|
||||
|
||||
@@ -264,9 +264,9 @@ function ProviderCard({
|
||||
return (
|
||||
<Hoverable.Root group="web-search/ProviderCard">
|
||||
<SelectCard
|
||||
variant="select-card"
|
||||
state={STATUS_TO_STATE[status]}
|
||||
sizeVariant="lg"
|
||||
padding="sm"
|
||||
rounding="lg"
|
||||
onClick={
|
||||
isDisconnected && onConnect
|
||||
? onConnect
|
||||
|
||||
@@ -67,7 +67,7 @@ export default function AdminListHeader({
|
||||
|
||||
if (!hasItems) {
|
||||
return (
|
||||
<Card paddingVariant="md" roundingVariant="lg" borderVariant="solid">
|
||||
<Card rounding="lg" border="solid">
|
||||
<div className="flex flex-row items-center justify-between gap-3">
|
||||
<Content
|
||||
title={emptyStateText}
|
||||
|
||||
@@ -86,9 +86,9 @@ export default function ProviderCard({
|
||||
|
||||
return (
|
||||
<SelectCard
|
||||
variant="select-card"
|
||||
state={STATUS_TO_STATE[status]}
|
||||
sizeVariant="lg"
|
||||
padding="sm"
|
||||
rounding="lg"
|
||||
aria-label={ariaLabel}
|
||||
onClick={isDisconnected && onConnect ? onConnect : undefined}
|
||||
>
|
||||
|
||||
@@ -225,11 +225,7 @@ function BedrockModalInternals({
|
||||
</FieldWrapper>
|
||||
|
||||
{authMethod === AUTH_METHOD_ACCESS_KEY && (
|
||||
<Card
|
||||
backgroundVariant="light"
|
||||
borderVariant="none"
|
||||
paddingVariant="sm"
|
||||
>
|
||||
<Card background="light" border="none" padding="sm">
|
||||
<Section gap={1}>
|
||||
<InputLayouts.Vertical
|
||||
name={FIELD_AWS_ACCESS_KEY_ID}
|
||||
@@ -255,7 +251,7 @@ function BedrockModalInternals({
|
||||
|
||||
{authMethod === AUTH_METHOD_IAM && (
|
||||
<FieldWrapper>
|
||||
<Card backgroundVariant="none" borderVariant="solid">
|
||||
<Card background="none" border="solid" padding="sm">
|
||||
<Content
|
||||
icon={SvgAlertCircle}
|
||||
title="Onyx will use the IAM role attached to the environment it’s running in to authenticate."
|
||||
@@ -267,11 +263,7 @@ function BedrockModalInternals({
|
||||
)}
|
||||
|
||||
{authMethod === AUTH_METHOD_LONG_TERM_API_KEY && (
|
||||
<Card
|
||||
backgroundVariant="light"
|
||||
borderVariant="none"
|
||||
paddingVariant="sm"
|
||||
>
|
||||
<Card background="light" border="none" padding="sm">
|
||||
<Section gap={0.5}>
|
||||
<InputLayouts.Vertical
|
||||
name={FIELD_AWS_BEARER_TOKEN_BEDROCK}
|
||||
|
||||
@@ -166,7 +166,7 @@ function ModelConfigurationList({ formikProps }: ModelConfigurationListProps) {
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyMessageCard title="No models added yet." />
|
||||
<EmptyMessageCard title="No models added yet." padding="sm" />
|
||||
)}
|
||||
|
||||
<Button
|
||||
@@ -393,7 +393,7 @@ export default function CustomModal({
|
||||
/>
|
||||
</FieldWrapper>
|
||||
|
||||
<Card>
|
||||
<Card padding="sm">
|
||||
<ModelConfigurationList formikProps={formikProps as any} />
|
||||
</Card>
|
||||
</Section>
|
||||
|
||||
@@ -140,7 +140,7 @@ function OllamaModalInternals({
|
||||
isTesting={isTesting}
|
||||
isSubmitting={formikProps.isSubmitting}
|
||||
>
|
||||
<Card backgroundVariant="light" borderVariant="none" paddingVariant="sm">
|
||||
<Card background="light" border="none" padding="sm">
|
||||
<Tabs defaultValue={defaultTab}>
|
||||
<Tabs.List>
|
||||
<Tabs.Trigger value={TAB_SELF_HOSTED}>
|
||||
|
||||
@@ -250,11 +250,7 @@ export function ModelsAccessField<T extends BaseLLMFormValues>({
|
||||
</FieldWrapper>
|
||||
|
||||
{!isPublic && (
|
||||
<Card
|
||||
backgroundVariant="light"
|
||||
borderVariant="none"
|
||||
paddingVariant="sm"
|
||||
>
|
||||
<Card background="light" border="none" padding="sm">
|
||||
<Section gap={0.5}>
|
||||
<InputComboBox
|
||||
placeholder="Add groups and agents"
|
||||
@@ -266,7 +262,7 @@ export function ModelsAccessField<T extends BaseLLMFormValues>({
|
||||
leftSearchIcon
|
||||
/>
|
||||
|
||||
<Card backgroundVariant="heavy" borderVariant="none">
|
||||
<Card background="heavy" border="none" padding="sm">
|
||||
<ContentAction
|
||||
icon={SvgUserManage}
|
||||
title="Admin"
|
||||
@@ -290,7 +286,7 @@ export function ModelsAccessField<T extends BaseLLMFormValues>({
|
||||
const memberCount = group?.users.length ?? 0;
|
||||
return (
|
||||
<div key={`group-${id}`} className="min-w-0">
|
||||
<Card backgroundVariant="heavy" borderVariant="none">
|
||||
<Card background="heavy" border="none" padding="sm">
|
||||
<ContentAction
|
||||
icon={SvgUsers}
|
||||
title={group?.name ?? `Group ${id}`}
|
||||
@@ -325,7 +321,7 @@ export function ModelsAccessField<T extends BaseLLMFormValues>({
|
||||
const agent = agentMap.get(id);
|
||||
return (
|
||||
<div key={`agent-${id}`} className="min-w-0">
|
||||
<Card backgroundVariant="heavy" borderVariant="none">
|
||||
<Card background="heavy" border="none" padding="sm">
|
||||
<ContentAction
|
||||
icon={
|
||||
agent
|
||||
@@ -452,7 +448,7 @@ export function ModelsField<T extends BaseLLMFormValues>({
|
||||
const visibleModels = modelConfigurations.filter((m) => m.is_visible);
|
||||
|
||||
return (
|
||||
<Card backgroundVariant="light" borderVariant="none" paddingVariant="sm">
|
||||
<Card background="light" border="none" padding="sm">
|
||||
<Section gap={0.5}>
|
||||
<InputLayouts.Horizontal
|
||||
title="Models"
|
||||
@@ -491,7 +487,7 @@ export function ModelsField<T extends BaseLLMFormValues>({
|
||||
</InputLayouts.Horizontal>
|
||||
|
||||
{modelConfigurations.length === 0 ? (
|
||||
<EmptyMessageCard title="No models available." />
|
||||
<EmptyMessageCard title="No models available." padding="sm" />
|
||||
) : (
|
||||
<Section gap={0.25}>
|
||||
{isAutoMode
|
||||
|
||||
Reference in New Issue
Block a user