Compare commits

..

28 Commits

Author SHA1 Message Date
Raunak Bhagat
3272604daa Add todo 2026-04-01 23:09:39 -07:00
Raunak Bhagat
a1249c02f1 refactor: divorce Interactive.Stateless from Disabled context
Remove useDisabled import and context fallback. Disabled state is now
driven entirely by the disabled prop — no implicit context dependency.
2026-04-01 23:09:39 -07:00
Raunak Bhagat
841720033c refactor: flatten aliased OpalButton Disabled wrappers 2026-04-01 23:09:39 -07:00
Raunak Bhagat
4d2d4845f9 refactor: use Interactive.Stateless disabled prop in ButtonTile 2026-04-01 23:09:39 -07:00
Raunak Bhagat
78bd54fff0 fix: reset allowClick when disabled prop is set directly
When the disabled prop is provided explicitly, ignore the context's
allowClick — the prop should fully override context behavior.
2026-04-01 23:09:39 -07:00
Raunak Bhagat
d2043f2bf6 refactor: divorce Button from Disabled context
Add a `disabled` prop to Interactive.Stateless that takes priority over
the Disabled context. Button now passes disabled directly to Stateless
instead of wrapping in <Disabled>, removing the dependency entirely.

Other components (SelectButton, OpenButton, etc.) still work through
the Disabled context as before.
2026-04-01 23:09:39 -07:00
Raunak Bhagat
5bf0ad985b refactor: flatten <Disabled><Button> into <Button disabled>
The Opal Button already has an internal `disabled` prop that wraps itself
in <Disabled>. Replace 134 redundant <Disabled> wrappers across 84 files
with the built-in prop, and remove now-unused Disabled imports.
2026-04-01 23:09:39 -07:00
Raunak Bhagat
c6974756f5 refactor: revert HookLogsModal back to useCreateModal/useModalClose
Now that the portal click guard is in Interactive, the Provider
pattern is safe again — no need for open/onOpenChange workaround.
2026-04-01 23:08:14 -07:00
Raunak Bhagat
bac273d21d fix: guard Interactive onClick against React portal event bubbling
React bubbles synthetic events through the fiber tree, not the DOM
tree. This means clicks on portalled elements (e.g. Radix Dialog
overlays) bubble to ancestor onClick handlers even though the portal
is not a DOM descendant. Add guardPortalClick utility that checks
e.currentTarget.contains(e.target) before firing onClick.

Applied to Interactive.Stateful, Interactive.Stateless, and
Interactive.Simple. Revert HookLogsModal to open/onOpenChange props
since the useCreateModal Provider pattern is not needed now that the
guard prevents the spurious click.
2026-04-01 23:08:14 -07:00
Raunak Bhagat
160d060077 refactor: replace raw div trigger with Opal Button in HookStatusPopover
- Use Button with rightIcon render function for status icon coloring
- Wrap click handlers with noProp to prevent SelectCard propagation
2026-04-01 23:08:14 -07:00
Raunak Bhagat
f57c464889 fix: update SvgHookNodes → SvgShareWebhook in PlansView 2026-04-01 23:08:14 -07:00
Raunak Bhagat
1edf070de3 fix: enable Save button when only change is clearing API key 2026-04-01 23:08:14 -07:00
Raunak Bhagat
b22c47bc67 fix: restore API key clear functionality in edit mode
Track explicit clearing with apiKeyCleared state, set via onChange
on the password field. Fixes dead code where initialValues comparison
always evaluated to false.
2026-04-01 23:08:14 -07:00
Raunak Bhagat
e8fb187e81 refactor: migrate modals to useModalClose, Opal Text, and noProp
- Use useModalClose() instead of onClose prop in confirmation modals
- Switch from refresh-components Text to Opal Text (string-enum API)
- Remove BasicModalFooter, render buttons directly in Modal.Footer
- Wrap all button onClick handlers in noProp() to prevent card click propagation
- Simplify modal header icons (TODO for red coloring)
2026-04-01 23:08:14 -07:00
Raunak Bhagat
13a13bd178 fix: update imports and add type annotations for ee/ path migration 2026-04-01 23:08:14 -07:00
Raunak Bhagat
2a8b8e85de fix: add LITELLM enum variant and use it in provider config maps 2026-04-01 23:08:14 -07:00
Raunak Bhagat
e09fe5299c fix: add explicit rounding="lg" to hook SelectCards 2026-04-01 23:08:14 -07:00
Raunak Bhagat
7102897328 fix: add explicit padding="sm" to hook cards after default change to "md" 2026-04-01 23:08:14 -07:00
Raunak Bhagat
3514901cb2 fix: add Documentation link to connected hook cards and Hoverable disconnect button
- Drop CardHeaderLayout in ConnectedHookCard in favor of manual layout
  matching UnconnectedHookCard, so both render the docs link below Content
- Wrap SvgUnplug disconnect button in Hoverable.Item (opacity-on-hover)
2026-04-01 23:08:14 -07:00
Raunak Bhagat
fe6cad4f98 refactor: revamp Hook Extensions page with Opal components
- Migrate hook cards to Opal SelectCard with CardHeaderLayout
- Consolidate ConnectedHookCard, UnconnectedHookCard, HooksContent,
  and hookPointIcons into HooksPage/index.tsx
- Migrate HookFormModal from manual useState validation to Formik + Yup
  with InputTypeInField, PasswordInputTypeInField, and InputLayouts
- Use useCreateModal for disconnect/delete confirmation modals and
  HookLogsModal
- Replace manual search with useFilter hook
- Use Content layout for hook point label in HookFormModal
- Rename SvgHookNodes to SvgShareWebhook
2026-04-01 23:08:14 -07:00
Raunak Bhagat
11835a0268 fix(opal): guard opal/interactive's onClick handlers against React portal event bubbling (#9850) 2026-04-02 05:37:00 +00:00
Danelegend
519fb61cc7 fix(xlsx): Improve empty row/col handling (#9288) 2026-04-02 02:52:20 +00:00
acaprau
02671937fb chore(opensearch): Increase DEFAULT_NUM_HYBRID_SUBQUERY_CANDIDATES to 500, disable profiling by default (#9844)
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-04-02 00:49:21 +00:00
acaprau
1466158c1e fix(opensearch): Add Vespa server-side timeout for the migration (#9843) 2026-04-02 00:20:59 +00:00
Bo-Onyx
073cf11c42 feat(hook): update hook doc link and reference (#9841) 2026-04-02 00:12:04 +00:00
Justin Tahara
a2b0c15027 fix(db): remove unnecessary selectinload(User.memories) from auth paths (#9838) 2026-04-01 23:51:06 +00:00
Raunak Bhagat
a462678ddd refactor(opal): split SelectCard's sizeVariant prop into paddingVariant + roundingVariant (#9830) 2026-04-01 23:15:20 +00:00
Bo-Onyx
c50d2739b8 feat(hook): integrate document ingestion hook point (#9810) 2026-04-01 23:08:12 +00:00
145 changed files with 3230 additions and 3309 deletions

View File

@@ -286,11 +286,9 @@ USING_AWS_MANAGED_OPENSEARCH = (
os.environ.get("USING_AWS_MANAGED_OPENSEARCH", "").lower() == "true"
)
# Profiling adds some overhead to OpenSearch operations. This overhead is
# unknown right now. It is enabled by default so we can get useful logs for
# investigating slow queries. We may never disable it if the overhead is
# minimal.
# unknown right now. Defaults to True.
OPENSEARCH_PROFILING_DISABLED = (
os.environ.get("OPENSEARCH_PROFILING_DISABLED", "").lower() == "true"
os.environ.get("OPENSEARCH_PROFILING_DISABLED", "true").lower() == "true"
)
# Whether to disable match highlights for OpenSearch. Defaults to True for now
# as we investigate query performance.
@@ -942,9 +940,20 @@ CUSTOM_ANSWER_VALIDITY_CONDITIONS = json.loads(
)
VESPA_REQUEST_TIMEOUT = int(os.environ.get("VESPA_REQUEST_TIMEOUT") or "15")
# This is the timeout for the client side of the Vespa migration task. When
# exceeded, an exception is raised in our code. This value should be higher than
# VESPA_MIGRATION_SERVER_SIDE_REQUEST_TIMEOUT.
VESPA_MIGRATION_REQUEST_TIMEOUT_S = int(
os.environ.get("VESPA_MIGRATION_REQUEST_TIMEOUT_S") or "120"
)
# This is the timeout Vespa uses on the server side to know when to wrap up its
# traversal and try to report partial results. This differs from the client
# timeout above which raises an exception in our code when exceeded. This
# timeout allows Vespa to return gracefully. This value should be lower than
# VESPA_MIGRATION_REQUEST_TIMEOUT_S. Formatted as <number of seconds>s.
VESPA_MIGRATION_SERVER_SIDE_REQUEST_TIMEOUT = os.environ.get(
"VESPA_MIGRATION_SERVER_SIDE_REQUEST_TIMEOUT", "110s"
)
SYSTEM_RECURSION_LIMIT = int(os.environ.get("SYSTEM_RECURSION_LIMIT") or "1000")

View File

@@ -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,

View File

@@ -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

View File

@@ -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(

View File

@@ -4,7 +4,6 @@ from fastapi_users.password import PasswordHelper
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload
from sqlalchemy.orm import selectinload
from sqlalchemy.orm import Session
from onyx.auth.api_key import ApiKeyDescriptor
@@ -55,7 +54,6 @@ async def fetch_user_for_api_key(
select(User)
.join(ApiKey, ApiKey.user_id == User.id)
.where(ApiKey.hashed_api_key == hashed_api_key)
.options(selectinload(User.memories))
)

View File

@@ -13,7 +13,6 @@ from sqlalchemy import func
from sqlalchemy import Select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy.orm import selectinload
from sqlalchemy.orm import Session
from onyx.auth.schemas import UserRole
@@ -98,11 +97,6 @@ async def get_user_count(only_admin_users: bool = False) -> int:
# Need to override this because FastAPI Users doesn't give flexibility for backend field creation logic in OAuth flow
class SQLAlchemyUserAdminDB(SQLAlchemyUserDatabase[UP, ID]):
async def _get_user(self, statement: Select) -> UP | None:
statement = statement.options(selectinload(User.memories))
results = await self.session.execute(statement)
return results.unique().scalar_one_or_none()
async def create(
self,
create_dict: Dict[str, Any],

View File

@@ -8,7 +8,6 @@ from uuid import UUID
from sqlalchemy import select
from sqlalchemy import update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from sqlalchemy.orm import Session
from onyx.auth.pat import build_displayable_pat
@@ -47,7 +46,6 @@ async def fetch_user_for_pat(
(PersonalAccessToken.expires_at.is_(None))
| (PersonalAccessToken.expires_at > now)
)
.options(selectinload(User.memories))
)
if not user:
return None

View File

@@ -229,7 +229,9 @@ def get_memories_for_user(
user_id: UUID,
db_session: Session,
) -> Sequence[Memory]:
return db_session.scalars(select(Memory).where(Memory.user_id == user_id)).all()
return db_session.scalars(
select(Memory).where(Memory.user_id == user_id).order_by(Memory.id.desc())
).all()
def update_user_pinned_assistants(

View File

@@ -37,10 +37,10 @@ M = 32 # Set relatively high for better accuracy.
# we have a much higher chance of all 10 of the final desired docs showing up
# and getting scored. In worse situations, the final 10 docs don't even show up
# as the final 10 (worse than just a miss at the reranking step).
# Defaults to 100 for now. Initially this defaulted to 750 but we were seeing
# poor search performance.
# Defaults to 500 for now. Initially this defaulted to 750 but we were seeing
# poor search performance; bumped from 100 to 500 to improve recall.
DEFAULT_NUM_HYBRID_SUBQUERY_CANDIDATES = int(
os.environ.get("DEFAULT_NUM_HYBRID_SUBQUERY_CANDIDATES", 100)
os.environ.get("DEFAULT_NUM_HYBRID_SUBQUERY_CANDIDATES", 500)
)
# Number of vectors to examine to decide the top k neighbors for the HNSW

View File

@@ -20,6 +20,7 @@ from onyx.background.celery.tasks.opensearch_migration.transformer import (
from onyx.configs.app_configs import LOG_VESPA_TIMING_INFORMATION
from onyx.configs.app_configs import VESPA_LANGUAGE_OVERRIDE
from onyx.configs.app_configs import VESPA_MIGRATION_REQUEST_TIMEOUT_S
from onyx.configs.app_configs import VESPA_MIGRATION_SERVER_SIDE_REQUEST_TIMEOUT
from onyx.context.search.models import IndexFilters
from onyx.context.search.models import InferenceChunkUncleaned
from onyx.document_index.interfaces import VespaChunkRequest
@@ -335,6 +336,11 @@ def get_all_chunks_paginated(
"format.tensors": "short-value",
"slices": total_slices,
"sliceId": slice_id,
# When exceeded, Vespa should return gracefully with partial
# results. Even if no hits are returned, Vespa should still return a
# new continuation token representing a new spot in the linear
# traversal.
"timeout": VESPA_MIGRATION_SERVER_SIDE_REQUEST_TIMEOUT,
}
if continuation_token is not None:
params["continuation"] = continuation_token
@@ -343,6 +349,9 @@ def get_all_chunks_paginated(
start_time = time.monotonic()
try:
with get_vespa_http_client(
# When exceeded, an exception is raised in our code. No progress
# is saved, and the task will retry this spot in the traversal
# later.
timeout=VESPA_MIGRATION_REQUEST_TIMEOUT_S
) as http_client:
response = http_client.get(url, params=params)

View File

@@ -1,3 +1,4 @@
import csv
import gc
import io
import json
@@ -19,6 +20,7 @@ from zipfile import BadZipFile
import chardet
import openpyxl
from openpyxl.worksheet.worksheet import Worksheet
from PIL import Image
from onyx.configs.constants import ONYX_METADATA_FILENAME
@@ -353,6 +355,94 @@ def pptx_to_text(file: IO[Any], file_name: str = "") -> str:
return presentation.markdown
def _worksheet_to_matrix(
worksheet: Worksheet,
) -> list[list[str]]:
"""
Converts a singular worksheet to a matrix of values
"""
rows: list[list[str]] = []
for worksheet_row in worksheet.iter_rows(min_row=1, values_only=True):
row = ["" if cell is None else str(cell) for cell in worksheet_row]
rows.append(row)
return rows
def _clean_worksheet_matrix(matrix: list[list[str]]) -> list[list[str]]:
"""
Cleans a worksheet matrix by removing rows if there are N consecutive empty
rows and removing cols if there are M consecutive empty columns
"""
MAX_EMPTY_ROWS = 2 # Runs longer than this are capped to max_empty; shorter runs are preserved as-is
MAX_EMPTY_COLS = 2
# Row cleanup
matrix = _remove_empty_runs(matrix, max_empty=MAX_EMPTY_ROWS)
if not matrix:
return matrix
# Column cleanup — determine which columns to keep without transposing.
num_cols = len(matrix[0])
keep_cols = _columns_to_keep(matrix, num_cols, max_empty=MAX_EMPTY_COLS)
if len(keep_cols) < num_cols:
matrix = [[row[c] for c in keep_cols] for row in matrix]
return matrix
def _columns_to_keep(
matrix: list[list[str]], num_cols: int, max_empty: int
) -> list[int]:
"""Return the indices of columns to keep after removing empty-column runs.
Uses the same logic as ``_remove_empty_runs`` but operates on column
indices so no transpose is needed.
"""
kept: list[int] = []
empty_buffer: list[int] = []
for col_idx in range(num_cols):
col_is_empty = all(not row[col_idx] for row in matrix)
if col_is_empty:
empty_buffer.append(col_idx)
else:
kept.extend(empty_buffer[:max_empty])
kept.append(col_idx)
empty_buffer = []
return kept
def _remove_empty_runs(
rows: list[list[str]],
max_empty: int,
) -> list[list[str]]:
"""Removes entire runs of empty rows when the run length exceeds max_empty.
Leading empty runs are capped to max_empty, just like interior runs.
Trailing empty rows are always dropped since there is no subsequent
non-empty row to flush them.
"""
result: list[list[str]] = []
empty_buffer: list[list[str]] = []
for row in rows:
# Check if empty
if not any(row):
if len(empty_buffer) < max_empty:
empty_buffer.append(row)
else:
# Add upto max empty rows onto the result - that's what we allow
result.extend(empty_buffer[:max_empty])
# Add the new non-empty row
result.append(row)
empty_buffer = []
return result
def xlsx_to_text(file: IO[Any], file_name: str = "") -> str:
# TODO: switch back to this approach in a few months when markitdown
# fixes their handling of excel files
@@ -391,30 +481,15 @@ def xlsx_to_text(file: IO[Any], file_name: str = "") -> str:
f"Failed to extract text from {file_name or 'xlsx file'}. This happens due to a bug in openpyxl. {e}"
)
return ""
raise e
raise
text_content = []
for sheet in workbook.worksheets:
rows = []
num_empty_consecutive_rows = 0
for row in sheet.iter_rows(min_row=1, values_only=True):
row_str = ",".join(str(cell or "") for cell in row)
# Only add the row if there are any values in the cells
if len(row_str) >= len(row):
rows.append(row_str)
num_empty_consecutive_rows = 0
else:
num_empty_consecutive_rows += 1
if num_empty_consecutive_rows > 100:
# handle massive excel sheets with mostly empty cells
logger.warning(
f"Found {num_empty_consecutive_rows} empty rows in {file_name}, skipping rest of file"
)
break
sheet_str = "\n".join(rows)
text_content.append(sheet_str)
sheet_matrix = _clean_worksheet_matrix(_worksheet_to_matrix(sheet))
buf = io.StringIO()
writer = csv.writer(buf, lineterminator="\n")
writer.writerows(sheet_matrix)
text_content.append(buf.getvalue().rstrip("\n"))
return TEXT_SECTION_SEPARATOR.join(text_content)

View File

@@ -1,33 +1,114 @@
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
# TODO(Bo-Onyx): update later
docs_url = "https://docs.google.com/document/d/1pGhB8Wcnhhj8rS4baEJL6CX05yFhuIDNk1gbBRiWu94/edit?tab=t.ue263ual5vdi"
docs_url = "https://docs.onyx.app/admins/advanced_configs/hook_extensions#document-ingestion"
payload_model = DocumentIngestionPayload
response_model = DocumentIngestionResponse

View File

@@ -65,8 +65,9 @@ class QueryProcessingSpec(HookPointSpec):
"The query will be blocked and the user will see an error message."
)
default_fail_strategy = HookFailStrategy.HARD
# TODO(Bo-Onyx): update later
docs_url = "https://docs.google.com/document/d/1pGhB8Wcnhhj8rS4baEJL6CX05yFhuIDNk1gbBRiWu94/edit?tab=t.g2r1a1699u87"
docs_url = (
"https://docs.onyx.app/admins/advanced_configs/hook_extensions#query-processing"
)
payload_model = QueryProcessingPayload
response_model = QueryProcessingResponse

View File

@@ -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,

View File

@@ -147,6 +147,7 @@ class UserInfo(BaseModel):
is_anonymous_user: bool | None = None,
tenant_info: TenantInfo | None = None,
assistant_specific_configs: UserSpecificAssistantPreferences | None = None,
memories: list[MemoryItem] | None = None,
) -> "UserInfo":
return cls(
id=str(user.id),
@@ -191,10 +192,7 @@ class UserInfo(BaseModel):
role=user.personal_role or "",
use_memories=user.use_memories,
enable_memory_tool=user.enable_memory_tool,
memories=[
MemoryItem(id=memory.id, content=memory.memory_text)
for memory in (user.memories or [])
],
memories=memories or [],
user_preferences=user.user_preferences or "",
),
)

View File

@@ -57,6 +57,7 @@ from onyx.db.user_preferences import activate_user
from onyx.db.user_preferences import deactivate_user
from onyx.db.user_preferences import get_all_user_assistant_specific_configs
from onyx.db.user_preferences import get_latest_access_token_for_user
from onyx.db.user_preferences import get_memories_for_user
from onyx.db.user_preferences import update_assistant_preferences
from onyx.db.user_preferences import update_user_assistant_visibility
from onyx.db.user_preferences import update_user_auto_scroll
@@ -823,6 +824,11 @@ def verify_user_logged_in(
[],
),
)
memories = [
MemoryItem(id=memory.id, content=memory.memory_text)
for memory in get_memories_for_user(user.id, db_session)
]
user_info = UserInfo.from_model(
user,
current_token_created_at=token_created_at,
@@ -833,6 +839,7 @@ def verify_user_logged_in(
new_tenant=new_tenant,
invitation=tenant_invitation,
),
memories=memories,
)
return user_info
@@ -930,7 +937,8 @@ def update_user_personalization_api(
else user.enable_memory_tool
)
existing_memories = [
MemoryItem(id=memory.id, content=memory.memory_text) for memory in user.memories
MemoryItem(id=memory.id, content=memory.memory_text)
for memory in get_memories_for_user(user.id, db_session)
]
new_memories = (
request.memories if request.memories is not None else existing_memories

View File

@@ -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

View File

@@ -0,0 +1,198 @@
import io
from typing import cast
import openpyxl
from openpyxl.worksheet.worksheet import Worksheet
from onyx.file_processing.extract_file_text import xlsx_to_text
def _make_xlsx(sheets: dict[str, list[list[str]]]) -> io.BytesIO:
"""Create an in-memory xlsx file from a dict of sheet_name -> matrix of strings."""
wb = openpyxl.Workbook()
if wb.active is not None:
wb.remove(cast(Worksheet, wb.active))
for sheet_name, rows in sheets.items():
ws = wb.create_sheet(title=sheet_name)
for row in rows:
ws.append(row)
buf = io.BytesIO()
wb.save(buf)
buf.seek(0)
return buf
class TestXlsxToText:
def test_single_sheet_basic(self) -> None:
xlsx = _make_xlsx(
{
"Sheet1": [
["Name", "Age"],
["Alice", "30"],
["Bob", "25"],
]
}
)
result = xlsx_to_text(xlsx)
lines = [line for line in result.strip().split("\n") if line.strip()]
assert len(lines) == 3
assert "Name" in lines[0]
assert "Age" in lines[0]
assert "Alice" in lines[1]
assert "30" in lines[1]
assert "Bob" in lines[2]
def test_multiple_sheets_separated(self) -> None:
xlsx = _make_xlsx(
{
"Sheet1": [["a", "b"]],
"Sheet2": [["c", "d"]],
}
)
result = xlsx_to_text(xlsx)
# TEXT_SECTION_SEPARATOR is "\n\n"
assert "\n\n" in result
parts = result.split("\n\n")
assert any("a" in p for p in parts)
assert any("c" in p for p in parts)
def test_empty_cells(self) -> None:
xlsx = _make_xlsx(
{
"Sheet1": [
["a", "", "b"],
["", "c", ""],
]
}
)
result = xlsx_to_text(xlsx)
lines = [line for line in result.strip().split("\n") if line.strip()]
assert len(lines) == 2
def test_commas_in_cells_are_quoted(self) -> None:
"""Cells containing commas should be quoted in CSV output."""
xlsx = _make_xlsx(
{
"Sheet1": [
["hello, world", "normal"],
]
}
)
result = xlsx_to_text(xlsx)
assert '"hello, world"' in result
def test_empty_workbook(self) -> None:
xlsx = _make_xlsx({"Sheet1": []})
result = xlsx_to_text(xlsx)
assert result.strip() == ""
def test_long_empty_row_run_capped(self) -> None:
"""Runs of >2 empty rows should be capped to 2."""
xlsx = _make_xlsx(
{
"Sheet1": [
["header"],
[""],
[""],
[""],
[""],
["data"],
]
}
)
result = xlsx_to_text(xlsx)
lines = [line for line in result.strip().split("\n") if line.strip()]
# 4 empty rows capped to 2, so: header + 2 empty + data = 4 lines
assert len(lines) == 4
assert "header" in lines[0]
assert "data" in lines[-1]
def test_long_empty_col_run_capped(self) -> None:
"""Runs of >2 empty columns should be capped to 2."""
xlsx = _make_xlsx(
{
"Sheet1": [
["a", "", "", "", "b"],
["c", "", "", "", "d"],
]
}
)
result = xlsx_to_text(xlsx)
lines = [line for line in result.strip().split("\n") if line.strip()]
assert len(lines) == 2
# Each row should have 4 fields (a + 2 empty + b), not 5
# csv format: a,,,b (3 commas = 4 fields)
first_line = lines[0].strip()
# Count commas to verify column reduction
assert first_line.count(",") == 3
def test_short_empty_runs_kept(self) -> None:
"""Runs of <=2 empty rows/cols should be preserved."""
xlsx = _make_xlsx(
{
"Sheet1": [
["a", "b"],
["", ""],
["", ""],
["c", "d"],
]
}
)
result = xlsx_to_text(xlsx)
lines = [line for line in result.strip().split("\n") if line.strip()]
# All 4 rows preserved (2 empty rows <= threshold)
assert len(lines) == 4
def test_bad_zip_file_returns_empty(self) -> None:
bad_file = io.BytesIO(b"not a zip file")
result = xlsx_to_text(bad_file, file_name="test.xlsx")
assert result == ""
def test_bad_zip_tilde_file_returns_empty(self) -> None:
bad_file = io.BytesIO(b"not a zip file")
result = xlsx_to_text(bad_file, file_name="~$temp.xlsx")
assert result == ""
def test_large_sparse_sheet(self) -> None:
"""A sheet with data, a big empty gap, and more data — gap is capped to 2."""
rows: list[list[str]] = [["row1_data"]]
rows.extend([[""] for _ in range(10)])
rows.append(["row2_data"])
xlsx = _make_xlsx({"Sheet1": rows})
result = xlsx_to_text(xlsx)
lines = [line for line in result.strip().split("\n") if line.strip()]
# 10 empty rows capped to 2: row1_data + 2 empty + row2_data = 4
assert len(lines) == 4
assert "row1_data" in lines[0]
assert "row2_data" in lines[-1]
def test_quotes_in_cells(self) -> None:
"""Cells containing quotes should be properly escaped."""
xlsx = _make_xlsx(
{
"Sheet1": [
['say "hello"', "normal"],
]
}
)
result = xlsx_to_text(xlsx)
# csv.writer escapes quotes by doubling them
assert '""hello""' in result
def test_each_row_is_separate_line(self) -> None:
"""Each row should produce its own line (regression for writerow vs writerows)."""
xlsx = _make_xlsx(
{
"Sheet1": [
["r1c1", "r1c2"],
["r2c1", "r2c2"],
["r3c1", "r3c2"],
]
}
)
result = xlsx_to_text(xlsx)
lines = [line for line in result.strip().split("\n") if line.strip()]
assert len(lines) == 3
assert "r1c1" in lines[0] and "r1c2" in lines[0]
assert "r2c1" in lines[1] and "r2c2" in lines[1]
assert "r3c1" in lines[2] and "r3c2" in lines[2]

View File

@@ -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"

View File

@@ -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.**

View File

@@ -1,9 +1,5 @@
import "@opal/components/tooltip.css";
import {
Disabled,
Interactive,
type InteractiveStatelessProps,
} from "@opal/core";
import { Interactive, type InteractiveStatelessProps } from "@opal/core";
import type {
ContainerSizeVariants,
ExtremaSizeVariants,
@@ -49,7 +45,7 @@ type ButtonProps = InteractiveStatelessProps &
/** Which side the tooltip appears on. */
tooltipSide?: TooltipSide;
/** Wraps the button in a Disabled context. `false` overrides parent contexts. */
/** Applies disabled styling and suppresses clicks. */
disabled?: boolean;
};
@@ -94,7 +90,11 @@ function Button({
) : null;
const button = (
<Interactive.Stateless type={type} {...interactiveProps}>
<Interactive.Stateless
type={type}
disabled={disabled}
{...interactiveProps}
>
<Interactive.Container
type={type}
border={interactiveProps.prominence === "secondary"}
@@ -118,28 +118,24 @@ function Button({
</Interactive.Stateless>
);
const result = tooltip ? (
<TooltipPrimitive.Root>
<TooltipPrimitive.Trigger asChild>{button}</TooltipPrimitive.Trigger>
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
className="opal-tooltip"
side={tooltipSide}
sideOffset={4}
>
{tooltip}
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
</TooltipPrimitive.Root>
) : (
button
);
if (disabled != null) {
return <Disabled disabled={disabled}>{result}</Disabled>;
if (tooltip) {
return (
<TooltipPrimitive.Root>
<TooltipPrimitive.Trigger asChild>{button}</TooltipPrimitive.Trigger>
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
className="opal-tooltip"
side={tooltipSide}
sideOffset={4}
>
{tooltip}
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
</TooltipPrimitive.Root>
);
}
return result;
return button;
}
export { Button, type ButtonProps };

View File

@@ -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}

View File

@@ -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>
```

View File

@@ -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>

View File

@@ -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>

View File

@@ -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." />
```

View File

@@ -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}

View File

@@ -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"

View File

@@ -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>

View File

@@ -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>

View File

@@ -2,6 +2,7 @@ import React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cn } from "@opal/utils";
import { useDisabled } from "@opal/core/disabled/components";
import { guardPortalClick } from "@opal/core/interactive/utils";
// ---------------------------------------------------------------------------
// Types
@@ -91,7 +92,7 @@ function InteractiveSimple({
? href
? (e: React.MouseEvent) => e.preventDefault()
: undefined
: onClick
: guardPortalClick(onClick)
}
/>
);

View File

@@ -4,6 +4,7 @@ import React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cn } from "@opal/utils";
import { useDisabled } from "@opal/core/disabled/components";
import { guardPortalClick } from "@opal/core/interactive/utils";
import type { ButtonType, WithoutStyles } from "@opal/types";
// ---------------------------------------------------------------------------
@@ -153,7 +154,7 @@ function InteractiveStateful({
? href
? (e: React.MouseEvent) => e.preventDefault()
: undefined
: onClick
: guardPortalClick(onClick)
}
/>
);

View File

@@ -3,7 +3,7 @@ import "@opal/core/interactive/stateless/styles.css";
import React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cn } from "@opal/utils";
import { useDisabled } from "@opal/core/disabled/components";
import { guardPortalClick } from "@opal/core/interactive/utils";
import type { ButtonType, WithoutStyles } from "@opal/types";
// ---------------------------------------------------------------------------
@@ -69,6 +69,11 @@ interface InteractiveStatelessProps
* Link target (e.g. `"_blank"`). Only used when `href` is provided.
*/
target?: string;
/**
* Applies variant-specific disabled colors and suppresses clicks.
*/
disabled?: boolean;
}
// ---------------------------------------------------------------------------
@@ -83,8 +88,7 @@ interface InteractiveStatelessProps
* color styling via CSS data-attributes and merges onto a single child
* element via Radix `Slot`.
*
* Disabled state is consumed from the nearest `<Disabled>` ancestor via
* context — there is no `disabled` prop on this component.
* Disabled state is controlled via the `disabled` prop.
*/
function InteractiveStateless({
ref,
@@ -95,9 +99,10 @@ function InteractiveStateless({
type,
href,
target,
disabled,
...props
}: InteractiveStatelessProps) {
const { isDisabled, allowClick } = useDisabled();
const isDisabled = !!disabled;
// onClick/href are always passed directly — Stateless is the outermost Slot,
// so Radix Slot-injected handlers don't bypass this guard.
@@ -133,11 +138,11 @@ function InteractiveStateless({
{...linkAttrs}
{...slotProps}
onClick={
isDisabled && !allowClick
isDisabled
? href
? (e: React.MouseEvent) => e.preventDefault()
: undefined
: onClick
: guardPortalClick(onClick)
}
/>
);

View File

@@ -0,0 +1,28 @@
import type React from "react";
/**
* Guards an onClick handler against React synthetic event bubbling from
* portalled children (e.g. Radix Dialog overlays).
*
* React bubbles synthetic events through the **fiber tree** (component
* hierarchy), not the DOM tree. This means a click on a portalled modal
* overlay will bubble to a parent component's onClick even though the
* overlay is not a DOM descendant. This guard checks that the click
* target is actually inside the handler's DOM element before firing.
*/
function guardPortalClick<E extends React.MouseEvent>(
onClick: ((e: E) => void) | undefined
): ((e: E) => void) | undefined {
if (!onClick) return undefined;
return (e: E) => {
if (
e.currentTarget instanceof Node &&
e.target instanceof Node &&
e.currentTarget.contains(e.target)
) {
onClick(e);
}
};
}
export { guardPortalClick };

View File

@@ -92,7 +92,7 @@ export { default as SvgHashSmall } from "@opal/icons/hash-small";
export { default as SvgHash } from "@opal/icons/hash";
export { default as SvgHeadsetMic } from "@opal/icons/headset-mic";
export { default as SvgHistory } from "@opal/icons/history";
export { default as SvgHookNodes } from "@opal/icons/hook-nodes";
export { default as SvgShareWebhook } from "@opal/icons/share-webhook";
export { default as SvgHourglass } from "@opal/icons/hourglass";
export { default as SvgImage } from "@opal/icons/image";
export { default as SvgImageSmall } from "@opal/icons/image-small";

View File

@@ -1,6 +1,6 @@
import type { IconProps } from "@opal/types";
const SvgHookNodes = ({ size, ...props }: IconProps) => (
const SvgShareWebhook = ({ size, ...props }: IconProps) => (
<svg
width={size}
height={size}
@@ -18,4 +18,4 @@ const SvgHookNodes = ({ size, ...props }: IconProps) => (
/>
</svg>
);
export default SvgHookNodes;
export default SvgShareWebhook;

View File

@@ -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,
};

View File

@@ -8,7 +8,6 @@ import * as InputLayouts from "@/layouts/input-layouts";
import Card from "@/refresh-components/cards/Card";
import Button from "@/refresh-components/buttons/Button";
import { Button as OpalButton } from "@opal/components";
import { Disabled } from "@opal/core";
import Text from "@/refresh-components/texts/Text";
import Message from "@/refresh-components/messages/Message";
import InfoBlock from "@/refresh-components/messages/InfoBlock";
@@ -246,15 +245,14 @@ function SubscriptionCard({
to make changes.
</Text>
) : disabled ? (
<Disabled disabled={isReconnecting}>
<OpalButton
prominence="secondary"
onClick={handleReconnect}
rightIcon={SvgArrowRight}
>
{isReconnecting ? "Connecting..." : "Connect to Stripe"}
</OpalButton>
</Disabled>
<OpalButton
disabled={isReconnecting}
prominence="secondary"
onClick={handleReconnect}
rightIcon={SvgArrowRight}
>
{isReconnecting ? "Connecting..." : "Connect to Stripe"}
</OpalButton>
) : (
<OpalButton onClick={handleManagePlan} rightIcon={SvgExternalLink}>
Manage Plan
@@ -377,11 +375,13 @@ function SeatsCard({
sizePreset="main-content"
variant="section"
/>
<Disabled disabled={isSubmitting}>
<OpalButton prominence="secondary" onClick={handleCancel}>
Cancel
</OpalButton>
</Disabled>
<OpalButton
disabled={isSubmitting}
prominence="secondary"
onClick={handleCancel}
>
Cancel
</OpalButton>
</Section>
<div className="billing-content-area">
@@ -463,15 +463,14 @@ function SeatsCard({
No changes to your billing.
</Text>
)}
<Disabled
<OpalButton
disabled={
isSubmitting || newSeatCount === totalSeats || isBelowMinimum
}
onClick={handleConfirm}
>
<OpalButton onClick={handleConfirm}>
{isSubmitting ? "Saving..." : "Confirm Change"}
</OpalButton>
</Disabled>
{isSubmitting ? "Saving..." : "Confirm Change"}
</OpalButton>
</Section>
</Card>
);
@@ -509,15 +508,14 @@ function SeatsCard({
View Users
</OpalButton>
{!hideUpdateSeats && (
<Disabled disabled={isLoadingUsers || disabled || !billing}>
<OpalButton
prominence="secondary"
onClick={handleStartEdit}
icon={SvgPlus}
>
Update Seats
</OpalButton>
</Disabled>
<OpalButton
disabled={isLoadingUsers || disabled || !billing}
prominence="secondary"
onClick={handleStartEdit}
icon={SvgPlus}
>
Update Seats
</OpalButton>
)}
</Section>
</Section>

View File

@@ -4,7 +4,6 @@ import { useState, useMemo, useEffect } from "react";
import { Section } from "@/layouts/general-layouts";
import * as InputLayouts from "@/layouts/input-layouts";
import { Button } from "@opal/components";
import { Disabled } from "@opal/core";
import Text from "@/refresh-components/texts/Text";
import Card from "@/refresh-components/cards/Card";
import Separator from "@/refresh-components/Separator";
@@ -263,11 +262,9 @@ export default function CheckoutView({ onAdjustPlan }: CheckoutViewProps) {
// Empty div to maintain space-between alignment
<div></div>
)}
<Disabled disabled={isSubmitting}>
<Button onClick={handleSubmit}>
{isSubmitting ? "Loading..." : "Continue to Payment"}
</Button>
</Disabled>
<Button disabled={isSubmitting} onClick={handleSubmit}>
{isSubmitting ? "Loading..." : "Continue to Payment"}
</Button>
</Section>
</Card>
);

View File

@@ -3,7 +3,6 @@
import { useState } from "react";
import Card from "@/refresh-components/cards/Card";
import { Button } from "@opal/components";
import { Disabled } from "@opal/core";
import Text from "@/refresh-components/texts/Text";
import InputFile from "@/refresh-components/inputs/InputFile";
import { Section } from "@/layouts/general-layouts";
@@ -147,11 +146,13 @@ export default function LicenseActivationCard({
<Text headingH3>
{hasLicense ? "Update License Key" : "Activate License Key"}
</Text>
<Disabled disabled={isActivating}>
<Button prominence="secondary" onClick={handleClose}>
Cancel
</Button>
</Disabled>
<Button
disabled={isActivating}
prominence="secondary"
onClick={handleClose}
>
Cancel
</Button>
</Section>
<Text secondaryBody text03>
Manually add and activate a license for this Onyx instance.
@@ -221,15 +222,16 @@ export default function LicenseActivationCard({
{/* Footer */}
<Section flexDirection="row" justifyContent="end" padding={1}>
<Disabled disabled={isActivating || !licenseKey.trim() || success}>
<Button onClick={handleActivate}>
{isActivating
? "Activating..."
: hasLicense
? "Update License"
: "Activate License"}
</Button>
</Disabled>
<Button
disabled={isActivating || !licenseKey.trim() || success}
onClick={handleActivate}
>
{isActivating
? "Activating..."
: hasLicense
? "Update License"
: "Activate License"}
</Button>
</Section>
</Card>
);

View File

@@ -7,6 +7,7 @@ import {
SvgGlobe,
SvgHardDrive,
SvgHeadsetMic,
SvgShareWebhook,
SvgKey,
SvgLock,
SvgPaintBrush,
@@ -63,6 +64,7 @@ const BUSINESS_FEATURES: PlanFeature[] = [
{ icon: SvgKey, text: "Service Account API Keys" },
{ icon: SvgHardDrive, text: "Self-hosting (Optional)" },
{ icon: SvgPaintBrush, text: "Custom Theming" },
{ icon: SvgShareWebhook, text: "Hook Extensions" },
];
const ENTERPRISE_FEATURES: PlanFeature[] = [

View File

@@ -5,7 +5,6 @@ import { Form, Formik } from "formik";
import * as Yup from "yup";
import { createSlackBot, updateSlackBot } from "./new/lib";
import { Button } from "@opal/components";
import { Disabled } from "@opal/core";
import Separator from "@/refresh-components/Separator";
import { useEffect } from "react";
import { DOCS_ADMINS_PATH } from "@/lib/constants";
@@ -127,16 +126,17 @@ export const SlackTokensForm = ({
subtext="Optional: User OAuth token for enhanced private channel access"
/>
<div className="flex justify-end w-full mt-4">
<Disabled
<Button
disabled={
isSubmitting ||
!values.bot_token ||
!values.app_token ||
!values.name
}
type="submit"
>
<Button type="submit">{isUpdate ? "Update" : "Create"}</Button>
</Disabled>
{isUpdate ? "Update" : "Create"}
</Button>
</div>
</Form>
)}

View File

@@ -6,7 +6,6 @@ import { ManualErrorMessage, TextFormField } from "@/components/Field";
import { useEffect, useState } from "react";
import CreateButton from "@/refresh-components/buttons/CreateButton";
import { Button } from "@opal/components";
import { Disabled } from "@opal/core";
import { SvgX } from "@opal/icons";
import Text from "@/refresh-components/texts/Text";
@@ -56,20 +55,17 @@ function ModelConfigurationRow({
/>
</div>
<div className="flex flex-col justify-center">
<Disabled
<Button
disabled={formikProps.values.model_configurations.length <= 1}
>
<Button
onClick={() => {
if (formikProps.values.model_configurations.length > 1) {
setError(null);
arrayHelpers.remove(index);
}
}}
icon={SvgX}
prominence="secondary"
/>
</Disabled>
onClick={() => {
if (formikProps.values.model_configurations.length > 1) {
setError(null);
arrayHelpers.remove(index);
}
}}
icon={SvgX}
prominence="secondary"
/>
</div>
</div>
);

View File

@@ -2,7 +2,6 @@
import { useState, useRef } from "react";
import { Button } from "@opal/components";
import { Disabled } from "@opal/core";
import {
Table,
TableBody,
@@ -184,25 +183,24 @@ export default function InlineFileManagement({
</Button>
) : (
<>
<Disabled disabled={isSaving}>
<Button
prominence="secondary"
onClick={handleCancel}
icon={SvgX}
>
Cancel
</Button>
</Disabled>
<Disabled
<Button
disabled={isSaving}
prominence="secondary"
onClick={handleCancel}
icon={SvgX}
>
Cancel
</Button>
<Button
disabled={
isSaving ||
(selectedFilesToRemove.size === 0 && filesToAdd.length === 0)
}
onClick={handleSaveClick}
icon={SvgCheck}
>
<Button onClick={handleSaveClick} icon={SvgCheck}>
{isSaving ? "Saving..." : "Save Changes"}
</Button>
</Disabled>
{isSaving ? "Saving..." : "Save Changes"}
</Button>
</>
)}
</div>
@@ -334,15 +332,14 @@ export default function InlineFileManagement({
className="hidden"
id={`file-upload-${connectorId}`}
/>
<Disabled disabled={isSaving}>
<Button
prominence="secondary"
onClick={() => fileInputRef.current?.click()}
icon={SvgPlusCircle}
>
Add Files
</Button>
</Disabled>
<Button
disabled={isSaving}
prominence="secondary"
onClick={() => fileInputRef.current?.click()}
icon={SvgPlusCircle}
>
Add Files
</Button>
</div>
)}
@@ -398,19 +395,16 @@ export default function InlineFileManagement({
</Modal.Body>
<Modal.Footer>
<Disabled disabled={isSaving}>
<Button
prominence="secondary"
onClick={() => setShowSaveConfirm(false)}
>
Cancel
</Button>
</Disabled>
<Disabled disabled={isSaving}>
<Button onClick={handleConfirmSave}>
{isSaving ? "Saving..." : "Confirm & Save"}
</Button>
</Disabled>
<Button
disabled={isSaving}
prominence="secondary"
onClick={() => setShowSaveConfirm(false)}
>
Cancel
</Button>
<Button disabled={isSaving} onClick={handleConfirmSave}>
{isSaving ? "Saving..." : "Confirm & Save"}
</Button>
</Modal.Footer>
</Modal.Content>
</Modal>

View File

@@ -1,7 +1,6 @@
"use client";
import { Button } from "@opal/components";
import { Disabled } from "@opal/core";
import { useState } from "react";
import { toast } from "@/hooks/useToast";
import { triggerIndexing } from "@/app/admin/connector/[ccPairId]/lib";
@@ -116,9 +115,9 @@ export default function ReIndexModal({ hide, onRunIndex }: ReIndexModalProps) {
This will pull in and index all documents that have changed and/or
have been added since the last successful indexing run.
</Text>
<Disabled disabled={isProcessing}>
<Button onClick={() => handleRunIndex(false)}>Run Update</Button>
</Disabled>
<Button disabled={isProcessing} onClick={() => handleRunIndex(false)}>
Run Update
</Button>
<Separator />
@@ -131,11 +130,9 @@ export default function ReIndexModal({ hide, onRunIndex }: ReIndexModalProps) {
in the source, this may take a long time.
</Text>
<Disabled disabled={isProcessing}>
<Button onClick={() => handleRunIndex(true)}>
Run Complete Re-Indexing
</Button>
</Disabled>
<Button disabled={isProcessing} onClick={() => handleRunIndex(true)}>
Run Complete Re-Indexing
</Button>
</Modal.Body>
</Modal.Content>
</Modal>

View File

@@ -56,7 +56,6 @@ import {
import { CreateStdOAuthCredential } from "@/components/credentials/actions/CreateStdOAuthCredential";
import { Spinner } from "@/components/Spinner";
import { Button } from "@opal/components";
import { Disabled } from "@opal/core";
import { deleteConnector } from "@/lib/connector";
import ConnectorDocsLink from "@/components/admin/connectors/ConnectorDocsLink";
import Text from "@/refresh-components/texts/Text";
@@ -580,19 +579,18 @@ export default function AddConnector({
{/* Button to sign in via OAuth */}
{oauthSupportedSources.includes(connector) &&
(NEXT_PUBLIC_CLOUD_ENABLED || NEXT_PUBLIC_TEST_ENV) && (
<Disabled disabled={isAuthorizing}>
<Button
variant="action"
onClick={handleAuthorize}
hidden={!isAuthorizeVisible}
>
{isAuthorizing
? "Authorizing..."
: `Authorize with ${getSourceDisplayName(
connector
)}`}
</Button>
</Disabled>
<Button
disabled={isAuthorizing}
variant="action"
onClick={handleAuthorize}
hidden={!isAuthorizeVisible}
>
{isAuthorizing
? "Authorizing..."
: `Authorize with ${getSourceDisplayName(
connector
)}`}
</Button>
)}
</div>
)}

View File

@@ -1,6 +1,5 @@
import { useFormContext } from "@/components/context/FormContext";
import { Button } from "@opal/components";
import { Disabled } from "@opal/core";
import { SvgArrowLeft, SvgArrowRight, SvgPlusCircle } from "@opal/icons";
const NavigationRow = ({
@@ -34,35 +33,35 @@ const NavigationRow = ({
</div>
<div className="flex justify-center">
{(formStep > 0 || noCredentials) && (
<Disabled disabled={!isValid}>
<Button rightIcon={SvgPlusCircle} onClick={onSubmit}>
Create Connector
</Button>
</Disabled>
<Button
disabled={!isValid}
rightIcon={SvgPlusCircle}
onClick={onSubmit}
>
Create Connector
</Button>
)}
</div>
<div className="flex justify-end">
{formStep === 0 && (
<Disabled disabled={!activatedCredential}>
<Button
variant="action"
rightIcon={SvgArrowRight}
onClick={() => nextFormStep()}
>
Continue
</Button>
</Disabled>
<Button
disabled={!activatedCredential}
variant="action"
rightIcon={SvgArrowRight}
onClick={() => nextFormStep()}
>
Continue
</Button>
)}
{!noAdvanced && formStep === 1 && (
<Disabled disabled={!isValid}>
<Button
prominence="secondary"
rightIcon={SvgArrowRight}
onClick={() => nextFormStep()}
>
Advanced
</Button>
</Disabled>
<Button
disabled={!isValid}
prominence="secondary"
rightIcon={SvgArrowRight}
onClick={() => nextFormStep()}
>
Advanced
</Button>
)}
</div>
</div>

View File

@@ -4,7 +4,6 @@ import { useEffect, useState } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { AdminPageTitle } from "@/components/admin/Title";
import { Button } from "@opal/components";
import { Disabled } from "@opal/core";
import { getSourceMetadata, isValidSource } from "@/lib/sources";
import { ConfluenceAccessibleResource, ValidSources } from "@/lib/types";
import CardSection from "@/components/admin/CardSection";
@@ -260,11 +259,9 @@ export default function OAuthFinalizePage() {
)}
<br />
{!redirectUrl && (
<Disabled disabled={!isValid || isSubmitting}>
<Button type="submit">
{isSubmitting ? "Submitting..." : "Submit"}
</Button>
</Disabled>
<Button disabled={!isValid || isSubmitting} type="submit">
{isSubmitting ? "Submitting..." : "Submit"}
</Button>
)}
</Form>
)}

View File

@@ -11,7 +11,6 @@ import { TextFormField, SectionHeader } from "@/components/Field";
import { Form, Formik } from "formik";
import { User } from "@/lib/types";
import { Button } from "@opal/components";
import { Disabled } from "@opal/core";
import {
Credential,
GoogleDriveCredentialJson,
@@ -563,11 +562,9 @@ export const DriveAuthSection = ({
subtext="Enter the email of an admin/owner of the Google Organization that owns the Google Drive(s) you want to index."
/>
<div className="flex">
<Disabled disabled={isSubmitting}>
<Button type="submit">
{isSubmitting ? "Creating..." : "Create Credential"}
</Button>
</Disabled>
<Button disabled={isSubmitting} type="submit">
{isSubmitting ? "Creating..." : "Create Credential"}
</Button>
</div>
</Form>
)}
@@ -587,35 +584,34 @@ export const DriveAuthSection = ({
Google Drive account.
</p>
</div>
<Disabled disabled={isAuthenticating}>
<Button
onClick={async () => {
setIsAuthenticating(true);
try {
const [authUrl, errorMsg] = await setupGoogleDriveOAuth({
isAdmin: true,
name: "OAuth (uploaded)",
});
<Button
disabled={isAuthenticating}
onClick={async () => {
setIsAuthenticating(true);
try {
const [authUrl, errorMsg] = await setupGoogleDriveOAuth({
isAdmin: true,
name: "OAuth (uploaded)",
});
if (authUrl) {
router.push(authUrl as Route);
} else {
toast.error(errorMsg);
setIsAuthenticating(false);
}
} catch (error) {
toast.error(
`Failed to authenticate with Google Drive - ${error}`
);
if (authUrl) {
router.push(authUrl as Route);
} else {
toast.error(errorMsg);
setIsAuthenticating(false);
}
}}
>
{isAuthenticating
? "Authenticating..."
: "Authenticate with Google Drive"}
</Button>
</Disabled>
} catch (error) {
toast.error(
`Failed to authenticate with Google Drive - ${error}`
);
setIsAuthenticating(false);
}
}}
>
{isAuthenticating
? "Authenticating..."
: "Authenticate with Google Drive"}
</Button>
</div>
);
}

View File

@@ -1,5 +1,4 @@
import { Button } from "@opal/components";
import { Disabled } from "@opal/core";
import { toast } from "@/hooks/useToast";
import React, { useState, useEffect } from "react";
import { useSWRConfig } from "swr";
@@ -570,11 +569,9 @@ export const GmailAuthSection = ({
subtext="Enter the email of an admin/owner of the Google Organization that owns the Gmail account(s) you want to index."
/>
<div className="flex">
<Disabled disabled={isSubmitting}>
<Button type="submit">
{isSubmitting ? "Creating..." : "Create Credential"}
</Button>
</Disabled>
<Button disabled={isSubmitting} type="submit">
{isSubmitting ? "Creating..." : "Create Credential"}
</Button>
</div>
</Form>
)}
@@ -593,36 +590,35 @@ export const GmailAuthSection = ({
read access to the emails you have access to in your Gmail account.
</p>
</div>
<Disabled disabled={isAuthenticating}>
<Button
onClick={async () => {
setIsAuthenticating(true);
try {
if (buildMode) {
Cookies.set(CRAFT_OAUTH_COOKIE_NAME, "true", {
path: "/",
});
}
const [authUrl, errorMsg] = await setupGmailOAuth({
isAdmin: true,
<Button
disabled={isAuthenticating}
onClick={async () => {
setIsAuthenticating(true);
try {
if (buildMode) {
Cookies.set(CRAFT_OAUTH_COOKIE_NAME, "true", {
path: "/",
});
}
const [authUrl, errorMsg] = await setupGmailOAuth({
isAdmin: true,
});
if (authUrl) {
onOAuthRedirect?.();
router.push(authUrl as Route);
} else {
toast.error(errorMsg);
setIsAuthenticating(false);
}
} catch (error) {
toast.error(`Failed to authenticate with Gmail - ${error}`);
if (authUrl) {
onOAuthRedirect?.();
router.push(authUrl as Route);
} else {
toast.error(errorMsg);
setIsAuthenticating(false);
}
}}
>
{isAuthenticating ? "Authenticating..." : "Authenticate with Gmail"}
</Button>
</Disabled>
} catch (error) {
toast.error(`Failed to authenticate with Gmail - ${error}`);
setIsAuthenticating(false);
}
}}
>
{isAuthenticating ? "Authenticating..." : "Authenticate with Gmail"}
</Button>
</div>
);
}

View File

@@ -5,7 +5,6 @@ import { Section } from "@/layouts/general-layouts";
import Text from "@/refresh-components/texts/Text";
import Card from "@/refresh-components/cards/Card";
import { Button } from "@opal/components";
import { Disabled } from "@opal/core";
import { Badge } from "@/components/ui/badge";
import PasswordInputTypeIn from "@/refresh-components/inputs/PasswordInputTypeIn";
import { ThreeDotsLoader } from "@/components/Loading";
@@ -126,14 +125,13 @@ export function BotConfigCard() {
}
disabled={!hasServerConfigs}
>
<Disabled disabled={isSubmitting || hasServerConfigs}>
<Button
variant="danger"
onClick={() => setShowDeleteConfirm(true)}
>
Delete Discord Token
</Button>
</Disabled>
<Button
disabled={isSubmitting || hasServerConfigs}
variant="danger"
onClick={() => setShowDeleteConfirm(true)}
>
Delete Discord Token
</Button>
</SimpleTooltip>
)}
</Section>
@@ -167,11 +165,12 @@ export function BotConfigCard() {
disabled={isSubmitting}
className="flex-1"
/>
<Disabled disabled={isSubmitting || !botToken.trim()}>
<Button onClick={handleSaveToken}>
{isSubmitting ? "Saving..." : "Save Token"}
</Button>
</Disabled>
<Button
disabled={isSubmitting || !botToken.trim()}
onClick={handleSaveToken}
>
{isSubmitting ? "Saving..." : "Save Token"}
</Button>
</Section>
</Section>
)}

View File

@@ -13,7 +13,6 @@ import {
import { Badge } from "@/components/ui/badge";
import { DeleteButton } from "@/components/DeleteButton";
import { Button } from "@opal/components";
import { Disabled } from "@opal/core";
import Switch from "@/refresh-components/inputs/Switch";
import { SvgEdit, SvgServer } from "@opal/icons";
import EmptyMessage from "@/refresh-components/EmptyMessage";
@@ -116,17 +115,14 @@ export function DiscordGuildsTable({ guilds, onRefresh }: Props) {
{guilds.map((guild) => (
<TableRow key={guild.id}>
<TableCell>
<Disabled disabled={!guild.guild_id}>
<Button
prominence="internal"
onClick={() =>
router.push(`/admin/discord-bot/${guild.id}`)
}
icon={SvgEdit}
>
{guild.guild_name || `Server #${guild.id}`}
</Button>
</Disabled>
<Button
disabled={!guild.guild_id}
prominence="internal"
onClick={() => router.push(`/admin/discord-bot/${guild.id}`)}
icon={SvgEdit}
>
{guild.guild_name || `Server #${guild.id}`}
</Button>
</TableCell>
<TableCell>
{guild.guild_id ? (

View File

@@ -13,7 +13,6 @@ import Card from "@/refresh-components/cards/Card";
import { Callout } from "@/components/ui/callout";
import Message from "@/refresh-components/messages/Message";
import { Button } from "@opal/components";
import { Disabled } from "@opal/core";
import { SvgServer } from "@opal/icons";
import InputSelect from "@/refresh-components/inputs/InputSelect";
import {
@@ -105,16 +104,20 @@ function GuildDetailContent({
width="fit"
gap={0.5}
>
<Disabled disabled={disabled}>
<Button prominence="secondary" onClick={handleEnableAll}>
Enable All
</Button>
</Disabled>
<Disabled disabled={disabled}>
<Button prominence="secondary" onClick={handleDisableAll}>
Disable All
</Button>
</Disabled>
<Button
disabled={disabled}
prominence="secondary"
onClick={handleEnableAll}
>
Enable All
</Button>
<Button
disabled={disabled}
prominence="secondary"
onClick={handleDisableAll}
>
Disable All
</Button>
</Section>
) : undefined
}
@@ -335,9 +338,9 @@ export default function Page({ params }: Props) {
description={registeredText}
backButton
rightChildren={
<Disabled disabled={isUpdateDisabled}>
<Button onClick={handleSaveChanges}>Update Configuration</Button>
</Disabled>
<Button disabled={isUpdateDisabled} onClick={handleSaveChanges}>
Update Configuration
</Button>
}
/>
<SettingsLayouts.Body>

View File

@@ -2,7 +2,6 @@ import React, { useRef, useState } from "react";
import Text from "@/refresh-components/texts/Text";
import { Callout } from "@/components/ui/callout";
import { Button } from "@opal/components";
import { Disabled } from "@opal/core";
import { Formik, Form } from "formik";
import * as Yup from "yup";
import { Label, TextFormField } from "@/components/Field";
@@ -297,19 +296,18 @@ export default function ProviderCreationModal({
</Callout>
)}
<Disabled disabled={isSubmitting}>
<Button
type="submit"
width="full"
icon={isSubmitting ? SimpleLoader : undefined}
>
{isSubmitting
? "Submitting"
: existingProvider
? "Update"
: "Create"}
</Button>
</Disabled>
<Button
disabled={isSubmitting}
type="submit"
width="full"
icon={isSubmitting ? SimpleLoader : undefined}
>
{isSubmitting
? "Submitting"
: existingProvider
? "Update"
: "Create"}
</Button>
</Form>
)}
</Formik>

View File

@@ -7,7 +7,6 @@ import { useCallback, useEffect, useMemo, useState, useRef } from "react";
import Text from "@/refresh-components/texts/Text";
import Button from "@/refresh-components/buttons/Button";
import { Button as OpalButton } from "@opal/components";
import { Disabled } from "@opal/core";
import { WarningCircle, Warning, CaretDownIcon } from "@phosphor-icons/react";
import {
CloudEmbeddingModel,
@@ -378,16 +377,15 @@ export default function EmbeddingForm() {
</div>
) : (
<div className="flex mx-auto gap-x-1 ml-auto items-center">
<Disabled disabled={!isOverallFormValid}>
<OpalButton
onClick={() => {
updateSearch();
navigateToEmbeddingPage("search settings");
}}
>
Update Search
</OpalButton>
</Disabled>
<OpalButton
disabled={!isOverallFormValid}
onClick={() => {
updateSearch();
navigateToEmbeddingPage("search settings");
}}
>
Update Search
</OpalButton>
{!isOverallFormValid &&
Object.keys(combinedFormErrors).length > 0 && (
<div className="relative group">

View File

@@ -10,7 +10,6 @@ import {
import * as SettingsLayouts from "@/layouts/settings-layouts";
import Modal from "@/refresh-components/Modal";
import { Button } from "@opal/components";
import { Disabled } from "@opal/core";
import SwitchField from "@/refresh-components/form/SwitchField";
import { Form, Formik, FormikState, useFormikContext } from "formik";
import { useState } from "react";
@@ -201,9 +200,9 @@ function KGConfiguration({
disabled={!props.values.enabled}
/>
</div>
<Disabled disabled={!props.dirty}>
<Button type="submit">Submit</Button>
</Disabled>
<Button disabled={!props.dirty} type="submit">
Submit
</Button>
</div>
</Form>
)}

View File

@@ -2,7 +2,6 @@ import { SvgDownload, SvgKey, SvgRefreshCw } from "@opal/icons";
import { Interactive, Hoverable } from "@opal/core";
import { Section } from "@/layouts/general-layouts";
import { Button } from "@opal/components";
import { Disabled } from "@opal/core";
import Text from "@/refresh-components/texts/Text";
import CopyIconButton from "@/refresh-components/buttons/CopyIconButton";
import InputTextArea from "@/refresh-components/inputs/InputTextArea";
@@ -55,11 +54,13 @@ export default function ScimModal({
title="Regenerate SCIM Token"
onClose={onClose}
submit={
<Disabled disabled={isSubmitting}>
<Button variant="danger" onClick={onRegenerate}>
Regenerate Token
</Button>
</Disabled>
<Button
disabled={isSubmitting}
variant="danger"
onClick={onRegenerate}
>
Regenerate Token
</Button>
}
>
<Section alignItems="start" gap={0.5}>

View File

@@ -3,7 +3,6 @@ import { ContentAction } from "@opal/layouts";
import { Section } from "@/layouts/general-layouts";
import Card from "@/refresh-components/cards/Card";
import { Button } from "@opal/components";
import { Disabled } from "@opal/core";
import Text from "@/refresh-components/texts/Text";
import Separator from "@/refresh-components/Separator";
import { timeAgo } from "@/lib/time";
@@ -54,11 +53,13 @@ export default function ScimSyncCard({
Regenerate Token
</Button>
) : (
<Disabled disabled={isSubmitting}>
<Button rightIcon={SvgKey} onClick={onGenerate}>
Generate SCIM Token
</Button>
</Disabled>
<Button
disabled={isSubmitting}
rightIcon={SvgKey}
onClick={onGenerate}
>
Generate SCIM Token
</Button>
)
}
/>

View File

@@ -2,7 +2,6 @@
import * as Yup from "yup";
import { Button } from "@opal/components";
import { Disabled } from "@opal/core";
import { useEffect, useState } from "react";
import Modal from "@/refresh-components/Modal";
import { Form, Formik } from "formik";
@@ -148,9 +147,9 @@ export default function CreateRateLimitModal({
type="number"
placeholder=""
/>
<Disabled disabled={isSubmitting}>
<Button type="submit">Create</Button>
</Disabled>
<Button disabled={isSubmitting} type="submit">
Create
</Button>
</Form>
)}
</Formik>

View File

@@ -1,5 +1,4 @@
import { Button } from "@opal/components";
import { Disabled } from "@opal/core";
import Text from "@/refresh-components/texts/Text";
import { SvgChevronLeft, SvgChevronRight } from "@opal/icons";
const DISABLED_MESSAGE = "Wait for agent message to complete";
@@ -33,14 +32,13 @@ export default function MessageSwitcher({
className="flex flex-row items-center gap-1"
data-testid="MessageSwitcher/container"
>
<Disabled disabled={disableForStreaming}>
<Button
icon={SvgChevronLeft}
onClick={previous}
prominence="tertiary"
tooltip={disableForStreaming ? DISABLED_MESSAGE : "Previous"}
/>
</Disabled>
<Button
disabled={disableForStreaming}
icon={SvgChevronLeft}
onClick={previous}
prominence="tertiary"
tooltip={disableForStreaming ? DISABLED_MESSAGE : "Previous"}
/>
<div className="flex flex-row items-center justify-center">
<Text as="p" text03 mainUiAction>
@@ -54,14 +52,13 @@ export default function MessageSwitcher({
</Text>
</div>
<Disabled disabled={disableForStreaming}>
<Button
icon={SvgChevronRight}
onClick={next}
prominence="tertiary"
tooltip={disableForStreaming ? DISABLED_MESSAGE : "Next"}
/>
</Disabled>
<Button
disabled={disableForStreaming}
icon={SvgChevronRight}
onClick={next}
prominence="tertiary"
tooltip={disableForStreaming ? DISABLED_MESSAGE : "Next"}
/>
</div>
);
}

View File

@@ -8,7 +8,6 @@ import { markdown } from "@opal/utils";
import Spacer from "@/refresh-components/Spacer";
import Link from "next/link";
import { Button } from "@opal/components";
import { Disabled } from "@opal/core";
import { Form, Formik } from "formik";
import * as Yup from "yup";
import { TextFormField } from "@/components/Field";
@@ -66,11 +65,9 @@ const ForgotPasswordPage: React.FC = () => {
/>
<div className="flex">
<Disabled disabled={isSubmitting}>
<Button type="submit" width="full">
Reset Password
</Button>
</Disabled>
<Button disabled={isSubmitting} type="submit" width="full">
Reset Password
</Button>
</div>
</Form>
)}

View File

@@ -10,7 +10,6 @@ import * as Yup from "yup";
import { toast } from "@/hooks/useToast";
import { TextFormField } from "@/components/Field";
import { Button } from "@opal/components";
import { Disabled } from "@opal/core";
import Text from "@/refresh-components/texts/Text";
const ImpersonateSchema = Yup.object().shape({
@@ -90,11 +89,9 @@ export default function ImpersonatePage() {
placeholder="Enter API Key"
/>
<Disabled disabled={isSubmitting}>
<Button type="submit" width="full">
Impersonate User
</Button>
</Disabled>
<Button disabled={isSubmitting} type="submit" width="full">
Impersonate User
</Button>
</Form>
)}
</Formik>

View File

@@ -3,7 +3,6 @@
import { toast } from "@/hooks/useToast";
import { basicLogin, basicSignup } from "@/lib/user";
import { Button } from "@opal/components";
import { Disabled } from "@opal/core";
import { Form, Formik } from "formik";
import * as Yup from "yup";
import { requestEmailVerification } from "../lib";
@@ -243,15 +242,14 @@ export default function EmailPasswordForm({
/>
<Spacer rem={0.25} />
<Disabled disabled={isSubmitting || !isValid || !dirty}>
<Button
type="submit"
width="full"
rightIcon={SvgArrowRightCircle}
>
{isJoin ? "Join" : isSignup ? "Create Account" : "Sign In"}
</Button>
</Disabled>
<Button
disabled={isSubmitting || !isValid || !dirty}
type="submit"
width="full"
rightIcon={SvgArrowRightCircle}
>
{isJoin ? "Join" : isSignup ? "Create Account" : "Sign In"}
</Button>
{user?.is_anonymous_user && (
<Link
href="/app"

View File

@@ -8,7 +8,6 @@ import { markdown } from "@opal/utils";
import Spacer from "@/refresh-components/Spacer";
import Link from "next/link";
import { Button } from "@opal/components";
import { Disabled } from "@opal/core";
import { Form, Formik } from "formik";
import * as Yup from "yup";
import { TextFormField } from "@/components/Field";
@@ -102,11 +101,9 @@ const ResetPasswordPage: React.FC = () => {
/>
<div className="flex">
<Disabled disabled={isSubmitting}>
<Button type="submit" width="full">
Reset Password
</Button>
</Disabled>
<Button disabled={isSubmitting} type="submit" width="full">
Reset Password
</Button>
</div>
</Form>
)}

View File

@@ -364,14 +364,13 @@ const InputBar = memo(
{/* Bottom left controls */}
<div className="flex flex-row items-center gap-1">
{/* (+) button for file upload */}
<Disabled disabled={disabled}>
<Button
icon={SvgPaperclip}
tooltip="Attach Files"
prominence="tertiary"
onClick={() => fileInputRef.current?.click()}
/>
</Disabled>
<Button
disabled={disabled}
icon={SvgPaperclip}
tooltip="Attach Files"
prominence="tertiary"
onClick={() => fileInputRef.current?.click()}
/>
{/* Demo Data indicator pill - only show on welcome page (no session) when demo data is enabled */}
{demoDataEnabled && isWelcomePage && (
<SimpleTooltip

View File

@@ -34,7 +34,6 @@ import {
} from "@opal/icons";
import ConfirmationModalLayout from "@/refresh-components/layouts/ConfirmationModalLayout";
import { Button } from "@opal/components";
import { Disabled } from "@opal/core";
import SimpleLoader from "@/refresh-components/loaders/SimpleLoader";
import TypewriterText from "@/app/craft/components/TypewriterText";
import {
@@ -272,25 +271,22 @@ function BuildSessionButton({
twoTone={!isDeleting && !deleteSuccess && !deleteError}
submit={
deleteSuccess ? (
<Disabled disabled>
<Button variant="action" icon={SvgCheckCircle}>
Done
</Button>
</Disabled>
<Button disabled variant="action" icon={SvgCheckCircle}>
Done
</Button>
) : deleteError ? (
<Button variant="danger" onClick={closeModal}>
Close
</Button>
) : (
<Disabled disabled={isDeleting}>
<Button
variant="danger"
onClick={handleConfirmDelete}
icon={isDeleting ? SimpleLoader : undefined}
>
{isDeleting ? "Deleting..." : "Delete"}
</Button>
</Disabled>
<Button
disabled={isDeleting}
variant="danger"
onClick={handleConfirmDelete}
icon={isDeleting ? SimpleLoader : undefined}
>
{isDeleting ? "Deleting..." : "Delete"}
</Button>
)
}
>

View File

@@ -4,7 +4,6 @@ import React from "react";
import { cn } from "@/lib/utils";
import Text from "@/refresh-components/texts/Text";
import { Button } from "@opal/components";
import { Disabled } from "@opal/core";
import {
SvgDownloadCloud,
SvgLoader,
@@ -154,16 +153,15 @@ export default function UrlBar({
</div>
{/* Export button — shown for downloadable file previews (e.g. markdown → docx) */}
{onDownload && (
<Disabled disabled={isDownloading}>
<Button
variant="action"
prominence="tertiary"
icon={isDownloading ? SpinningLoader : SvgExternalLink}
onClick={onDownload}
>
{isDownloading ? "Exporting..." : "Export to .docx"}
</Button>
</Disabled>
<Button
disabled={isDownloading}
variant="action"
prominence="tertiary"
icon={isDownloading ? SpinningLoader : SvgExternalLink}
onClick={onDownload}
>
{isDownloading ? "Exporting..." : "Export to .docx"}
</Button>
)}
{/* Share button — shown when webapp preview is active */}
{previewUrl && sessionId && (

View File

@@ -4,7 +4,6 @@ import { useState } from "react";
import { Formik, Form, useFormikContext } from "formik";
import { Section } from "@/layouts/general-layouts";
import { Button } from "@opal/components";
import { Disabled } from "@opal/core";
import { toast } from "@/hooks/useToast";
import { ValidSources } from "@/lib/types";
import { Credential } from "@/lib/connectors/credentials";
@@ -96,16 +95,16 @@ function ConnectorConfigForm({
/>
))}
<Section flexDirection="row" justifyContent="between" height="fit">
<Disabled disabled={isSubmitting}>
<Button prominence="secondary" onClick={onBack}>
Back
</Button>
</Disabled>
<Disabled disabled={isSubmitting}>
<Button type="button" onClick={handleSubmit}>
{isSubmitting ? "Creating..." : "Create Connector"}
</Button>
</Disabled>
<Button
disabled={isSubmitting}
prominence="secondary"
onClick={onBack}
>
Back
</Button>
<Button disabled={isSubmitting} type="button" onClick={handleSubmit}>
{isSubmitting ? "Creating..." : "Create Connector"}
</Button>
</Section>
</CardSection>
</Form>

View File

@@ -6,7 +6,6 @@ import * as Yup from "yup";
import { Section } from "@/layouts/general-layouts";
import Text from "@/refresh-components/texts/Text";
import { Button } from "@opal/components";
import { Disabled } from "@opal/core";
import { TextFormField } from "@/components/Field";
import { ValidSources } from "@/lib/types";
import {
@@ -150,20 +149,21 @@ export default function CreateCredentialInline({
gap={0.5}
height="fit"
>
<Disabled disabled={isSubmitting}>
<Button
variant="action"
prominence="secondary"
onClick={onCancel}
>
Cancel
</Button>
</Disabled>
<Disabled disabled={!isValid || !dirty || isSubmitting}>
<Button variant="action" type="submit">
{isSubmitting ? "Creating..." : "Create Credential"}
</Button>
</Disabled>
<Button
disabled={isSubmitting}
variant="action"
prominence="secondary"
onClick={onCancel}
>
Cancel
</Button>
<Button
disabled={!isValid || !dirty || isSubmitting}
variant="action"
type="submit"
>
{isSubmitting ? "Creating..." : "Create Credential"}
</Button>
</Section>
</Section>
</Form>

View File

@@ -3,7 +3,6 @@
import { useState } from "react";
import { Section } from "@/layouts/general-layouts";
import { Button } from "@opal/components";
import { Disabled } from "@opal/core";
import Modal from "@/refresh-components/Modal";
import { SvgKey } from "@opal/icons";
import {
@@ -229,31 +228,31 @@ export default function CredentialStep({
connectorType as ConfigurableSources
) &&
(NEXT_PUBLIC_CLOUD_ENABLED || NEXT_PUBLIC_TEST_ENV) && (
<Disabled disabled={isAuthorizing}>
<Button
variant="action"
onClick={handleAuthorize}
hidden={!isAuthorizeVisible}
>
{isAuthorizing
? "Authorizing..."
: `Authorize with ${getSourceDisplayName(
connectorType
)}`}
</Button>
</Disabled>
<Button
disabled={isAuthorizing}
variant="action"
onClick={handleAuthorize}
hidden={!isAuthorizeVisible}
>
{isAuthorizing
? "Authorizing..."
: `Authorize with ${getSourceDisplayName(
connectorType
)}`}
</Button>
)}
</div>
{hasCredentials && (
<Disabled disabled={!selectedCredential || isConnecting}>
<Button onClick={isSingleStep ? handleConnect : onContinue}>
{isSingleStep
? isConnecting
? "Connecting..."
: "Connect"
: "Continue"}
</Button>
</Disabled>
<Button
disabled={!selectedCredential || isConnecting}
onClick={isSingleStep ? handleConnect : onContinue}
>
{isSingleStep
? isConnecting
? "Connecting..."
: "Connect"
: "Continue"}
</Button>
)}
</div>
)}

View File

@@ -14,7 +14,6 @@ import {
import { LibraryEntry } from "@/app/craft/types/user-library";
import Text from "@/refresh-components/texts/Text";
import { Button } from "@opal/components";
import { Disabled } from "@opal/core";
import Modal from "@/refresh-components/Modal";
import ShadowDiv from "@/refresh-components/ShadowDiv";
import { Section } from "@/layouts/general-layouts";
@@ -262,15 +261,14 @@ export default function UserLibraryModal({
disabled={isUploading}
accept=".xlsx,.xls,.docx,.doc,.pptx,.ppt,.csv,.json,.txt,.pdf,.zip"
/>
<Disabled disabled={isUploading}>
<Button
prominence="secondary"
icon={SvgUploadCloud}
onClick={() => handleUploadToFolder("/")}
tooltip={isUploading ? "Uploading..." : "Upload"}
aria-label={isUploading ? "Uploading..." : "Upload"}
/>
</Disabled>
<Button
disabled={isUploading}
prominence="secondary"
icon={SvgUploadCloud}
onClick={() => handleUploadToFolder("/")}
tooltip={isUploading ? "Uploading..." : "Upload"}
aria-label={isUploading ? "Uploading..." : "Upload"}
/>
</Section>
{isLoading ? (
@@ -384,9 +382,12 @@ export default function UserLibraryModal({
>
Cancel
</Button>
<Disabled disabled={!newFolderName.trim()}>
<Button onClick={handleCreateDirectory}>Create</Button>
</Disabled>
<Button
disabled={!newFolderName.trim()}
onClick={handleCreateDirectory}
>
Create
</Button>
</Modal.Footer>
</Modal.Content>
</Modal>

View File

@@ -36,7 +36,6 @@ import { ConfirmEntityModal } from "@/components/modals/ConfirmEntityModal";
import { getSourceMetadata } from "@/lib/sources";
import { deleteConnector } from "@/app/craft/services/apiServices";
import { Button } from "@opal/components";
import { Disabled } from "@opal/core";
import {
OAUTH_STATE_KEY,
getDemoDataEnabled,
@@ -377,18 +376,19 @@ export default function BuildConfigPage() {
description="Select data sources and your default LLM"
rightChildren={
<div className="flex items-center gap-2">
<Disabled disabled={!hasChanges || isUpdating}>
<Button prominence="secondary" onClick={handleRestoreChanges}>
Restore Changes
</Button>
</Disabled>
<Disabled
disabled={!hasChanges || isUpdating || isPreProvisioning}
<Button
disabled={!hasChanges || isUpdating}
prominence="secondary"
onClick={handleRestoreChanges}
>
<Button onClick={handleUpdate}>
{isUpdating || isPreProvisioning ? "Updating..." : "Update"}
</Button>
</Disabled>
Restore Changes
</Button>
<Button
disabled={!hasChanges || isUpdating || isPreProvisioning}
onClick={handleUpdate}
>
{isUpdating || isPreProvisioning ? "Updating..." : "Update"}
</Button>
</div>
}
/>

View File

@@ -17,7 +17,6 @@ import Title from "@/components/ui/title";
import Spacer from "@/refresh-components/Spacer";
import Button from "@/refresh-components/buttons/Button";
import { Button as OpalButton } from "@opal/components";
import { Disabled } from "@opal/core";
import useSWR from "swr";
import { SWR_KEYS } from "@/lib/swr-keys";
import React, { useState } from "react";
@@ -202,15 +201,14 @@ function GenerateReportInput({
</Popover.Content>
</Popover>
</div>
<Disabled disabled={isLoading || isWaitingForReport}>
<OpalButton
color={"blue"}
icon={SvgDownloadCloud}
onClick={() => requestReport()}
>
{isWaitingForReport ? "Generating..." : "Generate Report"}
</OpalButton>
</Disabled>
<OpalButton
disabled={isLoading || isWaitingForReport}
color={"blue"}
icon={SvgDownloadCloud}
onClick={() => requestReport()}
>
{isWaitingForReport ? "Generating..." : "Generate Report"}
</OpalButton>
<p className="mt-1 text-xs">
{isWaitingForReport
? "A report is currently being generated. Please wait..."

View File

@@ -10,7 +10,6 @@ import Switch from "@/refresh-components/inputs/Switch";
import CharacterCount from "@/refresh-components/CharacterCount";
import InputImage from "@/refresh-components/inputs/InputImage";
import { Button } from "@opal/components";
import { Disabled } from "@opal/core";
import { useFormikContext } from "formik";
import {
forwardRef,
@@ -314,15 +313,14 @@ export const AppearanceThemeSettings = forwardRef<
/>
</FormField.Control>
<div className="mt-2 w-full justify-center items-center flex">
<Disabled disabled={!hasLogo}>
<Button
prominence="secondary"
onClick={handleLogoEdit}
icon={SvgEdit}
>
Update
</Button>
</Disabled>
<Button
disabled={!hasLogo}
prominence="secondary"
onClick={handleLogoEdit}
icon={SvgEdit}
>
Update
</Button>
</div>
</FormField>
</div>

View File

@@ -3,7 +3,6 @@
import * as SettingsLayouts from "@/layouts/settings-layouts";
import { ADMIN_ROUTES } from "@/lib/admin-routes";
import { Button } from "@opal/components";
import { Disabled } from "@opal/core";
import {
AppearanceThemeSettings,
AppearanceThemeSettingsRef,
@@ -225,26 +224,21 @@ export default function ThemePage() {
description="Customize how the application appears to users across your organization."
icon={route.icon}
rightChildren={
<Disabled
<Button
disabled={isSubmitting || (!dirty && !hasLogoChange)}
type="button"
onClick={async () => {
const errors = await validateForm();
if (Object.keys(errors).length > 0) {
setErrors(errors);
appearanceSettingsRef.current?.focusFirstError(errors);
return;
}
await submitForm();
}}
>
<Button
type="button"
onClick={async () => {
const errors = await validateForm();
if (Object.keys(errors).length > 0) {
setErrors(errors);
appearanceSettingsRef.current?.focusFirstError(
errors
);
return;
}
await submitForm();
}}
>
{isSubmitting ? "Applying..." : "Apply Changes"}
</Button>
</Disabled>
{isSubmitting ? "Applying..." : "Apply Changes"}
</Button>
}
/>
<SettingsLayouts.Body>

View File

@@ -1,6 +1,5 @@
import { SvgTrash } from "@opal/icons";
import { Button } from "@opal/components";
import { Disabled } from "@opal/core";
export interface DeleteButtonProps {
onClick?: (event: React.MouseEvent<HTMLElement>) => void | Promise<void>;
@@ -9,14 +8,13 @@ export interface DeleteButtonProps {
export function DeleteButton({ onClick, disabled }: DeleteButtonProps) {
return (
<Disabled disabled={disabled}>
<Button
onClick={onClick}
icon={SvgTrash}
tooltip="Delete"
prominence="tertiary"
size="sm"
/>
</Disabled>
<Button
disabled={disabled}
onClick={onClick}
icon={SvgTrash}
tooltip="Delete"
prominence="tertiary"
size="sm"
/>
);
}

View File

@@ -6,7 +6,6 @@ import {
import { toast } from "@/hooks/useToast";
import useSWRMutation from "swr/mutation";
import { Button } from "@opal/components";
import { Disabled } from "@opal/core";
import GenericConfirmModal from "@/components/modals/GenericConfirmModal";
import { useState } from "react";
@@ -109,11 +108,9 @@ export const InviteUserButton = ({
/>
)}
<Disabled disabled={isMutating}>
<Button onClick={() => setShowInviteModal(true)}>
{invited ? "Uninvite" : "Invite"}
</Button>
</Disabled>
<Button disabled={isMutating} onClick={() => setShowInviteModal(true)}>
{invited ? "Uninvite" : "Invite"}
</Button>
</>
);
};

View File

@@ -7,7 +7,6 @@ import { Input } from "@/components/ui/input";
import Label from "@/refresh-components/form/Label";
import Text from "@/refresh-components/texts/Text";
import { SvgAlertCircle, SvgEye, SvgEyeClosed, SvgKey } from "@opal/icons";
import { Disabled } from "@opal/core";
interface MCPAuthTemplate {
headers: Array<{ name: string; value: string }>;
request_body_params: Array<{ path: string; value: string }>;
@@ -251,12 +250,14 @@ export default function MCPApiKeyModal({
)}
<div className="flex justify-end space-x-2 pt-4">
<Disabled disabled={isSubmitting}>
<Button prominence="secondary" onClick={handleClose}>
Cancel
</Button>
</Disabled>
<Disabled
<Button
disabled={isSubmitting}
prominence="secondary"
onClick={handleClose}
>
Cancel
</Button>
<Button
disabled={
isSubmitting ||
(isTemplateMode
@@ -265,15 +266,14 @@ export default function MCPApiKeyModal({
)
: !apiKey.trim())
}
type="submit"
>
<Button type="submit">
{isSubmitting
? "Saving..."
: isAuthenticated
? `Update ${credsType}`
: `Save ${credsType}`}
</Button>
</Disabled>
{isSubmitting
? "Saving..."
: isAuthenticated
? `Update ${credsType}`
: `Save ${credsType}`}
</Button>
</div>
</form>
</Modal.Body>

View File

@@ -25,7 +25,6 @@ import { CredentialFieldsRenderer } from "./CredentialFieldsRenderer";
import { TypedFile } from "@/lib/connectors/fileTypes";
import ConnectorDocsLink from "@/components/admin/connectors/ConnectorDocsLink";
import { SvgPlusCircle } from "@opal/icons";
import { Disabled } from "@opal/core";
const CreateButton = ({
onClick,
isSubmitting,
@@ -37,11 +36,13 @@ const CreateButton = ({
isAdmin: boolean;
groups: number[];
}) => (
<Disabled disabled={isSubmitting || (!isAdmin && groups.length === 0)}>
<OpalButton onClick={onClick} icon={SvgPlusCircle}>
Create
</OpalButton>
</Disabled>
<OpalButton
disabled={isSubmitting || (!isAdmin && groups.length === 0)}
onClick={onClick}
icon={SvgPlusCircle}
>
Create
</OpalButton>
);
type formType = IsPublicGroupSelectorFormType & {

View File

@@ -1,5 +1,4 @@
import { Button } from "@opal/components";
import { Disabled } from "@opal/core";
import { Text } from "@opal/components";
import { FaNewspaper, FaTrash } from "react-icons/fa";
@@ -97,11 +96,9 @@ export default function EditCredential({
<Button onClick={() => resetForm()} icon={SvgTrash}>
Reset Changes
</Button>
<Disabled disabled={isSubmitting}>
<Button type="submit" icon={FaNewspaper}>
Update
</Button>
</Disabled>
<Button disabled={isSubmitting} type="submit" icon={FaNewspaper}>
Update
</Button>
</div>
</Form>
)}

View File

@@ -16,7 +16,6 @@ import {
SvgTrash,
} from "@opal/icons";
import { Button } from "@opal/components";
import { Disabled } from "@opal/core";
interface CredentialSelectionTableProps {
credentials: Credential<any>[];
editableCredentials: Credential<any>[];
@@ -120,14 +119,13 @@ function CredentialSelectionTable({
{new Date(credential.time_updated).toLocaleString()}
</td>
<td className="p-2 flex gap-x-2 content-center mt-auto">
<Disabled disabled={selected || !editable}>
<Button
onClick={async () => {
onDeleteCredential(credential);
}}
icon={SvgTrash}
/>
</Disabled>
<Button
disabled={selected || !editable}
onClick={async () => {
onDeleteCredential(credential);
}}
icon={SvgTrash}
/>
{onEditCredential && (
<button
disabled={!editable}
@@ -268,28 +266,23 @@ export default function ModifyCredential({
<div />
)}
<Disabled disabled={selectedCredential == null}>
<Button
onClick={() => {
if (onSwap && attachedConnector) {
onSwap(
selectedCredential!,
attachedConnector.id,
accessType
);
if (close) {
close();
}
<Button
disabled={selectedCredential == null}
onClick={() => {
if (onSwap && attachedConnector) {
onSwap(selectedCredential!, attachedConnector.id, accessType);
if (close) {
close();
}
if (onSwitch) {
onSwitch(selectedCredential!);
}
}}
icon={SvgArrowExchange}
>
Select
</Button>
</Disabled>
}
if (onSwitch) {
onSwitch(selectedCredential!);
}
}}
icon={SvgArrowExchange}
>
Select
</Button>
</div>
)}
</div>

View File

@@ -21,7 +21,6 @@ import { FiLink, FiMaximize2, FiTrash } from "react-icons/fi";
import { mutate } from "swr";
import { toast } from "@/hooks/useToast";
import { SvgTrash } from "@opal/icons";
import { Disabled } from "@opal/core";
export function FailedReIndexAttempts({
failedIndexingStatuses,
}: {
@@ -136,44 +135,42 @@ export function FailedReIndexAttempts({
</Link>
</TableCell>
<TableCell>
<Disabled disabled={!reindexingProgress.is_deletable}>
<Button
variant="danger"
onClick={async () => {
if (shouldConfirmConnectorDeletion) {
setPendingConnectorDeletion({
connectorId: reindexingProgress.connector_id,
credentialId: reindexingProgress.credential_id,
ccPairId: reindexingProgress.cc_pair_id,
name:
reindexingProgress.name ?? "this connector",
});
return;
}
<Button
disabled={!reindexingProgress.is_deletable}
variant="danger"
onClick={async () => {
if (shouldConfirmConnectorDeletion) {
setPendingConnectorDeletion({
connectorId: reindexingProgress.connector_id,
credentialId: reindexingProgress.credential_id,
ccPairId: reindexingProgress.cc_pair_id,
name: reindexingProgress.name ?? "this connector",
});
return;
}
try {
await deleteCCPair(
reindexingProgress.connector_id,
reindexingProgress.credential_id,
() =>
mutate(
buildCCPairInfoUrl(
reindexingProgress.cc_pair_id
)
try {
await deleteCCPair(
reindexingProgress.connector_id,
reindexingProgress.credential_id,
() =>
mutate(
buildCCPairInfoUrl(
reindexingProgress.cc_pair_id
)
);
} catch (error) {
console.error("Error deleting connector:", error);
toast.error(
"Failed to delete connector. Please try again."
);
}
}}
icon={SvgTrash}
>
Delete
</Button>
</Disabled>
)
);
} catch (error) {
console.error("Error deleting connector:", error);
toast.error(
"Failed to delete connector. Please try again."
);
}
}}
icon={SvgTrash}
>
Delete
</Button>
</TableCell>
</TableRow>
);

View File

@@ -4,7 +4,6 @@ import { useState } from "react";
import Link from "next/link";
import ErrorPageLayout from "@/components/errorPages/ErrorPageLayout";
import { Button } from "@opal/components";
import { Disabled } from "@opal/core";
import InlineExternalLink from "@/refresh-components/InlineExternalLink";
import { logout } from "@/lib/user";
import { loadStripe } from "@stripe/stripe-js";
@@ -137,11 +136,9 @@ export default function AccessRestricted() {
</Text>
<div className="flex flex-row gap-2">
<Disabled disabled={isLoading}>
<Button onClick={handleResubscribe}>
{isLoading ? "Loading..." : "Resubscribe"}
</Button>
</Disabled>
<Button disabled={isLoading} onClick={handleResubscribe}>
{isLoading ? "Loading..." : "Resubscribe"}
</Button>
<Button
prominence="secondary"
onClick={async () => {

View File

@@ -2,7 +2,6 @@
import { useState, useEffect } from "react";
import { Button } from "@opal/components";
import { Disabled } from "@opal/core";
import { useProjectsContext } from "@/providers/ProjectsContext";
import { useKeyPress } from "@/hooks/useKeyPress";
import * as InputLayouts from "@/layouts/input-layouts";
@@ -69,9 +68,9 @@ export default function CreateProjectModal({
<Button prominence="secondary" onClick={() => modal.toggle(false)}>
Cancel
</Button>
<Disabled disabled={!projectName.trim()}>
<Button onClick={handleSubmit}>Create Project</Button>
</Disabled>
<Button disabled={!projectName.trim()} onClick={handleSubmit}>
Create Project
</Button>
</Modal.Footer>
</Modal.Content>
</Modal>

View File

@@ -3,7 +3,6 @@ import Modal from "@/refresh-components/Modal";
import { Button } from "@opal/components";
import { TextFormField } from "@/components/Field";
import { SvgEdit } from "@opal/icons";
import { Disabled } from "@opal/core";
export interface EditPropertyModalProps {
propertyTitle: string;
propertyDetails?: string;
@@ -53,17 +52,16 @@ export default function EditPropertyModal({
/>
<Modal.Footer>
<Disabled
<Button
disabled={
isSubmitting ||
!isValid ||
values.propertyValue === propertyValue
}
type="submit"
>
<Button type="submit">
{isSubmitting ? "Updating..." : "Update property"}
</Button>
</Disabled>
{isSubmitting ? "Updating..." : "Update property"}
</Button>
</Modal.Footer>
</Form>
)}

View File

@@ -5,7 +5,6 @@ import { useRouter, useSearchParams } from "next/navigation";
import type { Route } from "next";
import { Dialog } from "@headlessui/react";
import { Button } from "@opal/components";
import { Disabled } from "@opal/core";
import { toast } from "@/hooks/useToast";
import { useUser } from "@/providers/UserProvider";
import { useModalContext } from "../context/ModalContext";
@@ -193,17 +192,16 @@ export default function NewTeamModal() {
Your join request can be approved by any admin of {appDomain}.
</p>
<div className="flex flex-col items-center justify-center gap-4 mt-4">
<Disabled disabled={isSubmitting}>
<Button
onClick={handleRequestInvite}
width="full"
icon={isSubmitting ? SimpleLoader : SvgArrowUp}
>
{isSubmitting
? "Sending request..."
: "Request to join your team"}
</Button>
</Disabled>
<Button
disabled={isSubmitting}
onClick={handleRequestInvite}
width="full"
icon={isSubmitting ? SimpleLoader : SvgArrowUp}
>
{isSubmitting
? "Sending request..."
: "Request to join your team"}
</Button>
</div>
<Button
onClick={handleContinueToNewOrg}

View File

@@ -3,7 +3,6 @@ import { Button } from "@opal/components";
import type { IconProps } from "@opal/types";
import Modal from "@/refresh-components/Modal";
import { SvgLoader } from "@opal/icons";
import { Disabled } from "@opal/core";
export interface ProviderModalProps {
// Modal configurations
clickOutsideToClose?: boolean;
@@ -84,15 +83,14 @@ export default function ProviderModal({
>
{cancelLabel}
</Button>
<Disabled disabled={submitDisabled || isSubmitting}>
<Button
type="button"
onClick={onSubmit}
icon={isSubmitting ? SpinningLoader : undefined}
>
{submitLabel}
</Button>
</Disabled>
<Button
disabled={submitDisabled || isSubmitting}
type="button"
onClick={onSubmit}
icon={isSubmitting ? SpinningLoader : undefined}
>
{submitLabel}
</Button>
</Modal.Footer>
)}
</Modal.Content>

View File

@@ -25,7 +25,6 @@ import {
import { Section } from "@/layouts/general-layouts";
import useFilter from "@/hooks/useFilter";
import { Button } from "@opal/components";
import { Disabled } from "@opal/core";
import ScrollIndicatorDiv from "@/refresh-components/ScrollIndicatorDiv";
function getIcon(
@@ -276,14 +275,13 @@ export default function UserFilesModal({
onClick={() => setShowOnlySelected(!showOnlySelected)}
interaction={showOnlySelected ? "hover" : "rest"}
/>
<Disabled disabled={selectedCount === 0}>
<Button
icon={SvgXCircle}
prominence="tertiary"
size="sm"
onClick={handleDeselectAll}
/>
</Disabled>
<Button
disabled={selectedCount === 0}
icon={SvgXCircle}
prominence="tertiary"
size="sm"
onClick={handleDeselectAll}
/>
</Section>
)}

View File

@@ -1,412 +0,0 @@
"use client";
import { useState } from "react";
import { toast } from "@/hooks/useToast";
import { Button } from "@opal/components";
import { Disabled } from "@opal/core";
import { cn } from "@/lib/utils";
import { markdown } from "@opal/utils";
import { Content } from "@opal/layouts";
import Card from "@/refresh-components/cards/Card";
import Text from "@/refresh-components/texts/Text";
import { Section } from "@/layouts/general-layouts";
import {
SvgExternalLink,
SvgPlug,
SvgRefreshCw,
SvgSettings,
SvgTrash,
SvgUnplug,
} from "@opal/icons";
import Modal, { BasicModalFooter } from "@/refresh-components/Modal";
import type {
HookPointMeta,
HookResponse,
} from "@/ee/refresh-pages/admin/HooksPage/interfaces";
import {
activateHook,
deactivateHook,
deleteHook,
validateHook,
} from "@/ee/refresh-pages/admin/HooksPage/svc";
import { getHookPointIcon } from "@/ee/refresh-pages/admin/HooksPage/hookPointIcons";
import HookStatusPopover from "@/ee/refresh-pages/admin/HooksPage/HookStatusPopover";
// ---------------------------------------------------------------------------
// Sub-component: disconnect confirmation modal
// ---------------------------------------------------------------------------
interface DisconnectConfirmModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
hook: HookResponse;
onDisconnect: () => void;
onDisconnectAndDelete: () => void;
}
function DisconnectConfirmModal({
open,
onOpenChange,
hook,
onDisconnect,
onDisconnectAndDelete,
}: DisconnectConfirmModalProps) {
return (
<Modal open={open} onOpenChange={onOpenChange}>
<Modal.Content width="md" height="fit">
<Modal.Header
icon={(props) => (
<SvgUnplug {...props} className="text-action-danger-05" />
)}
title={`Disconnect ${hook.name}`}
onClose={() => onOpenChange(false)}
/>
<Modal.Body>
<div className="flex flex-col gap-4">
<Text mainUiBody text03>
Onyx will stop calling this endpoint for hook{" "}
<strong>
<em>{hook.name}</em>
</strong>
. In-flight requests will continue to run. The external endpoint
may still retain data previously sent to it. You can reconnect
this hook later if needed.
</Text>
<Text mainUiBody text03>
You can also delete this hook. Deletion cannot be undone.
</Text>
</div>
</Modal.Body>
<Modal.Footer>
<BasicModalFooter
cancel={
<Button
prominence="secondary"
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
}
submit={
<div className="flex items-center gap-2">
<Button
variant="danger"
prominence="secondary"
onClick={onDisconnectAndDelete}
>
Disconnect &amp; Delete
</Button>
<Button
variant="danger"
prominence="primary"
onClick={onDisconnect}
>
Disconnect
</Button>
</div>
}
/>
</Modal.Footer>
</Modal.Content>
</Modal>
);
}
// ---------------------------------------------------------------------------
// Sub-component: delete confirmation modal
// ---------------------------------------------------------------------------
interface DeleteConfirmModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
hook: HookResponse;
onDelete: () => void;
}
function DeleteConfirmModal({
open,
onOpenChange,
hook,
onDelete,
}: DeleteConfirmModalProps) {
return (
<Modal open={open} onOpenChange={onOpenChange}>
<Modal.Content width="md" height="fit">
<Modal.Header
icon={(props) => (
<SvgTrash {...props} className="text-action-danger-05" />
)}
title={`Delete ${hook.name}`}
onClose={() => onOpenChange(false)}
/>
<Modal.Body>
<div className="flex flex-col gap-4">
<Text mainUiBody text03>
Hook{" "}
<strong>
<em>{hook.name}</em>
</strong>{" "}
will be permanently removed from this hook point. The external
endpoint may still retain data previously sent to it.
</Text>
<Text mainUiBody text03>
Deletion cannot be undone.
</Text>
</div>
</Modal.Body>
<Modal.Footer>
<BasicModalFooter
cancel={
<Button
prominence="secondary"
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
}
submit={
<Button variant="danger" prominence="primary" onClick={onDelete}>
Delete
</Button>
}
/>
</Modal.Footer>
</Modal.Content>
</Modal>
);
}
// ---------------------------------------------------------------------------
// ConnectedHookCard
// ---------------------------------------------------------------------------
export interface ConnectedHookCardProps {
hook: HookResponse;
spec: HookPointMeta | undefined;
onEdit: () => void;
onDeleted: () => void;
onToggled: (updated: HookResponse) => void;
}
export default function ConnectedHookCard({
hook,
spec,
onEdit,
onDeleted,
onToggled,
}: ConnectedHookCardProps) {
const [isBusy, setIsBusy] = useState(false);
const [disconnectConfirmOpen, setDisconnectConfirmOpen] = useState(false);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
async function handleDelete() {
setDeleteConfirmOpen(false);
setIsBusy(true);
try {
await deleteHook(hook.id);
onDeleted();
} catch (err) {
console.error("Failed to delete hook:", err);
toast.error(
err instanceof Error ? err.message : "Failed to delete hook."
);
} finally {
setIsBusy(false);
}
}
async function handleActivate() {
setIsBusy(true);
try {
const updated = await activateHook(hook.id);
onToggled(updated);
} catch (err) {
console.error("Failed to reconnect hook:", err);
toast.error(
err instanceof Error ? err.message : "Failed to reconnect hook."
);
} finally {
setIsBusy(false);
}
}
async function handleDeactivate() {
setDisconnectConfirmOpen(false);
setIsBusy(true);
try {
const updated = await deactivateHook(hook.id);
onToggled(updated);
} catch (err) {
console.error("Failed to deactivate hook:", err);
toast.error(
err instanceof Error ? err.message : "Failed to deactivate hook."
);
} finally {
setIsBusy(false);
}
}
async function handleDisconnectAndDelete() {
setDisconnectConfirmOpen(false);
setIsBusy(true);
try {
const deactivated = await deactivateHook(hook.id);
onToggled(deactivated);
await deleteHook(hook.id);
onDeleted();
} catch (err) {
console.error("Failed to disconnect hook:", err);
toast.error(
err instanceof Error ? err.message : "Failed to disconnect hook."
);
} finally {
setIsBusy(false);
}
}
async function handleValidate() {
setIsBusy(true);
try {
const result = await validateHook(hook.id);
if (result.status === "passed") {
toast.success("Hook validated successfully.");
} else {
toast.error(
result.error_message ?? `Validation failed: ${result.status}`
);
}
} catch (err) {
console.error("Failed to validate hook:", err);
toast.error(
err instanceof Error ? err.message : "Failed to validate hook."
);
} finally {
setIsBusy(false);
}
}
const HookIcon = getHookPointIcon(hook.hook_point);
return (
<>
<DisconnectConfirmModal
open={disconnectConfirmOpen}
onOpenChange={setDisconnectConfirmOpen}
hook={hook}
onDisconnect={handleDeactivate}
onDisconnectAndDelete={handleDisconnectAndDelete}
/>
<DeleteConfirmModal
open={deleteConfirmOpen}
onOpenChange={setDeleteConfirmOpen}
hook={hook}
onDelete={handleDelete}
/>
<Card
variant="primary"
padding={0.5}
gap={0}
className={cn(
"hover:border-border-02",
!hook.is_active && "!bg-background-neutral-02"
)}
>
<div className="w-full flex flex-row">
<div className="flex-1 p-2">
<Content
sizePreset="main-ui"
variant="section"
icon={HookIcon}
title={!hook.is_active ? markdown(`~~${hook.name}~~`) : hook.name}
description={`Hook Point: ${
spec?.display_name ?? hook.hook_point
}`}
/>
{spec?.docs_url && (
<a
href={spec.docs_url}
target="_blank"
rel="noopener noreferrer"
className="pl-6 flex items-center gap-1 w-fit"
>
<span className="underline font-secondary-body text-text-03">
Documentation
</span>
<SvgExternalLink size={12} className="shrink-0" />
</a>
)}
</div>
<Section
flexDirection="column"
alignItems="end"
width="fit"
height="fit"
gap={0}
>
<div className="flex items-center gap-1">
{hook.is_active ? (
<HookStatusPopover hook={hook} spec={spec} isBusy={isBusy} />
) : (
<div
className={cn(
"flex items-center gap-1 p-2",
isBusy ? "opacity-50 pointer-events-none" : "cursor-pointer"
)}
onClick={handleActivate}
>
<Text mainUiAction text03>
Reconnect
</Text>
<SvgPlug size={16} className="text-text-03 shrink-0" />
</div>
)}
</div>
<Disabled disabled={isBusy}>
<div className="flex items-center gap-0.5 pl-1 pr-1 pb-1">
{hook.is_active ? (
<>
<Button
prominence="tertiary"
size="sm"
icon={SvgUnplug}
onClick={() => setDisconnectConfirmOpen(true)}
tooltip="Disconnect Hook"
aria-label="Deactivate hook"
/>
<Button
prominence="tertiary"
size="sm"
icon={SvgRefreshCw}
onClick={handleValidate}
tooltip="Test Connection"
aria-label="Re-validate hook"
/>
</>
) : (
<Button
prominence="tertiary"
size="sm"
icon={SvgTrash}
onClick={() => setDeleteConfirmOpen(true)}
tooltip="Delete"
aria-label="Delete hook"
/>
)}
<Button
prominence="tertiary"
size="sm"
icon={SvgSettings}
onClick={onEdit}
tooltip="Manage"
aria-label="Configure hook"
/>
</div>
</Disabled>
</Section>
</div>
</Card>
</>
);
}

View File

@@ -1,21 +1,22 @@
"use client";
import { useState } from "react";
import { Formik, Form, useFormikContext } from "formik";
import * as Yup from "yup";
import { Button, Text } from "@opal/components";
import { Disabled } from "@opal/core";
import {
SvgCheckCircle,
SvgHookNodes,
SvgShareWebhook,
SvgLoader,
SvgRevert,
} from "@opal/icons";
import Modal, { BasicModalFooter } from "@/refresh-components/Modal";
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
import InputTypeInField from "@/refresh-components/form/InputTypeInField";
import InputSelect from "@/refresh-components/inputs/InputSelect";
import PasswordInputTypeIn from "@/refresh-components/inputs/PasswordInputTypeIn";
import { FormField } from "@/refresh-components/form/FormField";
import PasswordInputTypeInField from "@/refresh-components/form/PasswordInputTypeInField";
import * as InputLayouts from "@/layouts/input-layouts";
import { Section } from "@/layouts/general-layouts";
import { ContentAction } from "@opal/layouts";
import { Content, ContentAction } from "@opal/layouts";
import { toast } from "@/hooks/useToast";
import {
createHook,
@@ -37,7 +38,6 @@ import type {
// ---------------------------------------------------------------------------
interface HookFormModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** When provided, the modal is in edit mode for this hook. */
hook?: HookResponse;
@@ -50,7 +50,12 @@ interface HookFormModalProps {
// Helpers
// ---------------------------------------------------------------------------
function buildInitialState(
const MAX_TIMEOUT_SECONDS = 600;
const SOFT_DESCRIPTION =
"If the endpoint returns an error, Onyx logs it and continues the pipeline as normal, ignoring the hook result.";
function buildInitialValues(
hook: HookResponse | undefined,
spec: HookPointMeta | undefined
): HookFormState {
@@ -72,172 +77,95 @@ function buildInitialState(
};
}
const SOFT_DESCRIPTION =
"If the endpoint returns an error, Onyx logs it and continues the pipeline as normal, ignoring the hook result.";
function buildValidationSchema(isEdit: boolean) {
return Yup.object().shape({
name: Yup.string().trim().required("Display name cannot be empty."),
endpoint_url: Yup.string().trim().required("Endpoint URL cannot be empty."),
api_key: isEdit
? Yup.string()
: Yup.string().trim().required("API key cannot be empty."),
timeout_seconds: Yup.string()
.required("Timeout is required.")
.test(
"valid-timeout",
`Must be greater than 0 and at most ${MAX_TIMEOUT_SECONDS} seconds.`,
(val) => {
const num = parseFloat(val ?? "");
return !isNaN(num) && num > 0 && num <= MAX_TIMEOUT_SECONDS;
}
),
});
}
const MAX_TIMEOUT_SECONDS = 600;
// ---------------------------------------------------------------------------
// Timeout field (needs access to spec for revert button)
// ---------------------------------------------------------------------------
interface TimeoutFieldProps {
spec: HookPointMeta | undefined;
}
function TimeoutField({ spec }: TimeoutFieldProps) {
const { values, setFieldValue, isSubmitting } =
useFormikContext<HookFormState>();
return (
<InputLayouts.Vertical
name="timeout_seconds"
title="Timeout"
suffix="(seconds)"
subDescription={`Maximum time Onyx will wait for the endpoint to respond before applying the fail strategy. Must be greater than 0 and at most ${MAX_TIMEOUT_SECONDS} seconds.`}
>
<div className="[&_input]:!font-main-ui-mono [&_input::placeholder]:!font-main-ui-mono [&_input]:![appearance:textfield] [&_input::-webkit-outer-spin-button]:!appearance-none [&_input::-webkit-inner-spin-button]:!appearance-none w-full">
<InputTypeInField
name="timeout_seconds"
type="number"
placeholder={spec ? String(spec.default_timeout_seconds) : undefined}
variant={isSubmitting ? "disabled" : undefined}
showClearButton={false}
rightSection={
spec?.default_timeout_seconds !== undefined &&
values.timeout_seconds !== String(spec.default_timeout_seconds) ? (
<Button
prominence="tertiary"
size="xs"
icon={SvgRevert}
tooltip="Revert to Default"
onClick={() =>
setFieldValue(
"timeout_seconds",
String(spec.default_timeout_seconds)
)
}
disabled={isSubmitting}
/>
) : undefined
}
/>
</div>
</InputLayouts.Vertical>
);
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export default function HookFormModal({
open,
onOpenChange,
hook,
spec,
onSuccess,
}: HookFormModalProps) {
const isEdit = !!hook;
const [form, setForm] = useState<HookFormState>(() =>
buildInitialState(hook, spec)
);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isConnected, setIsConnected] = useState(false);
// Tracks whether the user explicitly cleared the API key field in edit mode.
// - false + empty field → key unchanged (omitted from PATCH)
// - true + empty field → key cleared (api_key: null sent to backend)
// - false + non-empty → new key provided (new value sent to backend)
const [apiKeyCleared, setApiKeyCleared] = useState(false);
const [touched, setTouched] = useState({
name: false,
endpoint_url: false,
api_key: false,
});
const [apiKeyServerError, setApiKeyServerError] = useState(false);
const [endpointServerError, setEndpointServerError] = useState<string | null>(
null
);
const [timeoutServerError, setTimeoutServerError] = useState(false);
function touch(key: keyof typeof touched) {
setTouched((prev) => ({ ...prev, [key]: true }));
}
const initialValues = buildInitialValues(hook, spec);
const validationSchema = buildValidationSchema(isEdit);
function handleOpenChange(next: boolean) {
if (!next) {
if (isSubmitting) return;
setTimeout(() => {
setForm(buildInitialState(hook, spec));
setIsConnected(false);
setApiKeyCleared(false);
setTouched({ name: false, endpoint_url: false, api_key: false });
setApiKeyServerError(false);
setEndpointServerError(null);
setTimeoutServerError(false);
}, 200);
}
onOpenChange(next);
}
function set<K extends keyof HookFormState>(key: K, value: HookFormState[K]) {
setForm((prev) => ({ ...prev, [key]: value }));
}
const timeoutNum = parseFloat(form.timeout_seconds);
const isTimeoutValid =
!isNaN(timeoutNum) && timeoutNum > 0 && timeoutNum <= MAX_TIMEOUT_SECONDS;
const isValid =
form.name.trim().length > 0 &&
form.endpoint_url.trim().length > 0 &&
isTimeoutValid &&
(isEdit || form.api_key.trim().length > 0);
const nameError = touched.name && !form.name.trim();
const endpointEmptyError = touched.endpoint_url && !form.endpoint_url.trim();
const endpointFieldError = endpointEmptyError
? "Endpoint URL cannot be empty."
: endpointServerError ?? undefined;
const apiKeyEmptyError = !isEdit && touched.api_key && !form.api_key.trim();
const apiKeyFieldError = apiKeyEmptyError
? "API key cannot be empty."
: apiKeyServerError
? "Invalid API key."
: undefined;
function handleTimeoutBlur() {
if (!isTimeoutValid) {
const fallback = hook?.timeout_seconds ?? spec?.default_timeout_seconds;
if (fallback !== undefined) {
set("timeout_seconds", String(fallback));
if (timeoutServerError) setTimeoutServerError(false);
}
}
}
const hasChanges =
isEdit && hook
? form.name !== hook.name ||
form.endpoint_url !== (hook.endpoint_url ?? "") ||
form.fail_strategy !== hook.fail_strategy ||
timeoutNum !== hook.timeout_seconds ||
form.api_key.trim().length > 0 ||
apiKeyCleared
: true;
async function handleSubmit() {
if (!isValid) return;
setIsSubmitting(true);
try {
let result: HookResponse;
if (isEdit && hook) {
const req: HookUpdateRequest = {};
if (form.name !== hook.name) req.name = form.name;
if (form.endpoint_url !== (hook.endpoint_url ?? ""))
req.endpoint_url = form.endpoint_url;
if (form.fail_strategy !== hook.fail_strategy)
req.fail_strategy = form.fail_strategy;
if (timeoutNum !== hook.timeout_seconds)
req.timeout_seconds = timeoutNum;
if (form.api_key.trim().length > 0) {
req.api_key = form.api_key;
} else if (apiKeyCleared) {
req.api_key = null;
}
if (Object.keys(req).length === 0) {
setIsSubmitting(false);
handleOpenChange(false);
return;
}
result = await updateHook(hook.id, req);
} else {
if (!spec) {
toast.error("No hook point specified.");
setIsSubmitting(false);
return;
}
result = await createHook({
name: form.name,
hook_point: spec.hook_point,
endpoint_url: form.endpoint_url,
...(form.api_key ? { api_key: form.api_key } : {}),
fail_strategy: form.fail_strategy,
timeout_seconds: timeoutNum,
});
}
toast.success(isEdit ? "Hook updated." : "Hook created.");
onSuccess(result);
if (!isEdit) {
setIsConnected(true);
await new Promise((resolve) => setTimeout(resolve, 500));
}
setIsSubmitting(false);
handleOpenChange(false);
} catch (err) {
if (err instanceof HookAuthError) {
setApiKeyServerError(true);
} else if (err instanceof HookTimeoutError) {
setTimeoutServerError(true);
} else if (err instanceof HookConnectError) {
setEndpointServerError(err.message || "Could not connect to endpoint.");
} else {
toast.error(
err instanceof Error ? err.message : "Something went wrong."
);
}
setIsSubmitting(false);
}
function handleClose() {
onOpenChange(false);
}
const hookPointDisplayName =
@@ -245,314 +173,287 @@ export default function HookFormModal({
const hookPointDescription = spec?.description;
const docsUrl = spec?.docs_url;
const failStrategyDescription =
form.fail_strategy === "soft"
? SOFT_DESCRIPTION
: spec?.fail_hard_description;
return (
<Modal open={open} onOpenChange={handleOpenChange}>
<Modal open onOpenChange={(open) => !open && handleClose()}>
<Modal.Content width="md" height="fit">
<Modal.Header
icon={SvgHookNodes}
title={isEdit ? "Manage Hook Extension" : "Set Up Hook Extension"}
description={
isEdit
? undefined
: "Connect an external API endpoint to extend the hook point."
}
onClose={() => handleOpenChange(false)}
/>
<Modal.Body>
{/* Hook point section header */}
<ContentAction
sizePreset="main-ui"
variant="section"
paddingVariant="fit"
title={hookPointDisplayName}
description={hookPointDescription}
rightChildren={
<Section
flexDirection="column"
alignItems="end"
width="fit"
height="fit"
gap={0.25}
>
<div className="flex items-center gap-1">
<SvgHookNodes
style={{ width: "1rem", height: "1rem" }}
className="text-text-03 shrink-0"
/>
<Text font="secondary-body" color="text-03">
Hook Point
</Text>
</div>
{docsUrl && (
<a
href={docsUrl}
target="_blank"
rel="noopener noreferrer"
className="underline"
>
<Text font="secondary-body" color="text-03">
Documentation
</Text>
</a>
)}
</Section>
}
/>
<FormField className="w-full" state={nameError ? "error" : "idle"}>
<FormField.Label>Display Name</FormField.Label>
<FormField.Control>
<div className="[&_input::placeholder]:!font-main-ui-muted w-full">
<InputTypeIn
value={form.name}
onChange={(e) => set("name", e.target.value)}
onBlur={() => touch("name")}
placeholder="Name your extension at this hook point"
variant={
isSubmitting ? "disabled" : nameError ? "error" : undefined
}
/>
</div>
</FormField.Control>
<FormField.Message
messages={{ error: "Display name cannot be empty." }}
/>
</FormField>
<FormField className="w-full">
<FormField.Label>Fail Strategy</FormField.Label>
<FormField.Control>
<InputSelect
value={form.fail_strategy}
onValueChange={(v) =>
set("fail_strategy", v as HookFailStrategy)
<Formik
initialValues={initialValues}
validationSchema={validationSchema}
validateOnMount
onSubmit={async (values, helpers) => {
try {
let result: HookResponse;
if (isEdit && hook) {
const req: HookUpdateRequest = {};
if (values.name !== hook.name) req.name = values.name;
if (values.endpoint_url !== (hook.endpoint_url ?? ""))
req.endpoint_url = values.endpoint_url;
if (values.fail_strategy !== hook.fail_strategy)
req.fail_strategy = values.fail_strategy;
const timeoutNum = parseFloat(values.timeout_seconds);
if (timeoutNum !== hook.timeout_seconds)
req.timeout_seconds = timeoutNum;
if (values.api_key.trim().length > 0) {
req.api_key = values.api_key;
} else if (apiKeyCleared) {
req.api_key = null;
}
disabled={isSubmitting}
>
<InputSelect.Trigger placeholder="Select strategy" />
<InputSelect.Content>
<InputSelect.Item value="soft">
Log Error and Continue
{spec?.default_fail_strategy === "soft" && (
<>
{" "}
<Text color="text-03">(Default)</Text>
</>
)}
</InputSelect.Item>
<InputSelect.Item value="hard">
Block Pipeline on Failure
{spec?.default_fail_strategy === "hard" && (
<>
{" "}
<Text color="text-03">(Default)</Text>
</>
)}
</InputSelect.Item>
</InputSelect.Content>
</InputSelect>
</FormField.Control>
<FormField.Description>
{failStrategyDescription}
</FormField.Description>
</FormField>
if (Object.keys(req).length === 0) {
handleClose();
return;
}
result = await updateHook(hook.id, req);
} else {
if (!spec) {
toast.error("No hook point specified.");
return;
}
result = await createHook({
name: values.name,
hook_point: spec.hook_point,
endpoint_url: values.endpoint_url,
...(values.api_key ? { api_key: values.api_key } : {}),
fail_strategy: values.fail_strategy,
timeout_seconds: parseFloat(values.timeout_seconds),
});
}
toast.success(isEdit ? "Hook updated." : "Hook created.");
onSuccess(result);
if (!isEdit) {
setIsConnected(true);
await new Promise((resolve) => setTimeout(resolve, 500));
}
handleClose();
} catch (err) {
if (err instanceof HookAuthError) {
helpers.setFieldError("api_key", "Invalid API key.");
} else if (err instanceof HookTimeoutError) {
helpers.setFieldError(
"timeout_seconds",
"Connection timed out. Try increasing the timeout."
);
} else if (err instanceof HookConnectError) {
helpers.setFieldError(
"endpoint_url",
err.message || "Could not connect to endpoint."
);
} else {
toast.error(
err instanceof Error ? err.message : "Something went wrong."
);
}
} finally {
helpers.setSubmitting(false);
}
}}
>
{({ values, setFieldValue, isSubmitting, isValid, dirty }) => {
const failStrategyDescription =
values.fail_strategy === "soft"
? SOFT_DESCRIPTION
: spec?.fail_hard_description;
<FormField
className="w-full"
state={timeoutServerError ? "error" : "idle"}
>
<FormField.Label>
Timeout{" "}
<Text font="main-ui-action" color="text-03">
(seconds)
</Text>
</FormField.Label>
<FormField.Control>
<div className="[&_input]:!font-main-ui-mono [&_input::placeholder]:!font-main-ui-mono [&_input]:![appearance:textfield] [&_input::-webkit-outer-spin-button]:!appearance-none [&_input::-webkit-inner-spin-button]:!appearance-none w-full">
<InputTypeIn
type="number"
value={form.timeout_seconds}
onChange={(e) => {
set("timeout_seconds", e.target.value);
if (timeoutServerError) setTimeoutServerError(false);
}}
onBlur={handleTimeoutBlur}
placeholder={
spec ? String(spec.default_timeout_seconds) : undefined
return (
<Form className="w-full overflow-visible">
<Modal.Header
icon={SvgShareWebhook}
title={
isEdit ? "Manage Hook Extension" : "Set Up Hook Extension"
}
variant={
isSubmitting
? "disabled"
: timeoutServerError
? "error"
: undefined
description={
isEdit
? undefined
: "Connect an external API endpoint to extend the hook point."
}
showClearButton={false}
rightSection={
spec?.default_timeout_seconds !== undefined &&
form.timeout_seconds !==
String(spec.default_timeout_seconds) ? (
<Button
prominence="tertiary"
size="xs"
icon={SvgRevert}
tooltip="Revert to Default"
onClick={() =>
set(
"timeout_seconds",
String(spec.default_timeout_seconds)
)
}
disabled={isSubmitting}
onClose={handleClose}
/>
<Modal.Body>
{/* Hook point section header */}
<ContentAction
sizePreset="main-ui"
variant="section"
paddingVariant="fit"
title={hookPointDisplayName}
description={hookPointDescription}
rightChildren={
<div className="flex flex-col items-end gap-1">
<Content
sizePreset="secondary"
variant="body"
icon={SvgShareWebhook}
title="Hook Point"
prominence="muted"
widthVariant="fit"
/>
{docsUrl && (
<a
href={docsUrl}
target="_blank"
rel="noopener noreferrer"
className="underline leading-none"
>
<Text font="secondary-body" color="text-03">
Documentation
</Text>
</a>
)}
</div>
}
/>
<InputLayouts.Vertical name="name" title="Display Name">
<div className="[&_input::placeholder]:!font-main-ui-muted w-full">
<InputTypeInField
name="name"
placeholder="Name your extension at this hook point"
variant={isSubmitting ? "disabled" : undefined}
/>
) : undefined
}
/>
</div>
</FormField.Control>
{!timeoutServerError && (
<FormField.Description>
Maximum time Onyx will wait for the endpoint to respond before
applying the fail strategy. Must be greater than 0 and at most{" "}
{MAX_TIMEOUT_SECONDS} seconds.
</FormField.Description>
)}
<FormField.Message
messages={{
error: "Connection timed out. Try increasing the timeout.",
}}
/>
</FormField>
</div>
</InputLayouts.Vertical>
<FormField
className="w-full"
state={endpointFieldError ? "error" : "idle"}
>
<FormField.Label>External API Endpoint URL</FormField.Label>
<FormField.Control>
<div className="[&_input::placeholder]:!font-main-ui-muted w-full">
<InputTypeIn
value={form.endpoint_url}
onChange={(e) => {
set("endpoint_url", e.target.value);
if (endpointServerError) setEndpointServerError(null);
}}
onBlur={() => touch("endpoint_url")}
placeholder="https://your-api-endpoint.com"
variant={
isSubmitting
? "disabled"
: endpointFieldError
? "error"
: undefined
}
/>
</div>
</FormField.Control>
{!endpointFieldError && (
<FormField.Description>
Only connect to servers you trust. You are responsible for
actions taken and data shared with this connection.
</FormField.Description>
)}
<FormField.Message messages={{ error: endpointFieldError }} />
</FormField>
<InputLayouts.Vertical
name="fail_strategy"
title="Fail Strategy"
nonInteractive
subDescription={failStrategyDescription}
>
<InputSelect
value={values.fail_strategy}
onValueChange={(v) =>
setFieldValue("fail_strategy", v as HookFailStrategy)
}
disabled={isSubmitting}
>
<InputSelect.Trigger placeholder="Select strategy" />
<InputSelect.Content>
<InputSelect.Item value="soft">
Log Error and Continue
{spec?.default_fail_strategy === "soft" && (
<>
{" "}
<Text color="text-03">(Default)</Text>
</>
)}
</InputSelect.Item>
<InputSelect.Item value="hard">
Block Pipeline on Failure
{spec?.default_fail_strategy === "hard" && (
<>
{" "}
<Text color="text-03">(Default)</Text>
</>
)}
</InputSelect.Item>
</InputSelect.Content>
</InputSelect>
</InputLayouts.Vertical>
<FormField
className="w-full"
state={apiKeyFieldError ? "error" : "idle"}
>
<FormField.Label>API Key</FormField.Label>
<FormField.Control>
<PasswordInputTypeIn
value={form.api_key}
onChange={(e) => {
set("api_key", e.target.value);
if (apiKeyServerError) setApiKeyServerError(false);
if (isEdit) {
setApiKeyCleared(
e.target.value === "" && !!hook?.api_key_masked
);
}
}}
onBlur={() => touch("api_key")}
placeholder={
isEdit
? hook?.api_key_masked ?? "Leave blank to keep current key"
: undefined
}
disabled={isSubmitting}
error={!!apiKeyFieldError}
/>
</FormField.Control>
{!apiKeyFieldError && (
<FormField.Description>
Onyx will use this key to authenticate with your API endpoint.
</FormField.Description>
)}
<FormField.Message messages={{ error: apiKeyFieldError }} />
</FormField>
<TimeoutField spec={spec} />
{!isEdit && (isSubmitting || isConnected) && (
<Section
flexDirection="row"
alignItems="center"
justifyContent="start"
height="fit"
gap={1}
className="px-0.5"
>
<div className="p-0.5 shrink-0">
{isConnected ? (
<SvgCheckCircle
size={16}
className="text-status-success-05"
<InputLayouts.Vertical
name="endpoint_url"
title="External API Endpoint URL"
subDescription="Only connect to servers you trust. You are responsible for actions taken and data shared with this connection."
>
<div className="[&_input::placeholder]:!font-main-ui-muted w-full">
<InputTypeInField
name="endpoint_url"
placeholder="https://your-api-endpoint.com"
variant={isSubmitting ? "disabled" : undefined}
/>
</div>
</InputLayouts.Vertical>
<InputLayouts.Vertical
name="api_key"
title="API Key"
subDescription="Onyx will use this key to authenticate with your API endpoint."
>
<PasswordInputTypeInField
name="api_key"
placeholder={
isEdit
? hook?.api_key_masked ??
"Leave blank to keep current key"
: undefined
}
disabled={isSubmitting}
onChange={(e) => {
if (isEdit && hook?.api_key_masked) {
setApiKeyCleared(e.target.value === "");
}
}}
/>
</InputLayouts.Vertical>
{!isEdit && (isSubmitting || isConnected) && (
<Section
flexDirection="row"
alignItems="center"
justifyContent="start"
height="fit"
gap={1}
className="px-0.5"
>
<div className="p-0.5 shrink-0">
{isConnected ? (
<SvgCheckCircle
size={16}
className="text-status-success-05"
/>
) : (
<SvgLoader
size={16}
className="animate-spin text-text-03"
/>
)}
</div>
<Text font="secondary-body" color="text-03">
{isConnected
? "Connection valid."
: "Verifying connection…"}
</Text>
</Section>
)}
</Modal.Body>
<Modal.Footer>
<BasicModalFooter
cancel={
<Button
disabled={isSubmitting}
prominence="secondary"
onClick={handleClose}
>
Cancel
</Button>
}
submit={
<Button
disabled={
isSubmitting ||
!isValid ||
(!dirty && !apiKeyCleared && isEdit)
}
type="submit"
icon={
isSubmitting && !isEdit
? () => (
<SvgLoader size={16} className="animate-spin" />
)
: undefined
}
>
{isEdit ? "Save Changes" : "Connect"}
</Button>
}
/>
) : (
<SvgLoader size={16} className="animate-spin text-text-03" />
)}
</div>
<Text font="secondary-body" color="text-03">
{isConnected ? "Connection valid." : "Verifying connection…"}
</Text>
</Section>
)}
</Modal.Body>
<Modal.Footer>
<BasicModalFooter
cancel={
<Disabled disabled={isSubmitting}>
<Button
prominence="secondary"
onClick={() => handleOpenChange(false)}
>
Cancel
</Button>
</Disabled>
}
submit={
<Disabled disabled={isSubmitting || !isValid || !hasChanges}>
<Button
onClick={handleSubmit}
icon={
isSubmitting && !isEdit
? () => <SvgLoader size={16} className="animate-spin" />
: undefined
}
>
{isEdit ? "Save Changes" : "Connect"}
</Button>
</Disabled>
}
/>
</Modal.Footer>
</Modal.Footer>
</Form>
);
}}
</Formik>
</Modal.Content>
</Modal>
);

View File

@@ -14,15 +14,16 @@ import type {
HookPointMeta,
HookResponse,
} from "@/ee/refresh-pages/admin/HooksPage/interfaces";
import { useModalClose } from "@/refresh-components/contexts/ModalContext";
interface HookLogsModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
hook: HookResponse;
spec: HookPointMeta | undefined;
}
// Section header: "Past Hour ————" or "Older ————"
//
// TODO(@raunakab): replace this with a proper, opalified `Separator` component (when it lands).
function SectionHeader({ label }: { label: string }) {
return (
<Section
@@ -69,12 +70,9 @@ function LogRow({ log }: { log: HookExecutionRecord }) {
);
}
export default function HookLogsModal({
open,
onOpenChange,
hook,
spec,
}: HookLogsModalProps) {
export default function HookLogsModal({ hook, spec }: HookLogsModalProps) {
const onClose = useModalClose();
const { recentErrors, olderErrors, isLoading, error } = useHookExecutionLogs(
hook.id,
10
@@ -99,7 +97,7 @@ export default function HookLogsModal({
}
return (
<Modal open={open} onOpenChange={onOpenChange}>
<Modal open onOpenChange={onClose}>
<Modal.Content width="md" height="fit">
<Modal.Header
icon={(props) => <SvgTextLines {...props} />}
@@ -107,7 +105,7 @@ export default function HookLogsModal({
description={`Hook: ${hook.name} • Hook Point: ${
spec?.display_name ?? hook.hook_point
}`}
onClose={() => onOpenChange(false)}
onClose={onClose}
/>
<Modal.Body>
{isLoading ? (

View File

@@ -1,9 +1,11 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
import { useCreateModal } from "@/refresh-components/contexts/ModalContext";
import { noProp } from "@/lib/utils";
import { formatTimeOnly } from "@/lib/dateUtils";
import { Text } from "@opal/components";
import { Button, Text } from "@opal/components";
import { Content } from "@opal/layouts";
import LineItem from "@/refresh-components/buttons/LineItem";
import Popover from "@/refresh-components/Popover";
import SimpleLoader from "@/refresh-components/loaders/SimpleLoader";
@@ -22,6 +24,7 @@ import type {
HookPointMeta,
HookResponse,
} from "@/ee/refresh-pages/admin/HooksPage/interfaces";
import { cn } from "@opal/utils";
interface HookStatusPopoverProps {
hook: HookResponse;
@@ -34,7 +37,7 @@ export default function HookStatusPopover({
spec,
isBusy,
}: HookStatusPopoverProps) {
const [logsOpen, setLogsOpen] = useState(false);
const logsModal = useCreateModal();
const [open, setOpen] = useState(false);
// true = opened by click (stays until dismissed); false = opened by hover (closes after 1s)
const [clickOpened, setClickOpened] = useState(false);
@@ -113,39 +116,34 @@ export default function HookStatusPopover({
return (
<>
<HookLogsModal
open={logsOpen}
onOpenChange={setLogsOpen}
hook={hook}
spec={spec}
/>
<logsModal.Provider>
<HookLogsModal hook={hook} spec={spec} />
</logsModal.Provider>
<Popover open={open} onOpenChange={handleOpenChange}>
<Popover.Anchor asChild>
<div
<Button
prominence="tertiary"
rightIcon={({ className, ...props }) =>
hasRecentErrors ? (
<SvgAlertTriangle
{...props}
className={cn("text-status-warning-05", className)}
/>
) : (
<SvgCheckCircle
{...props}
className={cn("text-status-success-05", className)}
/>
)
}
onMouseEnter={handleTriggerMouseEnter}
onMouseLeave={handleTriggerMouseLeave}
onClick={handleTriggerClick}
className={cn(
"flex items-center gap-1 cursor-pointer rounded-xl p-2 transition-colors hover:bg-background-neutral-02",
isBusy && "opacity-50 pointer-events-none"
)}
onClick={noProp(handleTriggerClick)}
disabled={isBusy}
>
<Text font="main-ui-action" color="text-03">
Connected
</Text>
{hasRecentErrors ? (
<SvgAlertTriangle
size={16}
className="text-status-warning-05 shrink-0"
/>
) : (
<SvgCheckCircle
size={16}
className="text-status-success-05 shrink-0"
/>
)}
</div>
Connected
</Button>
</Popover.Anchor>
<Popover.Content
@@ -160,62 +158,36 @@ export default function HookStatusPopover({
alignItems="start"
height="fit"
width={hasRecentErrors ? 20 : 12.5}
padding={0.125}
gap={0.25}
>
{isLoading ? (
<Section justifyContent="center" height="fit" className="p-3">
<Section justifyContent="center">
<SimpleLoader />
</Section>
) : error ? (
<Section justifyContent="center" height="fit" className="p-3">
<Text font="secondary-body" color="text-03">
Failed to load logs.
</Text>
</Section>
<Text font="secondary-body" color="text-03">
Failed to load logs.
</Text>
) : hasRecentErrors ? (
// Errors state
<>
{/* Header: "N Errors" (≤3) or "Most Recent Errors" (>3) */}
<Section
flexDirection="row"
justifyContent="start"
alignItems="start"
gap={0.25}
padding={0.375}
height="fit"
className="rounded-lg"
>
<Section
justifyContent="center"
alignItems="center"
width={1.25}
height={1.25}
className="shrink-0"
>
<SvgXOctagon size={16} className="text-status-error-05" />
</Section>
<Section
flexDirection="column"
justifyContent="start"
alignItems="start"
width="fit"
height="fit"
gap={0}
className="px-0.5"
>
<Text font="main-ui-action" color="text-04">
{recentErrors.length <= 3
<div className="p-1">
<Content
sizePreset="secondary"
variant="section"
icon={SvgXOctagon}
title={
recentErrors.length <= 3
? `${recentErrors.length} ${
recentErrors.length === 1 ? "Error" : "Errors"
}`
: "Most Recent Errors"}
</Text>
<Text font="secondary-body" color="text-03">
in the past hour
</Text>
</Section>
</Section>
: "Most Recent Errors"
}
description="in the past hour"
/>
</div>
<Separator noPadding className="py-1" />
<Separator noPadding className="px-2" />
{/* Log rows — at most 3, timestamp first then error message */}
<Section
@@ -266,10 +238,10 @@ export default function HookStatusPopover({
<LineItem
muted
icon={SvgMaximize2}
onClick={() => {
onClick={noProp(() => {
handleOpenChange(false);
setLogsOpen(true);
}}
logsModal.toggle(true);
})}
>
View More Lines
</LineItem>
@@ -277,56 +249,26 @@ export default function HookStatusPopover({
) : (
// No errors state
<>
{/* No Error / in the past hour */}
<Section
flexDirection="row"
justifyContent="start"
alignItems="start"
gap={0.25}
padding={0.375}
height="fit"
className="rounded-lg"
>
<Section
justifyContent="center"
alignItems="center"
width={1.25}
height={1.25}
className="shrink-0"
>
<SvgCheckCircle
size={16}
className="text-status-success-05"
/>
</Section>
<Section
flexDirection="column"
justifyContent="start"
alignItems="start"
width="fit"
height="fit"
gap={0}
className="px-0.5"
>
<Text font="main-ui-action" color="text-04">
No Error
</Text>
<Text font="secondary-body" color="text-03">
in the past hour
</Text>
</Section>
</Section>
<div className="p-1">
<Content
sizePreset="secondary"
variant="section"
icon={SvgCheckCircle}
title="No Error"
description="in the past hour"
/>
</div>
<Separator noPadding className="py-1" />
<Separator noPadding className="px-2" />
{/* View Older Errors */}
<LineItem
muted
icon={SvgMaximize2}
onClick={() => {
onClick={noProp(() => {
handleOpenChange(false);
setLogsOpen(true);
}}
logsModal.toggle(true);
})}
>
View Older Errors
</LineItem>

View File

@@ -1,214 +0,0 @@
"use client";
import { useState } from "react";
import { useHookSpecs } from "@/ee/hooks/useHookSpecs";
import { useHooks } from "@/ee/hooks/useHooks";
import SimpleLoader from "@/refresh-components/loaders/SimpleLoader";
import { Button } from "@opal/components";
import { Content } from "@opal/layouts";
import InputSearch from "@/refresh-components/inputs/InputSearch";
import Card from "@/refresh-components/cards/Card";
import Text from "@/refresh-components/texts/Text";
import { SvgArrowExchange, SvgExternalLink } from "@opal/icons";
import HookFormModal from "@/ee/refresh-pages/admin/HooksPage/HookFormModal";
import ConnectedHookCard from "@/ee/refresh-pages/admin/HooksPage/ConnectedHookCard";
import { getHookPointIcon } from "@/ee/refresh-pages/admin/HooksPage/hookPointIcons";
import type {
HookPointMeta,
HookResponse,
} from "@/ee/refresh-pages/admin/HooksPage/interfaces";
import { markdown } from "@opal/utils";
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
export default function HooksContent() {
const [search, setSearch] = useState("");
const [connectSpec, setConnectSpec] = useState<HookPointMeta | null>(null);
const [editHook, setEditHook] = useState<HookResponse | null>(null);
const { specs, isLoading: specsLoading, error: specsError } = useHookSpecs();
const {
hooks,
isLoading: hooksLoading,
error: hooksError,
mutate,
} = useHooks();
if (specsLoading || hooksLoading) {
return <SimpleLoader />;
}
if (specsError || hooksError) {
return (
<Text text03 secondaryBody>
Failed to load{specsError ? " hook specifications" : " hooks"}. Please
refresh the page.
</Text>
);
}
const hooksByPoint: Record<string, HookResponse[]> = {};
for (const hook of hooks ?? []) {
(hooksByPoint[hook.hook_point] ??= []).push(hook);
}
const searchLower = search.toLowerCase();
// Connected hooks sorted alphabetically by hook name
const connectedHooks = (hooks ?? [])
.filter(
(hook) =>
!searchLower ||
hook.name.toLowerCase().includes(searchLower) ||
specs
?.find((s) => s.hook_point === hook.hook_point)
?.display_name.toLowerCase()
.includes(searchLower)
)
.sort((a, b) => a.name.localeCompare(b.name));
// Unconnected hook point specs sorted alphabetically
const unconnectedSpecs = (specs ?? [])
.filter(
(spec) =>
(hooksByPoint[spec.hook_point]?.length ?? 0) === 0 &&
(!searchLower ||
spec.display_name.toLowerCase().includes(searchLower) ||
spec.description.toLowerCase().includes(searchLower))
)
.sort((a, b) => a.display_name.localeCompare(b.display_name));
function handleHookSuccess(updated: HookResponse) {
mutate((prev) => {
if (!prev) return [updated];
const idx = prev.findIndex((h) => h.id === updated.id);
if (idx >= 0) {
const next = [...prev];
next[idx] = updated;
return next;
}
return [...prev, updated];
});
}
function handleHookDeleted(id: number) {
mutate((prev) => prev?.filter((h) => h.id !== id));
}
const connectSpec_ =
connectSpec ??
(editHook
? specs?.find((s) => s.hook_point === editHook.hook_point)
: undefined);
return (
<>
<div className="flex flex-col gap-6">
<InputSearch
placeholder="Search hooks..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<div className="flex flex-col gap-2">
{connectedHooks.length === 0 && unconnectedSpecs.length === 0 ? (
<Text text03 secondaryBody>
{search
? "No hooks match your search."
: "No hook points are available."}
</Text>
) : (
<>
{connectedHooks.map((hook) => {
const spec = specs?.find(
(s) => s.hook_point === hook.hook_point
);
return (
<ConnectedHookCard
key={hook.id}
hook={hook}
spec={spec}
onEdit={() => setEditHook(hook)}
onDeleted={() => handleHookDeleted(hook.id)}
onToggled={handleHookSuccess}
/>
);
})}
{unconnectedSpecs.map((spec) => {
const UnconnectedIcon = getHookPointIcon(spec.hook_point);
return (
<Card
key={spec.hook_point}
variant="secondary"
padding={0.5}
gap={0}
className="hover:border-border-02"
>
<div className="w-full flex flex-row">
<div className="flex-1 p-2">
<Content
sizePreset="main-ui"
variant="section"
icon={UnconnectedIcon}
title={spec.display_name}
description={spec.description}
/>
{spec.docs_url && (
<a
href={spec.docs_url}
target="_blank"
rel="noopener noreferrer"
className="pl-6 flex items-center gap-1"
>
<span className="underline font-secondary-body text-text-03">
Documentation
</span>
<SvgExternalLink size={12} className="shrink-0" />
</a>
)}
</div>
<Button
prominence="tertiary"
rightIcon={SvgArrowExchange}
onClick={() => setConnectSpec(spec)}
>
Connect
</Button>
</div>
</Card>
);
})}
</>
)}
</div>
</div>
{/* Create modal */}
<HookFormModal
key={connectSpec?.hook_point ?? "create"}
open={!!connectSpec}
onOpenChange={(open) => {
if (!open) setConnectSpec(null);
}}
spec={connectSpec ?? undefined}
onSuccess={handleHookSuccess}
/>
{/* Edit modal */}
<HookFormModal
key={editHook?.id ?? "edit"}
open={!!editHook}
onOpenChange={(open) => {
if (!open) setEditHook(null);
}}
hook={editHook ?? undefined}
spec={connectSpec_ ?? undefined}
onSuccess={handleHookSuccess}
/>
</>
);
}

View File

@@ -1,13 +0,0 @@
import { SvgBubbleText, SvgFileBroadcast, SvgHookNodes } from "@opal/icons";
import type { IconFunctionComponent } from "@opal/types";
const HOOK_POINT_ICONS: Record<string, IconFunctionComponent> = {
document_ingestion: SvgFileBroadcast,
query_processing: SvgBubbleText,
};
function getHookPointIcon(hookPoint: string): IconFunctionComponent {
return HOOK_POINT_ICONS[hookPoint] ?? SvgHookNodes;
}
export { HOOK_POINT_ICONS, getHookPointIcon };

View File

@@ -1,22 +1,510 @@
"use client";
import { useEffect } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import * as SettingsLayouts from "@/layouts/settings-layouts";
import { ADMIN_ROUTES } from "@/lib/admin-routes";
import { useSettingsContext } from "@/providers/SettingsProvider";
import { usePaidEnterpriseFeaturesEnabled } from "@/components/settings/usePaidEnterpriseFeaturesEnabled";
import { useHookSpecs } from "@/ee/hooks/useHookSpecs";
import { useHooks } from "@/ee/hooks/useHooks";
import useFilter from "@/hooks/useFilter";
import { toast } from "@/hooks/useToast";
import {
useCreateModal,
useModalClose,
} from "@/refresh-components/contexts/ModalContext";
import SimpleLoader from "@/refresh-components/loaders/SimpleLoader";
import HooksContent from "./HooksContent";
import { Button, SelectCard, Text } from "@opal/components";
import { Disabled, Hoverable } from "@opal/core";
import { markdown } from "@opal/utils";
import { Content, IllustrationContent } from "@opal/layouts";
import Modal from "@/refresh-components/Modal";
import {
SvgArrowExchange,
SvgBubbleText,
SvgExternalLink,
SvgFileBroadcast,
SvgShareWebhook,
SvgPlug,
SvgRefreshCw,
SvgSettings,
SvgTrash,
SvgUnplug,
} from "@opal/icons";
import type { IconFunctionComponent } from "@opal/types";
import { SvgNoResult, SvgEmpty } from "@opal/illustrations";
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
import HookFormModal from "@/ee/refresh-pages/admin/HooksPage/HookFormModal";
import HookStatusPopover from "@/ee/refresh-pages/admin/HooksPage/HookStatusPopover";
import {
activateHook,
deactivateHook,
deleteHook,
validateHook,
} from "@/ee/refresh-pages/admin/HooksPage/svc";
import type {
HookPointMeta,
HookResponse,
} from "@/ee/refresh-pages/admin/HooksPage/interfaces";
import { noProp } from "@/lib/utils";
const route = ADMIN_ROUTES.HOOKS;
const HOOK_POINT_ICONS: Record<string, IconFunctionComponent> = {
document_ingestion: SvgFileBroadcast,
query_processing: SvgBubbleText,
};
function getHookPointIcon(hookPoint: string): IconFunctionComponent {
return HOOK_POINT_ICONS[hookPoint] ?? SvgShareWebhook;
}
// ---------------------------------------------------------------------------
// Disconnect confirmation modal
// ---------------------------------------------------------------------------
interface DisconnectConfirmModalProps {
hook: HookResponse;
onDisconnect: () => void;
onDisconnectAndDelete: () => void;
}
function DisconnectConfirmModal({
hook,
onDisconnect,
onDisconnectAndDelete,
}: DisconnectConfirmModalProps) {
const onClose = useModalClose();
return (
<Modal open onOpenChange={onClose}>
<Modal.Content width="md" height="fit">
<Modal.Header
// TODO(@raunakab): replace the colour of this SVG with red.
icon={SvgUnplug}
title={markdown(`Disconnect *${hook.name}*`)}
onClose={onClose}
/>
<Modal.Body>
<div className="flex flex-col gap-2">
<Text font="main-ui-body" color="text-03">
{markdown(
`Onyx will stop calling this endpoint for hook ***${hook.name}***. In-flight requests will continue to run. The external endpoint may still retain data previously sent to it. You can reconnect this hook later if needed.`
)}
</Text>
<Text font="main-ui-body" color="text-03">
You can also delete this hook. Deletion cannot be undone.
</Text>
</div>
</Modal.Body>
<Modal.Footer>
<Button prominence="secondary" onClick={onClose}>
Cancel
</Button>
<Button
variant="danger"
prominence="secondary"
onClick={onDisconnectAndDelete}
>
Disconnect &amp; Delete
</Button>
<Button variant="danger" prominence="primary" onClick={onDisconnect}>
Disconnect
</Button>
</Modal.Footer>
</Modal.Content>
</Modal>
);
}
// ---------------------------------------------------------------------------
// Delete confirmation modal
// ---------------------------------------------------------------------------
interface DeleteConfirmModalProps {
hook: HookResponse;
onDelete: () => void;
}
function DeleteConfirmModal({ hook, onDelete }: DeleteConfirmModalProps) {
const onClose = useModalClose();
return (
<Modal open onOpenChange={onClose}>
<Modal.Content width="md" height="fit">
<Modal.Header
// TODO(@raunakab): replace the colour of this SVG with red.
icon={SvgTrash}
title={`Delete ${hook.name}`}
onClose={onClose}
/>
<Modal.Body>
<div className="flex flex-col gap-2">
<Text font="main-ui-body" color="text-03">
{markdown(
`Hook ***${hook.name}*** will be permanently removed from this hook point. The external endpoint may still retain data previously sent to it.`
)}
</Text>
<Text font="main-ui-body" color="text-03">
Deletion cannot be undone.
</Text>
</div>
</Modal.Body>
<Modal.Footer>
<Button prominence="secondary" onClick={onClose}>
Cancel
</Button>
<Button variant="danger" prominence="primary" onClick={onDelete}>
Delete
</Button>
</Modal.Footer>
</Modal.Content>
</Modal>
);
}
// ---------------------------------------------------------------------------
// Unconnected hook card
// ---------------------------------------------------------------------------
interface UnconnectedHookCardProps {
spec: HookPointMeta;
onConnect: () => void;
}
function UnconnectedHookCard({ spec, onConnect }: UnconnectedHookCardProps) {
const Icon = getHookPointIcon(spec.hook_point);
return (
<SelectCard state="empty" padding="sm" rounding="lg" onClick={onConnect}>
<div className="w-full flex flex-row">
<div className="flex-1 p-2">
<Content
sizePreset="main-ui"
variant="section"
icon={Icon}
title={spec.display_name}
description={spec.description}
/>
{spec.docs_url && (
<a
href={spec.docs_url}
target="_blank"
rel="noopener noreferrer"
className="ml-6 flex items-center gap-1 w-min"
>
<span className="underline font-secondary-body text-text-03">
Documentation
</span>
<SvgExternalLink size={12} className="shrink-0" />
</a>
)}
</div>
<Button
prominence="tertiary"
rightIcon={SvgArrowExchange}
onClick={noProp(onConnect)}
>
Connect
</Button>
</div>
</SelectCard>
);
}
// ---------------------------------------------------------------------------
// Connected hook card
// ---------------------------------------------------------------------------
interface ConnectedHookCardProps {
hook: HookResponse;
spec: HookPointMeta | undefined;
onEdit: () => void;
onDeleted: () => void;
onToggled: (updated: HookResponse) => void;
}
function ConnectedHookCard({
hook,
spec,
onEdit,
onDeleted,
onToggled,
}: ConnectedHookCardProps) {
const [isBusy, setIsBusy] = useState(false);
const disconnectModal = useCreateModal();
const deleteModal = useCreateModal();
async function handleDelete() {
deleteModal.toggle(false);
setIsBusy(true);
try {
await deleteHook(hook.id);
onDeleted();
} catch (err) {
console.error("Failed to delete hook:", err);
toast.error(
err instanceof Error ? err.message : "Failed to delete hook."
);
} finally {
setIsBusy(false);
}
}
async function handleActivate() {
setIsBusy(true);
try {
const updated = await activateHook(hook.id);
onToggled(updated);
} catch (err) {
console.error("Failed to reconnect hook:", err);
toast.error(
err instanceof Error ? err.message : "Failed to reconnect hook."
);
} finally {
setIsBusy(false);
}
}
async function handleDeactivate() {
disconnectModal.toggle(false);
setIsBusy(true);
try {
const updated = await deactivateHook(hook.id);
onToggled(updated);
} catch (err) {
console.error("Failed to deactivate hook:", err);
toast.error(
err instanceof Error ? err.message : "Failed to deactivate hook."
);
} finally {
setIsBusy(false);
}
}
async function handleDisconnectAndDelete() {
disconnectModal.toggle(false);
setIsBusy(true);
try {
const deactivated = await deactivateHook(hook.id);
onToggled(deactivated);
await deleteHook(hook.id);
onDeleted();
} catch (err) {
console.error("Failed to disconnect hook:", err);
toast.error(
err instanceof Error ? err.message : "Failed to disconnect hook."
);
} finally {
setIsBusy(false);
}
}
async function handleValidate() {
setIsBusy(true);
try {
const result = await validateHook(hook.id);
if (result.status === "passed") {
toast.success("Hook validated successfully.");
} else {
toast.error(
result.error_message ?? `Validation failed: ${result.status}`
);
}
} catch (err) {
console.error("Failed to validate hook:", err);
toast.error(
err instanceof Error ? err.message : "Failed to validate hook."
);
} finally {
setIsBusy(false);
}
}
const HookIcon = getHookPointIcon(hook.hook_point);
return (
<>
<disconnectModal.Provider>
<DisconnectConfirmModal
hook={hook}
onDisconnect={handleDeactivate}
onDisconnectAndDelete={handleDisconnectAndDelete}
/>
</disconnectModal.Provider>
<deleteModal.Provider>
<DeleteConfirmModal hook={hook} onDelete={handleDelete} />
</deleteModal.Provider>
<Hoverable.Root group="connected-hook-card">
{/* TODO(@raunakab): Modify the background colour (by using `SelectCard disabled={...}` [when it lands]) to indicate when the card is "disconnected". */}
<SelectCard state="filled" padding="sm" rounding="lg" onClick={onEdit}>
<div className="w-full flex flex-row">
<div className="flex-1 p-2">
<Content
sizePreset="main-ui"
variant="section"
icon={HookIcon}
title={
!hook.is_active ? markdown(`~~${hook.name}~~`) : hook.name
}
description={`Hook Point: ${
spec?.display_name ?? hook.hook_point
}`}
/>
{spec?.docs_url && (
<a
href={spec.docs_url}
target="_blank"
rel="noopener noreferrer"
className="ml-6 flex items-center gap-1 w-min"
>
<span className="underline font-secondary-body text-text-03">
Documentation
</span>
<SvgExternalLink size={12} className="shrink-0" />
</a>
)}
</div>
<div className="flex flex-col items-end shrink-0">
<div className="flex items-center gap-1">
{hook.is_active ? (
<HookStatusPopover hook={hook} spec={spec} isBusy={isBusy} />
) : (
<Button
prominence="tertiary"
rightIcon={SvgPlug}
onClick={noProp(handleActivate)}
disabled={isBusy}
>
Reconnect
</Button>
)}
</div>
<Disabled disabled={isBusy}>
<div className="flex items-center pb-1 px-1 gap-1">
{hook.is_active ? (
<>
<Hoverable.Item
group="connected-hook-card"
variant="opacity-on-hover"
>
<Button
prominence="tertiary"
size="md"
icon={SvgUnplug}
onClick={noProp(() => disconnectModal.toggle(true))}
tooltip="Disconnect Hook"
aria-label="Deactivate hook"
/>
</Hoverable.Item>
<Button
prominence="tertiary"
size="md"
icon={SvgRefreshCw}
onClick={noProp(handleValidate)}
tooltip="Test Connection"
aria-label="Re-validate hook"
/>
</>
) : (
<Button
prominence="tertiary"
size="md"
icon={SvgTrash}
onClick={noProp(() => deleteModal.toggle(true))}
tooltip="Delete"
aria-label="Delete hook"
/>
)}
<Button
prominence="tertiary"
size="md"
icon={SvgSettings}
onClick={noProp(onEdit)}
tooltip="Manage"
aria-label="Configure hook"
/>
</div>
</Disabled>
</div>
</div>
</SelectCard>
</Hoverable.Root>
</>
);
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default function HooksPage() {
const router = useRouter();
const { settings, settingsLoading } = useSettingsContext();
const isEE = usePaidEnterpriseFeaturesEnabled();
const [connectSpec, setConnectSpec] = useState<HookPointMeta | null>(null);
const [editHook, setEditHook] = useState<HookResponse | null>(null);
const { specs, isLoading: specsLoading, error: specsError } = useHookSpecs();
const {
hooks,
isLoading: hooksLoading,
error: hooksError,
mutate,
} = useHooks();
const hookExtractor = useCallback(
(hook: HookResponse) =>
`${hook.name} ${
specs?.find((s: HookPointMeta) => s.hook_point === hook.hook_point)
?.display_name ?? ""
}`,
[specs]
);
const sortedHooks = useMemo(
() => [...(hooks ?? [])].sort((a, b) => a.name.localeCompare(b.name)),
[hooks]
);
const {
query: search,
setQuery: setSearch,
filtered: connectedHooks,
} = useFilter(sortedHooks, hookExtractor);
const hooksByPoint = useMemo(() => {
const map: Record<string, HookResponse[]> = {};
for (const hook of hooks ?? []) {
(map[hook.hook_point] ??= []).push(hook);
}
return map;
}, [hooks]);
const unconnectedSpecs = useMemo(() => {
const searchLower = search.toLowerCase();
return (specs ?? [])
.filter(
(spec: HookPointMeta) =>
(hooksByPoint[spec.hook_point]?.length ?? 0) === 0 &&
(!searchLower ||
spec.display_name.toLowerCase().includes(searchLower) ||
spec.description.toLowerCase().includes(searchLower))
)
.sort((a: HookPointMeta, b: HookPointMeta) =>
a.display_name.localeCompare(b.display_name)
);
}, [specs, hooksByPoint, search]);
useEffect(() => {
if (settingsLoading) return;
if (!isEE) {
@@ -32,17 +520,132 @@ export default function HooksPage() {
return <SimpleLoader />;
}
const isLoading = specsLoading || hooksLoading;
function handleHookSuccess(updated: HookResponse) {
mutate((prev: HookResponse[] | undefined) => {
if (!prev) return [updated];
const idx = prev.findIndex((h: HookResponse) => h.id === updated.id);
if (idx >= 0) {
const next = [...prev];
next[idx] = updated;
return next;
}
return [...prev, updated];
});
}
function handleHookDeleted(id: number) {
mutate(
(prev: HookResponse[] | undefined) =>
prev?.filter((h: HookResponse) => h.id !== id)
);
}
const connectSpec_ =
connectSpec ??
(editHook
? specs?.find((s: HookPointMeta) => s.hook_point === editHook.hook_point)
: undefined);
return (
<SettingsLayouts.Root>
<SettingsLayouts.Header
icon={route.icon}
title={route.title}
description="Extend Onyx pipelines by registering external API endpoints as callbacks at predefined hook points."
separator
/>
<SettingsLayouts.Body>
<HooksContent />
</SettingsLayouts.Body>
</SettingsLayouts.Root>
<>
{/* Create modal */}
{!!connectSpec && (
<HookFormModal
key={connectSpec?.hook_point ?? "create"}
onOpenChange={(open: boolean) => {
if (!open) setConnectSpec(null);
}}
spec={connectSpec ?? undefined}
onSuccess={handleHookSuccess}
/>
)}
{/* Edit modal */}
{!!editHook && (
<HookFormModal
key={editHook?.id ?? "edit"}
onOpenChange={(open: boolean) => {
if (!open) setEditHook(null);
}}
hook={editHook ?? undefined}
spec={connectSpec_ ?? undefined}
onSuccess={handleHookSuccess}
/>
)}
<SettingsLayouts.Root>
<SettingsLayouts.Header
icon={route.icon}
title={route.title}
description="Extend Onyx pipelines by registering external API endpoints as callbacks at predefined hook points."
separator
/>
<SettingsLayouts.Body>
{isLoading ? (
<SimpleLoader />
) : specsError || hooksError ? (
<Text font="secondary-body" color="text-03">
{`Failed to load${
specsError ? " hook specifications" : " hooks"
}. Please refresh the page.`}
</Text>
) : (
<div className="flex flex-col gap-3 h-full">
<div className="pb-3">
<InputTypeIn
placeholder="Search hooks..."
value={search}
variant="internal"
leftSearchIcon
onChange={(e) => setSearch(e.target.value)}
/>
</div>
{connectedHooks.length === 0 && unconnectedSpecs.length === 0 ? (
<div>
<IllustrationContent
title={
search ? "No results found" : "No hook points available"
}
description={
search ? "Try using a different search term." : undefined
}
illustration={search ? SvgNoResult : SvgEmpty}
/>
</div>
) : (
<div className="flex flex-col gap-2">
{connectedHooks.map((hook) => {
const spec = specs?.find(
(s: HookPointMeta) => s.hook_point === hook.hook_point
);
return (
<ConnectedHookCard
key={hook.id}
hook={hook}
spec={spec}
onEdit={() => setEditHook(hook)}
onDeleted={() => handleHookDeleted(hook.id)}
onToggled={handleHookSuccess}
/>
);
})}
{unconnectedSpecs.map((spec: HookPointMeta) => (
<UnconnectedHookCard
key={spec.hook_point}
spec={spec}
onConnect={() => setConnectSpec(spec)}
/>
))}
</div>
)}
</div>
)}
</SettingsLayouts.Body>
</SettingsLayouts.Root>
</>
);
}

View File

@@ -12,6 +12,7 @@ export enum LLMProviderName {
OPENROUTER = "openrouter",
VERTEX_AI = "vertex_ai",
BEDROCK = "bedrock",
LITELLM = "litellm",
LITELLM_PROXY = "litellm_proxy",
BIFROST = "bifrost",
CUSTOM = "custom",

View File

@@ -4,7 +4,7 @@ import {
SvgActivity,
SvgArrowExchange,
SvgAudio,
SvgHookNodes,
SvgShareWebhook,
SvgBarChart,
SvgBookOpen,
SvgBubbleText,
@@ -230,7 +230,7 @@ export const ADMIN_ROUTES = {
},
HOOKS: {
path: "/admin/hooks",
icon: SvgHookNodes,
icon: SvgShareWebhook,
title: "Hook Extensions",
sidebarLabel: "Hook Extensions",
},

View File

@@ -5,7 +5,6 @@ import {
SvgOpenai,
SvgClaude,
SvgOllama,
SvgCloud,
SvgAws,
SvgOpenrouter,
SvgServer,
@@ -22,7 +21,7 @@ const PROVIDER_ICONS: Record<string, IconFunctionComponent> = {
[LLMProviderName.VERTEX_AI]: SvgGemini,
[LLMProviderName.BEDROCK]: SvgAws,
[LLMProviderName.AZURE]: SvgAzure,
litellm: SvgLitellm,
[LLMProviderName.LITELLM]: SvgLitellm,
[LLMProviderName.LITELLM_PROXY]: SvgLitellm,
[LLMProviderName.OLLAMA_CHAT]: SvgOllama,
[LLMProviderName.OPENROUTER]: SvgOpenrouter,
@@ -39,7 +38,7 @@ const PROVIDER_PRODUCT_NAMES: Record<string, string> = {
[LLMProviderName.VERTEX_AI]: "Gemini",
[LLMProviderName.BEDROCK]: "Amazon Bedrock",
[LLMProviderName.AZURE]: "Azure OpenAI",
litellm: "LiteLLM",
[LLMProviderName.LITELLM]: "LiteLLM",
[LLMProviderName.LITELLM_PROXY]: "LiteLLM Proxy",
[LLMProviderName.OLLAMA_CHAT]: "Ollama",
[LLMProviderName.OPENROUTER]: "OpenRouter",
@@ -56,7 +55,7 @@ const PROVIDER_DISPLAY_NAMES: Record<string, string> = {
[LLMProviderName.VERTEX_AI]: "Google Cloud Vertex AI",
[LLMProviderName.BEDROCK]: "AWS",
[LLMProviderName.AZURE]: "Microsoft Azure",
litellm: "LiteLLM",
[LLMProviderName.LITELLM]: "LiteLLM",
[LLMProviderName.LITELLM_PROXY]: "LiteLLM Proxy",
[LLMProviderName.OLLAMA_CHAT]: "Ollama",
[LLMProviderName.OPENROUTER]: "OpenRouter",

View File

@@ -16,7 +16,6 @@ import { IconProps } from "@opal/types";
import { SvgChevronLeft, SvgChevronRight } from "@opal/icons";
import Text from "./texts/Text";
import { Button } from "@opal/components";
import { Disabled } from "@opal/core";
/* =============================================================================
CONTEXT
@@ -504,24 +503,22 @@ const TabsList = React.forwardRef<
ref={scrollArrowsRef}
className="flex items-center gap-1 pl-2 flex-shrink-0"
>
<Disabled disabled={!canScrollLeft}>
<Button
prominence="tertiary"
size="sm"
icon={SvgChevronLeft}
onClick={handleScrollLeft}
tooltip="Scroll tabs left"
/>
</Disabled>
<Disabled disabled={!canScrollRight}>
<Button
prominence="tertiary"
size="sm"
icon={SvgChevronRight}
onClick={handleScrollRight}
tooltip="Scroll tabs right"
/>
</Disabled>
<Button
disabled={!canScrollLeft}
prominence="tertiary"
size="sm"
icon={SvgChevronLeft}
onClick={handleScrollLeft}
tooltip="Scroll tabs left"
/>
<Button
disabled={!canScrollRight}
prominence="tertiary"
size="sm"
icon={SvgChevronRight}
onClick={handleScrollRight}
tooltip="Scroll tabs right"
/>
</div>
)}

View File

@@ -3,7 +3,6 @@
import React, { memo } from "react";
import Text from "@/refresh-components/texts/Text";
import { Button } from "@opal/components";
import { Disabled } from "@opal/core";
import {
SvgArrowLeft,
SvgArrowRight,
@@ -91,22 +90,20 @@ const SourceTagDetailsCardInner = ({
{showNavigation && (
<div className="flex items-center justify-between p-2 bg-background-tint-01 border-b border-border-01">
<div className="flex items-center gap-1">
<Disabled disabled={isFirst}>
<Button
prominence="internal"
icon={SvgArrowLeft}
onClick={onPrev}
size="sm"
/>
</Disabled>
<Disabled disabled={isLast}>
<Button
prominence="internal"
icon={SvgArrowRight}
onClick={onNext}
size="sm"
/>
</Disabled>
<Button
disabled={isFirst}
prominence="internal"
icon={SvgArrowLeft}
onClick={onPrev}
size="sm"
/>
<Button
disabled={isLast}
prominence="internal"
icon={SvgArrowRight}
onClick={onNext}
size="sm"
/>
</div>
<Text secondaryBody text03 className="px-1">
{currentIndex + 1}/{sources.length}

Some files were not shown because too many files have changed in this diff Show More