mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-04-02 13:32:44 +00:00
Compare commits
28 Commits
feat/resol
...
refactor/f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3272604daa | ||
|
|
a1249c02f1 | ||
|
|
841720033c | ||
|
|
4d2d4845f9 | ||
|
|
78bd54fff0 | ||
|
|
d2043f2bf6 | ||
|
|
5bf0ad985b | ||
|
|
c6974756f5 | ||
|
|
bac273d21d | ||
|
|
160d060077 | ||
|
|
f57c464889 | ||
|
|
1edf070de3 | ||
|
|
b22c47bc67 | ||
|
|
e8fb187e81 | ||
|
|
13a13bd178 | ||
|
|
2a8b8e85de | ||
|
|
e09fe5299c | ||
|
|
7102897328 | ||
|
|
3514901cb2 | ||
|
|
fe6cad4f98 | ||
|
|
11835a0268 | ||
|
|
519fb61cc7 | ||
|
|
02671937fb | ||
|
|
1466158c1e | ||
|
|
073cf11c42 | ||
|
|
a2b0c15027 | ||
|
|
a462678ddd | ||
|
|
c50d2739b8 |
@@ -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")
|
||||
|
||||
|
||||
@@ -42,9 +42,6 @@ from onyx.connectors.google_drive.file_retrieval import (
|
||||
get_all_files_in_my_drive_and_shared,
|
||||
)
|
||||
from onyx.connectors.google_drive.file_retrieval import get_external_access_for_folder
|
||||
from onyx.connectors.google_drive.file_retrieval import (
|
||||
get_files_by_web_view_links_batch,
|
||||
)
|
||||
from onyx.connectors.google_drive.file_retrieval import get_files_in_shared_drive
|
||||
from onyx.connectors.google_drive.file_retrieval import get_folder_metadata
|
||||
from onyx.connectors.google_drive.file_retrieval import get_root_folder_id
|
||||
@@ -73,13 +70,11 @@ from onyx.connectors.interfaces import CheckpointedConnectorWithPermSync
|
||||
from onyx.connectors.interfaces import CheckpointOutput
|
||||
from onyx.connectors.interfaces import GenerateSlimDocumentOutput
|
||||
from onyx.connectors.interfaces import NormalizationResult
|
||||
from onyx.connectors.interfaces import Resolver
|
||||
from onyx.connectors.interfaces import SecondsSinceUnixEpoch
|
||||
from onyx.connectors.interfaces import SlimConnectorWithPermSync
|
||||
from onyx.connectors.models import ConnectorFailure
|
||||
from onyx.connectors.models import ConnectorMissingCredentialError
|
||||
from onyx.connectors.models import Document
|
||||
from onyx.connectors.models import DocumentFailure
|
||||
from onyx.connectors.models import EntityFailure
|
||||
from onyx.connectors.models import HierarchyNode
|
||||
from onyx.connectors.models import SlimDocument
|
||||
@@ -207,9 +202,7 @@ class DriveIdStatus(Enum):
|
||||
|
||||
|
||||
class GoogleDriveConnector(
|
||||
SlimConnectorWithPermSync,
|
||||
CheckpointedConnectorWithPermSync[GoogleDriveCheckpoint],
|
||||
Resolver,
|
||||
SlimConnectorWithPermSync, CheckpointedConnectorWithPermSync[GoogleDriveCheckpoint]
|
||||
):
|
||||
def __init__(
|
||||
self,
|
||||
@@ -1672,82 +1665,6 @@ class GoogleDriveConnector(
|
||||
start, end, checkpoint, include_permissions=True
|
||||
)
|
||||
|
||||
@override
|
||||
def resolve_errors(
|
||||
self,
|
||||
errors: list[ConnectorFailure],
|
||||
include_permissions: bool = False,
|
||||
) -> Generator[Document | ConnectorFailure | HierarchyNode, None, None]:
|
||||
if self._creds is None or self._primary_admin_email is None:
|
||||
raise RuntimeError(
|
||||
"Credentials missing, should not call this method before calling load_credentials"
|
||||
)
|
||||
|
||||
logger.info(f"Resolving {len(errors)} errors")
|
||||
doc_ids = [
|
||||
failure.failed_document.document_id
|
||||
for failure in errors
|
||||
if failure.failed_document
|
||||
]
|
||||
service = get_drive_service(self.creds, self.primary_admin_email)
|
||||
field_type = (
|
||||
DriveFileFieldType.WITH_PERMISSIONS
|
||||
if include_permissions or self.exclude_domain_link_only
|
||||
else DriveFileFieldType.STANDARD
|
||||
)
|
||||
batch_result = get_files_by_web_view_links_batch(service, doc_ids, field_type)
|
||||
|
||||
for doc_id, error in batch_result.errors.items():
|
||||
yield ConnectorFailure(
|
||||
failed_document=DocumentFailure(
|
||||
document_id=doc_id,
|
||||
document_link=doc_id,
|
||||
),
|
||||
failure_message=f"Failed to retrieve file during error resolution: {error}",
|
||||
exception=error,
|
||||
)
|
||||
|
||||
permission_sync_context = (
|
||||
PermissionSyncContext(
|
||||
primary_admin_email=self.primary_admin_email,
|
||||
google_domain=self.google_domain,
|
||||
)
|
||||
if include_permissions
|
||||
else None
|
||||
)
|
||||
|
||||
retrieved_files = [
|
||||
RetrievedDriveFile(
|
||||
drive_file=file,
|
||||
user_email=self.primary_admin_email,
|
||||
completion_stage=DriveRetrievalStage.DONE,
|
||||
)
|
||||
for file in batch_result.files.values()
|
||||
]
|
||||
|
||||
yield from self._get_new_ancestors_for_files(
|
||||
files=retrieved_files,
|
||||
seen_hierarchy_node_raw_ids=ThreadSafeSet(),
|
||||
fully_walked_hierarchy_node_raw_ids=ThreadSafeSet(),
|
||||
permission_sync_context=permission_sync_context,
|
||||
add_prefix=True,
|
||||
)
|
||||
|
||||
func_with_args = [
|
||||
(
|
||||
self._convert_retrieved_file_to_document,
|
||||
(rf, permission_sync_context),
|
||||
)
|
||||
for rf in retrieved_files
|
||||
]
|
||||
results = cast(
|
||||
list[Document | ConnectorFailure | None],
|
||||
run_functions_tuples_in_parallel(func_with_args, max_workers=8),
|
||||
)
|
||||
for result in results:
|
||||
if result is not None:
|
||||
yield result
|
||||
|
||||
def _extract_slim_docs_from_google_drive(
|
||||
self,
|
||||
checkpoint: GoogleDriveCheckpoint,
|
||||
|
||||
@@ -9,7 +9,6 @@ from urllib.parse import urlparse
|
||||
|
||||
from googleapiclient.discovery import Resource # type: ignore
|
||||
from googleapiclient.errors import HttpError # type: ignore
|
||||
from googleapiclient.http import BatchHttpRequest # type: ignore
|
||||
|
||||
from onyx.access.models import ExternalAccess
|
||||
from onyx.connectors.google_drive.constants import DRIVE_FOLDER_TYPE
|
||||
@@ -61,8 +60,6 @@ SLIM_FILE_FIELDS = (
|
||||
)
|
||||
FOLDER_FIELDS = "nextPageToken, files(id, name, permissions, modifiedTime, webViewLink, shortcutDetails)"
|
||||
|
||||
MAX_BATCH_SIZE = 100
|
||||
|
||||
HIERARCHY_FIELDS = "id, name, parents, webViewLink, mimeType, driveId"
|
||||
|
||||
HIERARCHY_FIELDS_WITH_PERMISSIONS = (
|
||||
@@ -219,7 +216,7 @@ def get_external_access_for_folder(
|
||||
|
||||
|
||||
def _get_fields_for_file_type(field_type: DriveFileFieldType) -> str:
|
||||
"""Get the appropriate fields string for files().list() based on the field type enum."""
|
||||
"""Get the appropriate fields string based on the field type enum"""
|
||||
if field_type == DriveFileFieldType.SLIM:
|
||||
return SLIM_FILE_FIELDS
|
||||
elif field_type == DriveFileFieldType.WITH_PERMISSIONS:
|
||||
@@ -228,25 +225,6 @@ def _get_fields_for_file_type(field_type: DriveFileFieldType) -> str:
|
||||
return FILE_FIELDS
|
||||
|
||||
|
||||
def _extract_single_file_fields(list_fields: str) -> str:
|
||||
"""Convert a files().list() fields string to one suitable for files().get().
|
||||
|
||||
List fields look like "nextPageToken, files(field1, field2, ...)"
|
||||
Single-file fields should be just "field1, field2, ..."
|
||||
"""
|
||||
start = list_fields.find("files(")
|
||||
if start == -1:
|
||||
return list_fields
|
||||
inner_start = start + len("files(")
|
||||
inner_end = list_fields.rfind(")")
|
||||
return list_fields[inner_start:inner_end]
|
||||
|
||||
|
||||
def _get_single_file_fields(field_type: DriveFileFieldType) -> str:
|
||||
"""Get the appropriate fields string for files().get() based on the field type enum."""
|
||||
return _extract_single_file_fields(_get_fields_for_file_type(field_type))
|
||||
|
||||
|
||||
def _get_files_in_parent(
|
||||
service: Resource,
|
||||
parent_id: str,
|
||||
@@ -558,74 +536,3 @@ def get_file_by_web_view_link(
|
||||
)
|
||||
.execute()
|
||||
)
|
||||
|
||||
|
||||
class BatchRetrievalResult:
|
||||
"""Result of a batch file retrieval, separating successes from errors."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.files: dict[str, GoogleDriveFileType] = {}
|
||||
self.errors: dict[str, Exception] = {}
|
||||
|
||||
|
||||
def get_files_by_web_view_links_batch(
|
||||
service: GoogleDriveService,
|
||||
web_view_links: list[str],
|
||||
field_type: DriveFileFieldType,
|
||||
) -> BatchRetrievalResult:
|
||||
"""Retrieve multiple Google Drive files by webViewLink using the batch API.
|
||||
|
||||
Returns a BatchRetrievalResult containing successful file retrievals
|
||||
and errors for any files that could not be fetched.
|
||||
Automatically splits into chunks of MAX_BATCH_SIZE.
|
||||
"""
|
||||
fields = _get_single_file_fields(field_type)
|
||||
if len(web_view_links) <= MAX_BATCH_SIZE:
|
||||
return _get_files_by_web_view_links_batch(service, web_view_links, fields)
|
||||
|
||||
combined = BatchRetrievalResult()
|
||||
for i in range(0, len(web_view_links), MAX_BATCH_SIZE):
|
||||
chunk = web_view_links[i : i + MAX_BATCH_SIZE]
|
||||
chunk_result = _get_files_by_web_view_links_batch(service, chunk, fields)
|
||||
combined.files.update(chunk_result.files)
|
||||
combined.errors.update(chunk_result.errors)
|
||||
return combined
|
||||
|
||||
|
||||
def _get_files_by_web_view_links_batch(
|
||||
service: GoogleDriveService,
|
||||
web_view_links: list[str],
|
||||
fields: str,
|
||||
) -> BatchRetrievalResult:
|
||||
"""Single-batch implementation."""
|
||||
|
||||
result = BatchRetrievalResult()
|
||||
|
||||
def callback(
|
||||
request_id: str,
|
||||
response: GoogleDriveFileType,
|
||||
exception: Exception | None,
|
||||
) -> None:
|
||||
if exception:
|
||||
logger.warning(f"Error retrieving file {request_id}: {exception}")
|
||||
result.errors[request_id] = exception
|
||||
else:
|
||||
result.files[request_id] = response
|
||||
|
||||
batch = cast(BatchHttpRequest, service.new_batch_http_request(callback=callback))
|
||||
|
||||
for web_view_link in web_view_links:
|
||||
try:
|
||||
file_id = _extract_file_id_from_web_view_link(web_view_link)
|
||||
request = service.files().get(
|
||||
fileId=file_id,
|
||||
supportsAllDrives=True,
|
||||
fields=fields,
|
||||
)
|
||||
batch.add(request, request_id=web_view_link)
|
||||
except ValueError as e:
|
||||
logger.warning(f"Failed to extract file ID from {web_view_link}: {e}")
|
||||
result.errors[web_view_link] = e
|
||||
|
||||
batch.execute()
|
||||
return result
|
||||
|
||||
@@ -298,22 +298,6 @@ class CheckpointedConnectorWithPermSync(CheckpointedConnector[CT]):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class Resolver(BaseConnector):
|
||||
@abc.abstractmethod
|
||||
def resolve_errors(
|
||||
self,
|
||||
errors: list[ConnectorFailure],
|
||||
include_permissions: bool = False,
|
||||
) -> Generator[Document | ConnectorFailure | HierarchyNode, None, None]:
|
||||
"""Attempts to yield back ALL the documents described by the errors, no checkpointing.
|
||||
|
||||
Caller's responsibility is to delete the old ConnectorFailures and replace with the new ones.
|
||||
If include_permissions is True, the documents will have permissions synced.
|
||||
May also yield HierarchyNode objects for ancestor folders of resolved documents.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class HierarchyConnector(BaseConnector):
|
||||
@abc.abstractmethod
|
||||
def load_hierarchy(
|
||||
|
||||
@@ -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))
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 "",
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
198
backend/tests/unit/onyx/file_processing/test_xlsx_to_text.py
Normal file
198
backend/tests/unit/onyx/file_processing/test_xlsx_to_text.py
Normal 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]
|
||||
@@ -2,6 +2,7 @@ import threading
|
||||
from typing import Any
|
||||
from typing import cast
|
||||
from typing import List
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import Mock
|
||||
from unittest.mock import patch
|
||||
|
||||
@@ -12,8 +13,13 @@ from onyx.connectors.models import Document
|
||||
from onyx.connectors.models import DocumentSource
|
||||
from onyx.connectors.models import ImageSection
|
||||
from onyx.connectors.models import TextSection
|
||||
from onyx.hooks.executor import HookSkipped
|
||||
from onyx.hooks.executor import HookSoftFailed
|
||||
from onyx.hooks.points.document_ingestion import DocumentIngestionResponse
|
||||
from onyx.hooks.points.document_ingestion import DocumentIngestionSection
|
||||
from onyx.indexing.chunker import Chunker
|
||||
from onyx.indexing.embedder import DefaultIndexingEmbedder
|
||||
from onyx.indexing.indexing_pipeline import _apply_document_ingestion_hook
|
||||
from onyx.indexing.indexing_pipeline import add_contextual_summaries
|
||||
from onyx.indexing.indexing_pipeline import filter_documents
|
||||
from onyx.indexing.indexing_pipeline import process_image_sections
|
||||
@@ -223,3 +229,148 @@ def test_contextual_rag(
|
||||
count += 1
|
||||
assert chunk.doc_summary == doc_summary
|
||||
assert chunk.chunk_context == chunk_context
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _apply_document_ingestion_hook
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_PATCH_EXECUTE_HOOK = "onyx.indexing.indexing_pipeline.execute_hook"
|
||||
|
||||
|
||||
def _make_doc(
|
||||
doc_id: str = "doc1",
|
||||
sections: list[TextSection | ImageSection] | None = None,
|
||||
) -> Document:
|
||||
if sections is None:
|
||||
sections = [TextSection(text="Hello", link="http://example.com")]
|
||||
return Document(
|
||||
id=doc_id,
|
||||
title="Test Doc",
|
||||
semantic_identifier="test-doc",
|
||||
sections=cast(list[TextSection | ImageSection], sections),
|
||||
source=DocumentSource.FILE,
|
||||
metadata={},
|
||||
)
|
||||
|
||||
|
||||
def test_document_ingestion_hook_skipped_passes_through() -> None:
|
||||
doc = _make_doc()
|
||||
with patch(_PATCH_EXECUTE_HOOK, return_value=HookSkipped()):
|
||||
result = _apply_document_ingestion_hook([doc], MagicMock())
|
||||
assert result == [doc]
|
||||
|
||||
|
||||
def test_document_ingestion_hook_soft_failed_passes_through() -> None:
|
||||
doc = _make_doc()
|
||||
with patch(_PATCH_EXECUTE_HOOK, return_value=HookSoftFailed()):
|
||||
result = _apply_document_ingestion_hook([doc], MagicMock())
|
||||
assert result == [doc]
|
||||
|
||||
|
||||
def test_document_ingestion_hook_none_sections_drops_document() -> None:
|
||||
doc = _make_doc()
|
||||
with patch(
|
||||
_PATCH_EXECUTE_HOOK,
|
||||
return_value=DocumentIngestionResponse(
|
||||
sections=None, rejection_reason="PII detected"
|
||||
),
|
||||
):
|
||||
result = _apply_document_ingestion_hook([doc], MagicMock())
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_document_ingestion_hook_all_invalid_sections_drops_document() -> None:
|
||||
"""A non-empty list where every section has neither text nor image_file_id drops the doc."""
|
||||
doc = _make_doc()
|
||||
with patch(
|
||||
_PATCH_EXECUTE_HOOK,
|
||||
return_value=DocumentIngestionResponse(sections=[DocumentIngestionSection()]),
|
||||
):
|
||||
result = _apply_document_ingestion_hook([doc], MagicMock())
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_document_ingestion_hook_empty_sections_drops_document() -> None:
|
||||
doc = _make_doc()
|
||||
with patch(
|
||||
_PATCH_EXECUTE_HOOK,
|
||||
return_value=DocumentIngestionResponse(sections=[]),
|
||||
):
|
||||
result = _apply_document_ingestion_hook([doc], MagicMock())
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_document_ingestion_hook_rewrites_text_sections() -> None:
|
||||
doc = _make_doc(sections=[TextSection(text="original", link="http://a.com")])
|
||||
with patch(
|
||||
_PATCH_EXECUTE_HOOK,
|
||||
return_value=DocumentIngestionResponse(
|
||||
sections=[DocumentIngestionSection(text="rewritten", link="http://b.com")]
|
||||
),
|
||||
):
|
||||
result = _apply_document_ingestion_hook([doc], MagicMock())
|
||||
assert len(result) == 1
|
||||
assert len(result[0].sections) == 1
|
||||
section = result[0].sections[0]
|
||||
assert isinstance(section, TextSection)
|
||||
assert section.text == "rewritten"
|
||||
assert section.link == "http://b.com"
|
||||
|
||||
|
||||
def test_document_ingestion_hook_preserves_image_section_order() -> None:
|
||||
"""Hook receives all sections including images and controls final ordering."""
|
||||
image = ImageSection(image_file_id="img-1", link=None)
|
||||
doc = _make_doc(
|
||||
sections=cast(
|
||||
list[TextSection | ImageSection],
|
||||
[TextSection(text="original", link=None), image],
|
||||
)
|
||||
)
|
||||
# Hook moves the image before the text section
|
||||
with patch(
|
||||
_PATCH_EXECUTE_HOOK,
|
||||
return_value=DocumentIngestionResponse(
|
||||
sections=[
|
||||
DocumentIngestionSection(image_file_id="img-1", link=None),
|
||||
DocumentIngestionSection(text="rewritten", link=None),
|
||||
]
|
||||
),
|
||||
):
|
||||
result = _apply_document_ingestion_hook([doc], MagicMock())
|
||||
assert len(result) == 1
|
||||
sections = result[0].sections
|
||||
assert len(sections) == 2
|
||||
assert (
|
||||
isinstance(sections[0], ImageSection) and sections[0].image_file_id == "img-1"
|
||||
)
|
||||
assert isinstance(sections[1], TextSection) and sections[1].text == "rewritten"
|
||||
|
||||
|
||||
def test_document_ingestion_hook_mixed_batch() -> None:
|
||||
"""Drop one doc, rewrite another, pass through a third."""
|
||||
doc_drop = _make_doc(doc_id="drop")
|
||||
doc_rewrite = _make_doc(doc_id="rewrite")
|
||||
doc_skip = _make_doc(doc_id="skip")
|
||||
|
||||
def _side_effect(**kwargs: Any) -> Any:
|
||||
doc_id = kwargs["payload"]["document_id"]
|
||||
if doc_id == "drop":
|
||||
return DocumentIngestionResponse(sections=None)
|
||||
if doc_id == "rewrite":
|
||||
return DocumentIngestionResponse(
|
||||
sections=[DocumentIngestionSection(text="new text", link=None)]
|
||||
)
|
||||
return HookSkipped()
|
||||
|
||||
with patch(_PATCH_EXECUTE_HOOK, side_effect=_side_effect):
|
||||
result = _apply_document_ingestion_hook(
|
||||
[doc_drop, doc_rewrite, doc_skip], MagicMock()
|
||||
)
|
||||
|
||||
assert len(result) == 2
|
||||
ids = {d.id for d in result}
|
||||
assert ids == {"rewrite", "skip"}
|
||||
rewritten = next(d for d in result if d.id == "rewrite")
|
||||
assert isinstance(rewritten.sections[0], TextSection)
|
||||
assert rewritten.sections[0].text == "new text"
|
||||
|
||||
@@ -231,6 +231,23 @@ import { Hoverable } from "@opal/core";
|
||||
|
||||
# Best Practices
|
||||
|
||||
## 0. Size Variant Defaults
|
||||
|
||||
**When using `SizeVariants` (or any subset like `PaddingVariants`, `RoundingVariants`) as a prop
|
||||
type, always default to `"md"`.**
|
||||
|
||||
**Reason:** `"md"` is the standard middle-of-the-road preset across the design system. Consistent
|
||||
defaults make components predictable — callers only need to specify a size when they want something
|
||||
other than the norm.
|
||||
|
||||
```typescript
|
||||
// ✅ Good — default to "md"
|
||||
function MyCard({ padding = "md", rounding = "md" }: MyCardProps) { ... }
|
||||
|
||||
// ❌ Bad — arbitrary or inconsistent defaults
|
||||
function MyCard({ padding = "sm", rounding = "lg" }: MyCardProps) { ... }
|
||||
```
|
||||
|
||||
## 1. Tailwind Dark Mode
|
||||
|
||||
**Strictly forbid using the `dark:` modifier in Tailwind classes, except for logo icon handling.**
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -29,7 +29,7 @@ export const BackgroundVariants: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-col gap-4 w-96">
|
||||
{BACKGROUND_VARIANTS.map((bg) => (
|
||||
<Card key={bg} backgroundVariant={bg} borderVariant="solid">
|
||||
<Card key={bg} background={bg} border="solid">
|
||||
<p>backgroundVariant: {bg}</p>
|
||||
</Card>
|
||||
))}
|
||||
@@ -41,7 +41,7 @@ export const BorderVariants: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-col gap-4 w-96">
|
||||
{BORDER_VARIANTS.map((border) => (
|
||||
<Card key={border} borderVariant={border}>
|
||||
<Card key={border} border={border}>
|
||||
<p>borderVariant: {border}</p>
|
||||
</Card>
|
||||
))}
|
||||
@@ -53,7 +53,7 @@ export const PaddingVariants: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-col gap-4 w-96">
|
||||
{PADDING_VARIANTS.map((padding) => (
|
||||
<Card key={padding} paddingVariant={padding} borderVariant="solid">
|
||||
<Card key={padding} padding={padding} border="solid">
|
||||
<p>paddingVariant: {padding}</p>
|
||||
</Card>
|
||||
))}
|
||||
@@ -65,7 +65,7 @@ export const RoundingVariants: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-col gap-4 w-96">
|
||||
{ROUNDING_VARIANTS.map((rounding) => (
|
||||
<Card key={rounding} roundingVariant={rounding} borderVariant="solid">
|
||||
<Card key={rounding} rounding={rounding} border="solid">
|
||||
<p>roundingVariant: {rounding}</p>
|
||||
</Card>
|
||||
))}
|
||||
@@ -84,9 +84,9 @@ export const AllCombinations: Story = {
|
||||
BORDER_VARIANTS.map((border) => (
|
||||
<Card
|
||||
key={`${padding}-${bg}-${border}`}
|
||||
paddingVariant={padding}
|
||||
backgroundVariant={bg}
|
||||
borderVariant={border}
|
||||
padding={padding}
|
||||
background={bg}
|
||||
border={border}
|
||||
>
|
||||
<p className="text-xs">
|
||||
bg: {bg}, border: {border}
|
||||
|
||||
@@ -8,30 +8,30 @@ A plain container component with configurable background, border, padding, and r
|
||||
|
||||
Padding and rounding are controlled independently:
|
||||
|
||||
| `paddingVariant` | Class |
|
||||
|------------------|---------|
|
||||
| `"lg"` | `p-6` |
|
||||
| `"md"` | `p-4` |
|
||||
| `"sm"` | `p-2` |
|
||||
| `"xs"` | `p-1` |
|
||||
| `"2xs"` | `p-0.5` |
|
||||
| `"fit"` | `p-0` |
|
||||
| `padding` | Class |
|
||||
|-----------|---------|
|
||||
| `"lg"` | `p-6` |
|
||||
| `"md"` | `p-4` |
|
||||
| `"sm"` | `p-2` |
|
||||
| `"xs"` | `p-1` |
|
||||
| `"2xs"` | `p-0.5` |
|
||||
| `"fit"` | `p-0` |
|
||||
|
||||
| `roundingVariant` | Class |
|
||||
|-------------------|--------------|
|
||||
| `"xs"` | `rounded-04` |
|
||||
| `"sm"` | `rounded-08` |
|
||||
| `"md"` | `rounded-12` |
|
||||
| `"lg"` | `rounded-16` |
|
||||
| `rounding` | Class |
|
||||
|------------|--------------|
|
||||
| `"xs"` | `rounded-04` |
|
||||
| `"sm"` | `rounded-08` |
|
||||
| `"md"` | `rounded-12` |
|
||||
| `"lg"` | `rounded-16` |
|
||||
|
||||
## Props
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `paddingVariant` | `PaddingVariants` | `"sm"` | Padding preset |
|
||||
| `roundingVariant` | `RoundingVariants` | `"md"` | Border-radius preset |
|
||||
| `backgroundVariant` | `"none" \| "light" \| "heavy"` | `"light"` | Background fill intensity |
|
||||
| `borderVariant` | `"none" \| "dashed" \| "solid"` | `"none"` | Border style |
|
||||
| `padding` | `PaddingVariants` | `"sm"` | Padding preset |
|
||||
| `rounding` | `RoundingVariants` | `"md"` | Border-radius preset |
|
||||
| `background` | `"none" \| "light" \| "heavy"` | `"light"` | Background fill intensity |
|
||||
| `border` | `"none" \| "dashed" \| "solid"` | `"none"` | Border style |
|
||||
| `ref` | `React.Ref<HTMLDivElement>` | — | Ref forwarded to the root div |
|
||||
| `children` | `React.ReactNode` | — | Card content |
|
||||
|
||||
@@ -47,17 +47,17 @@ import { Card } from "@opal/components";
|
||||
</Card>
|
||||
|
||||
// Large padding + rounding with solid border
|
||||
<Card paddingVariant="lg" roundingVariant="lg" borderVariant="solid">
|
||||
<Card padding="lg" rounding="lg" border="solid">
|
||||
<p>Spacious card</p>
|
||||
</Card>
|
||||
|
||||
// Compact card with solid border
|
||||
<Card paddingVariant="xs" roundingVariant="sm" borderVariant="solid">
|
||||
<Card padding="xs" rounding="sm" border="solid">
|
||||
<p>Compact card</p>
|
||||
</Card>
|
||||
|
||||
// Empty state card
|
||||
<Card backgroundVariant="none" borderVariant="dashed">
|
||||
<Card background="none" border="dashed">
|
||||
<p>No items yet</p>
|
||||
</Card>
|
||||
```
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import "@opal/components/cards/card/styles.css";
|
||||
import type { PaddingVariants, RoundingVariants } from "@opal/types";
|
||||
import { cardPaddingVariants, cardRoundingVariants } from "@opal/shared";
|
||||
import { cn } from "@opal/utils";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -22,9 +23,9 @@ type CardProps = {
|
||||
* | `"2xs"` | `p-0.5` |
|
||||
* | `"fit"` | `p-0` |
|
||||
*
|
||||
* @default "sm"
|
||||
* @default "md"
|
||||
*/
|
||||
paddingVariant?: PaddingVariants;
|
||||
padding?: PaddingVariants;
|
||||
|
||||
/**
|
||||
* Border-radius preset.
|
||||
@@ -38,7 +39,7 @@ type CardProps = {
|
||||
*
|
||||
* @default "md"
|
||||
*/
|
||||
roundingVariant?: RoundingVariants;
|
||||
rounding?: RoundingVariants;
|
||||
|
||||
/**
|
||||
* Background fill intensity.
|
||||
@@ -48,7 +49,7 @@ type CardProps = {
|
||||
*
|
||||
* @default "light"
|
||||
*/
|
||||
backgroundVariant?: BackgroundVariant;
|
||||
background?: BackgroundVariant;
|
||||
|
||||
/**
|
||||
* Border style.
|
||||
@@ -58,7 +59,7 @@ type CardProps = {
|
||||
*
|
||||
* @default "none"
|
||||
*/
|
||||
borderVariant?: BorderVariant;
|
||||
border?: BorderVariant;
|
||||
|
||||
/** Ref forwarded to the root `<div>`. */
|
||||
ref?: React.Ref<HTMLDivElement>;
|
||||
@@ -66,47 +67,27 @@ type CardProps = {
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mappings
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const paddingForVariant: Record<PaddingVariants, string> = {
|
||||
lg: "p-6",
|
||||
md: "p-4",
|
||||
sm: "p-2",
|
||||
xs: "p-1",
|
||||
"2xs": "p-0.5",
|
||||
fit: "p-0",
|
||||
};
|
||||
|
||||
const roundingForVariant: Record<RoundingVariants, string> = {
|
||||
lg: "rounded-16",
|
||||
md: "rounded-12",
|
||||
sm: "rounded-08",
|
||||
xs: "rounded-04",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Card
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function Card({
|
||||
paddingVariant = "sm",
|
||||
roundingVariant = "md",
|
||||
backgroundVariant = "light",
|
||||
borderVariant = "none",
|
||||
padding: paddingProp = "md",
|
||||
rounding: roundingProp = "md",
|
||||
background = "light",
|
||||
border = "none",
|
||||
ref,
|
||||
children,
|
||||
}: CardProps) {
|
||||
const padding = paddingForVariant[paddingVariant];
|
||||
const rounding = roundingForVariant[roundingVariant];
|
||||
const padding = cardPaddingVariants[paddingProp];
|
||||
const rounding = cardRoundingVariants[roundingProp];
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("opal-card", padding, rounding)}
|
||||
data-background={backgroundVariant}
|
||||
data-border={borderVariant}
|
||||
data-background={background}
|
||||
data-border={border}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { EmptyMessageCard } from "@opal/components";
|
||||
import { SvgSparkle, SvgUsers } from "@opal/icons";
|
||||
|
||||
const SIZE_VARIANTS = ["lg", "md", "sm", "xs", "2xs", "fit"] as const;
|
||||
const PADDING_VARIANTS = ["fit", "2xs", "xs", "sm", "md", "lg"] as const;
|
||||
|
||||
const meta: Meta<typeof EmptyMessageCard> = {
|
||||
title: "opal/components/EmptyMessageCard",
|
||||
@@ -26,14 +26,14 @@ export const WithCustomIcon: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const SizeVariants: Story = {
|
||||
export const PaddingVariants: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-col gap-4 w-96">
|
||||
{SIZE_VARIANTS.map((size) => (
|
||||
{PADDING_VARIANTS.map((padding) => (
|
||||
<EmptyMessageCard
|
||||
key={size}
|
||||
sizeVariant={size}
|
||||
title={`sizeVariant: ${size}`}
|
||||
key={padding}
|
||||
padding={padding}
|
||||
title={`padding: ${padding}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -6,12 +6,12 @@ A pre-configured Card for empty states. Renders a transparent card with a dashed
|
||||
|
||||
## Props
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ----------------- | --------------------------- | ---------- | ------------------------------------------------ |
|
||||
| `icon` | `IconFunctionComponent` | `SvgEmpty` | Icon displayed alongside the title |
|
||||
| `title` | `string` | — | Primary message text (required) |
|
||||
| `paddingVariant` | `PaddingVariants` | `"sm"` | Padding preset for the card |
|
||||
| `ref` | `React.Ref<HTMLDivElement>` | — | Ref forwarded to the root div |
|
||||
| Prop | Type | Default | Description |
|
||||
| --------- | --------------------------- | ---------- | -------------------------------- |
|
||||
| `icon` | `IconFunctionComponent` | `SvgEmpty` | Icon displayed alongside the title |
|
||||
| `title` | `string` | — | Primary message text (required) |
|
||||
| `padding` | `PaddingVariants` | `"sm"` | Padding preset for the card |
|
||||
| `ref` | `React.Ref<HTMLDivElement>` | — | Ref forwarded to the root div |
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -26,5 +26,5 @@ import { SvgSparkle, SvgFileText } from "@opal/icons";
|
||||
<EmptyMessageCard icon={SvgSparkle} title="No agents selected." />
|
||||
|
||||
// With custom padding
|
||||
<EmptyMessageCard paddingVariant="xs" icon={SvgFileText} title="No documents available." />
|
||||
<EmptyMessageCard padding="xs" icon={SvgFileText} title="No documents available." />
|
||||
```
|
||||
|
||||
@@ -14,8 +14,8 @@ type EmptyMessageCardProps = {
|
||||
/** Primary message text. */
|
||||
title: string;
|
||||
|
||||
/** Padding preset for the card. */
|
||||
paddingVariant?: PaddingVariants;
|
||||
/** Padding preset for the card. @default "md" */
|
||||
padding?: PaddingVariants;
|
||||
|
||||
/** Ref forwarded to the root Card div. */
|
||||
ref?: React.Ref<HTMLDivElement>;
|
||||
@@ -28,15 +28,16 @@ type EmptyMessageCardProps = {
|
||||
function EmptyMessageCard({
|
||||
icon = SvgEmpty,
|
||||
title,
|
||||
paddingVariant = "sm",
|
||||
padding = "md",
|
||||
ref,
|
||||
}: EmptyMessageCardProps) {
|
||||
return (
|
||||
<Card
|
||||
ref={ref}
|
||||
backgroundVariant="none"
|
||||
borderVariant="dashed"
|
||||
paddingVariant={paddingVariant}
|
||||
background="none"
|
||||
border="dashed"
|
||||
padding={padding}
|
||||
rounding="md"
|
||||
>
|
||||
<Content
|
||||
icon={icon}
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
**Import:** `import { SelectCard, type SelectCardProps } from "@opal/components";`
|
||||
|
||||
A stateful interactive card — the card counterpart to [`SelectButton`](../../buttons/select-button/README.md). Built on `Interactive.Stateful` (Slot) with a structural `<div>` that owns padding, rounding, border, and overflow.
|
||||
A stateful interactive card — the card counterpart to [`SelectButton`](../../buttons/select-button/README.md). Built on `Interactive.Stateful` (Slot) with a structural `<div>` that owns padding, rounding, border, and overflow. Always uses the `select-card` Interactive.Stateful variant internally.
|
||||
|
||||
## Relationship to Card
|
||||
|
||||
`Card` is a plain, non-interactive container. `SelectCard` adds stateful interactivity (hover, active, disabled, state-driven colors) by wrapping its root div with `Interactive.Stateful`. The relationship mirrors `Button` (stateless) vs `SelectButton` (stateful).
|
||||
`Card` is a plain, non-interactive container. `SelectCard` adds stateful interactivity (hover, active, disabled, state-driven colors) by wrapping its root div with `Interactive.Stateful`. Both share the same independent `padding` / `rounding` API.
|
||||
|
||||
## Relationship to SelectButton
|
||||
|
||||
@@ -18,15 +18,15 @@ Interactive.Stateful → structural element → content
|
||||
|
||||
The key differences:
|
||||
|
||||
- SelectCard renders a `<div>` (not `Interactive.Container`) — cards have their own rounding scale (one notch larger than buttons) and don't need Container's height/min-width.
|
||||
- SelectCard renders a `<div>` (not `Interactive.Container`) — cards have their own rounding scale and don't need Container's height/min-width.
|
||||
- SelectCard has no `foldable` prop — use `Interactive.Foldable` directly inside children.
|
||||
- SelectCard's children are fully composable — use `CardHeaderLayout`, `ContentAction`, `Content`, buttons, etc. inside.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Interactive.Stateful <- variant, state, interaction, disabled, onClick
|
||||
└─ div.opal-select-card <- padding, rounding, border, overflow
|
||||
Interactive.Stateful (variant="select-card") <- state, interaction, disabled, onClick
|
||||
└─ div.opal-select-card <- padding, rounding, border, overflow
|
||||
└─ children (composable)
|
||||
```
|
||||
|
||||
@@ -34,28 +34,36 @@ The `Interactive.Stateful` Slot merges onto the div, producing a single DOM elem
|
||||
|
||||
## Props
|
||||
|
||||
Inherits **all** props from `InteractiveStatefulProps` (variant, state, interaction, onClick, href, etc.) plus:
|
||||
Inherits **all** props from `InteractiveStatefulProps` (except `variant`, which is hardcoded to `select-card`) plus:
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `sizeVariant` | `ContainerSizeVariants` | `"lg"` | Controls padding and border-radius |
|
||||
| `padding` | `PaddingVariants` | `"sm"` | Padding preset |
|
||||
| `rounding` | `RoundingVariants` | `"lg"` | Border-radius preset |
|
||||
| `ref` | `React.Ref<HTMLDivElement>` | — | Ref forwarded to the root div |
|
||||
| `children` | `React.ReactNode` | — | Card content |
|
||||
|
||||
### Padding scale
|
||||
|
||||
| `padding` | Class |
|
||||
|-----------|---------|
|
||||
| `"lg"` | `p-6` |
|
||||
| `"md"` | `p-4` |
|
||||
| `"sm"` | `p-2` |
|
||||
| `"xs"` | `p-1` |
|
||||
| `"2xs"` | `p-0.5` |
|
||||
| `"fit"` | `p-0` |
|
||||
|
||||
### Rounding scale
|
||||
|
||||
Cards use a bumped-up rounding scale compared to buttons:
|
||||
| `rounding` | Class |
|
||||
|------------|--------------|
|
||||
| `"xs"` | `rounded-04` |
|
||||
| `"sm"` | `rounded-08` |
|
||||
| `"md"` | `rounded-12` |
|
||||
| `"lg"` | `rounded-16` |
|
||||
|
||||
| Size | Rounding | Effective radius |
|
||||
|---|---|---|
|
||||
| `lg` | `rounded-16` | 1rem (16px) |
|
||||
| `md`–`sm` | `rounded-12` | 0.75rem (12px) |
|
||||
| `xs`–`2xs` | `rounded-08` | 0.5rem (8px) |
|
||||
| `fit` | `rounded-16` | 1rem (16px) |
|
||||
|
||||
### Recommended variant: `select-card`
|
||||
|
||||
The `select-card` Interactive.Stateful variant is specifically designed for cards. Unlike `select-heavy` (which only changes foreground color between empty and filled), `select-card` gives the filled state a visible background — important on larger surfaces where background carries more of the visual distinction.
|
||||
### State colors (`select-card` variant)
|
||||
|
||||
| State | Rest background | Rest foreground |
|
||||
|---|---|---|
|
||||
@@ -82,7 +90,7 @@ All background and foreground colors come from the Interactive.Stateful CSS, not
|
||||
import { SelectCard } from "@opal/components";
|
||||
import { CardHeaderLayout } from "@opal/layouts";
|
||||
|
||||
<SelectCard variant="select-card" state="selected" onClick={handleClick}>
|
||||
<SelectCard state="selected" onClick={handleClick}>
|
||||
<CardHeaderLayout
|
||||
icon={SvgGlobe}
|
||||
title="Google"
|
||||
@@ -100,7 +108,7 @@ import { CardHeaderLayout } from "@opal/layouts";
|
||||
### Disconnected state (clickable)
|
||||
|
||||
```tsx
|
||||
<SelectCard variant="select-card" state="empty" onClick={handleConnect}>
|
||||
<SelectCard state="empty" onClick={handleConnect}>
|
||||
<CardHeaderLayout
|
||||
icon={SvgCloud}
|
||||
title="OpenAI"
|
||||
@@ -115,7 +123,7 @@ import { CardHeaderLayout } from "@opal/layouts";
|
||||
### With foldable hover-reveal
|
||||
|
||||
```tsx
|
||||
<SelectCard variant="select-card" state="filled">
|
||||
<SelectCard state="filled">
|
||||
<CardHeaderLayout
|
||||
icon={SvgCloud}
|
||||
title="OpenAI"
|
||||
|
||||
@@ -21,7 +21,8 @@ const withTooltipProvider: Decorator = (Story) => (
|
||||
);
|
||||
|
||||
const STATES = ["empty", "filled", "selected"] as const;
|
||||
const SIZE_VARIANTS = ["lg", "md", "sm", "xs", "2xs", "fit"] as const;
|
||||
const PADDING_VARIANTS = ["fit", "2xs", "xs", "sm", "md", "lg"] as const;
|
||||
const ROUNDING_VARIANTS = ["xs", "sm", "md", "lg"] as const;
|
||||
|
||||
const meta = {
|
||||
title: "opal/components/SelectCard",
|
||||
@@ -44,7 +45,7 @@ type Story = StoryObj<typeof meta>;
|
||||
export const Default: Story = {
|
||||
render: () => (
|
||||
<div className="w-96">
|
||||
<SelectCard variant="select-card" state="empty">
|
||||
<SelectCard state="empty">
|
||||
<div className="p-2">
|
||||
<Content
|
||||
sizePreset="main-ui"
|
||||
@@ -63,7 +64,7 @@ export const AllStates: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-col gap-4 w-96">
|
||||
{STATES.map((state) => (
|
||||
<SelectCard key={state} variant="select-card" state={state}>
|
||||
<SelectCard key={state} state={state}>
|
||||
<div className="p-2">
|
||||
<Content
|
||||
sizePreset="main-ui"
|
||||
@@ -82,11 +83,7 @@ export const AllStates: Story = {
|
||||
export const Clickable: Story = {
|
||||
render: () => (
|
||||
<div className="w-96">
|
||||
<SelectCard
|
||||
variant="select-card"
|
||||
state="empty"
|
||||
onClick={() => alert("Card clicked")}
|
||||
>
|
||||
<SelectCard state="empty" onClick={() => alert("Card clicked")}>
|
||||
<div className="p-2">
|
||||
<Content
|
||||
sizePreset="main-ui"
|
||||
@@ -105,7 +102,7 @@ export const WithActions: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-col gap-4 w-[28rem]">
|
||||
{/* Disconnected */}
|
||||
<SelectCard variant="select-card" state="empty" onClick={() => {}}>
|
||||
<SelectCard state="empty" onClick={() => {}}>
|
||||
<div className="flex flex-row items-stretch w-full">
|
||||
<div className="flex-1 p-2">
|
||||
<Content
|
||||
@@ -125,7 +122,7 @@ export const WithActions: Story = {
|
||||
</SelectCard>
|
||||
|
||||
{/* Connected with foldable */}
|
||||
<SelectCard variant="select-card" state="filled">
|
||||
<SelectCard state="filled">
|
||||
<div className="flex flex-row items-stretch w-full">
|
||||
<div className="flex-1 p-2">
|
||||
<Content
|
||||
@@ -163,7 +160,7 @@ export const WithActions: Story = {
|
||||
</SelectCard>
|
||||
|
||||
{/* Selected */}
|
||||
<SelectCard variant="select-card" state="selected">
|
||||
<SelectCard state="selected">
|
||||
<div className="flex flex-row items-stretch w-full">
|
||||
<div className="flex-1 p-2">
|
||||
<Content
|
||||
@@ -203,22 +200,17 @@ export const WithActions: Story = {
|
||||
),
|
||||
};
|
||||
|
||||
export const SizeVariants: Story = {
|
||||
export const PaddingVariants: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-col gap-4 w-96">
|
||||
{SIZE_VARIANTS.map((size) => (
|
||||
<SelectCard
|
||||
key={size}
|
||||
variant="select-card"
|
||||
state="filled"
|
||||
sizeVariant={size}
|
||||
>
|
||||
{PADDING_VARIANTS.map((padding) => (
|
||||
<SelectCard key={padding} state="filled" padding={padding}>
|
||||
<Content
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
icon={SvgGlobe}
|
||||
title={`sizeVariant: ${size}`}
|
||||
description="Shows padding and rounding differences."
|
||||
title={`paddingVariant: ${padding}`}
|
||||
description="Shows padding differences."
|
||||
/>
|
||||
</SelectCard>
|
||||
))}
|
||||
@@ -226,20 +218,18 @@ export const SizeVariants: Story = {
|
||||
),
|
||||
};
|
||||
|
||||
export const SelectHeavyVariant: Story = {
|
||||
export const RoundingVariants: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-col gap-4 w-96">
|
||||
{STATES.map((state) => (
|
||||
<SelectCard key={state} variant="select-heavy" state={state}>
|
||||
<div className="p-2">
|
||||
<Content
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
icon={SvgGlobe}
|
||||
title={`select-heavy / ${state}`}
|
||||
description="For comparison with select-card variant."
|
||||
/>
|
||||
</div>
|
||||
{ROUNDING_VARIANTS.map((rounding) => (
|
||||
<SelectCard key={rounding} state="filled" rounding={rounding}>
|
||||
<Content
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
icon={SvgGlobe}
|
||||
title={`roundingVariant: ${rounding}`}
|
||||
description="Shows rounding differences."
|
||||
/>
|
||||
</SelectCard>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import "@opal/components/cards/select-card/styles.css";
|
||||
import type { ContainerSizeVariants } from "@opal/types";
|
||||
import { containerSizeVariants } from "@opal/shared";
|
||||
import type { PaddingVariants, RoundingVariants } from "@opal/types";
|
||||
import { cardPaddingVariants, cardRoundingVariants } from "@opal/shared";
|
||||
import { cn } from "@opal/utils";
|
||||
import { Interactive, type InteractiveStatefulProps } from "@opal/core";
|
||||
|
||||
@@ -8,23 +8,36 @@ import { Interactive, type InteractiveStatefulProps } from "@opal/core";
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type SelectCardProps = InteractiveStatefulProps & {
|
||||
type SelectCardProps = Omit<InteractiveStatefulProps, "variant"> & {
|
||||
/**
|
||||
* Size preset — controls padding and border-radius.
|
||||
* Padding preset.
|
||||
*
|
||||
* Padding comes from the shared size scale. Rounding follows the same
|
||||
* mapping as `Card` / `Button` / `Interactive.Container`:
|
||||
* | Value | Class |
|
||||
* |---------|---------|
|
||||
* | `"lg"` | `p-6` |
|
||||
* | `"md"` | `p-4` |
|
||||
* | `"sm"` | `p-2` |
|
||||
* | `"xs"` | `p-1` |
|
||||
* | `"2xs"` | `p-0.5` |
|
||||
* | `"fit"` | `p-0` |
|
||||
*
|
||||
* | Size | Rounding |
|
||||
* |------------|--------------|
|
||||
* | `lg` | `rounded-16` |
|
||||
* | `md`–`sm` | `rounded-12` |
|
||||
* | `xs`–`2xs` | `rounded-08` |
|
||||
* | `fit` | `rounded-16` |
|
||||
*
|
||||
* @default "lg"
|
||||
* @default "md"
|
||||
*/
|
||||
sizeVariant?: ContainerSizeVariants;
|
||||
padding?: PaddingVariants;
|
||||
|
||||
/**
|
||||
* Border-radius preset.
|
||||
*
|
||||
* | Value | Class |
|
||||
* |--------|--------------|
|
||||
* | `"xs"` | `rounded-04` |
|
||||
* | `"sm"` | `rounded-08` |
|
||||
* | `"md"` | `rounded-12` |
|
||||
* | `"lg"` | `rounded-16` |
|
||||
*
|
||||
* @default "md"
|
||||
*/
|
||||
rounding?: RoundingVariants;
|
||||
|
||||
/** Ref forwarded to the root `<div>`. */
|
||||
ref?: React.Ref<HTMLDivElement>;
|
||||
@@ -32,19 +45,6 @@ type SelectCardProps = InteractiveStatefulProps & {
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rounding
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const roundingForSize: Record<ContainerSizeVariants, string> = {
|
||||
lg: "rounded-16",
|
||||
md: "rounded-12",
|
||||
sm: "rounded-12",
|
||||
xs: "rounded-08",
|
||||
"2xs": "rounded-08",
|
||||
fit: "rounded-16",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SelectCard
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -61,7 +61,7 @@ const roundingForSize: Record<ContainerSizeVariants, string> = {
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <SelectCard variant="select-card" state="selected" onClick={handleClick}>
|
||||
* <SelectCard state="selected" onClick={handleClick}>
|
||||
* <ContentAction
|
||||
* icon={SvgGlobe}
|
||||
* title="Google"
|
||||
@@ -72,16 +72,17 @@ const roundingForSize: Record<ContainerSizeVariants, string> = {
|
||||
* ```
|
||||
*/
|
||||
function SelectCard({
|
||||
sizeVariant = "lg",
|
||||
padding: paddingProp = "md",
|
||||
rounding: roundingProp = "md",
|
||||
ref,
|
||||
children,
|
||||
...statefulProps
|
||||
}: SelectCardProps) {
|
||||
const { padding } = containerSizeVariants[sizeVariant];
|
||||
const rounding = roundingForSize[sizeVariant];
|
||||
const padding = cardPaddingVariants[paddingProp];
|
||||
const rounding = cardRoundingVariants[roundingProp];
|
||||
|
||||
return (
|
||||
<Interactive.Stateful {...statefulProps}>
|
||||
<Interactive.Stateful {...statefulProps} variant="select-card">
|
||||
<div ref={ref} className={cn("opal-select-card", padding, rounding)}>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
28
web/lib/opal/src/core/interactive/utils.ts
Normal file
28
web/lib/opal/src/core/interactive/utils.ts
Normal 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 };
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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[] = [
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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..."
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 & {
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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 & 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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 & 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user