Compare commits

..

8 Commits

Author SHA1 Message Date
Evan Lohn
5b45b7fc87 lite stuff 2026-03-04 18:20:17 -08:00
Evan Lohn
f46afd70fb chore: update install script 2026-03-04 18:12:22 -08:00
acaprau
c5c236d098 chore(opensearch): Fix and consolidate the dev script used to start OpenSearch locally (#9036) 2026-03-05 01:54:02 +00:00
Danelegend
b18baff4d0 fix: Correct file_id for docs (#9058) 2026-03-05 01:43:58 +00:00
SubashMohan
eb3e15c195 feat(table): add ColumnVisibilityPopover, Footer, Pagination, and SortingPopover components (#9019)
Co-authored-by: Nik <nikolas.garza5@gmail.com>
2026-03-05 01:43:37 +00:00
acaprau
47d9a9e1ac feat(document index): Re-enable search settings swap (#9005) 2026-03-05 01:41:03 +00:00
Evan Lohn
aca466b35d fix: doc to hierarchynode connection in pruning (#9046) 2026-03-05 01:30:36 +00:00
Justin Tahara
5176fd7386 fix(llm): Final LLM Cleanup for Nightly Tests (#9055) 2026-03-05 01:00:45 +00:00
154 changed files with 3752 additions and 2564 deletions

15
.vscode/launch.json vendored
View File

@@ -485,21 +485,6 @@
"group": "3"
}
},
{
"name": "Clear and Restart OpenSearch Container",
// Generic debugger type, required arg but has no bearing on bash.
"type": "node",
"request": "launch",
"runtimeExecutable": "bash",
"runtimeArgs": [
"${workspaceFolder}/backend/scripts/restart_opensearch_container.sh"
],
"cwd": "${workspaceFolder}",
"console": "integratedTerminal",
"presentation": {
"group": "3"
}
},
{
"name": "Eval CLI",
"type": "debugpy",

View File

@@ -39,9 +39,13 @@ CT = TypeVar("CT", bound=ConnectorCheckpoint)
class SlimConnectorExtractionResult(BaseModel):
"""Result of extracting document IDs and hierarchy nodes from a connector."""
"""Result of extracting document IDs and hierarchy nodes from a connector.
doc_ids: set[str]
raw_id_to_parent maps document ID → parent_hierarchy_raw_node_id (or None).
Use raw_id_to_parent.keys() wherever the old set of IDs was needed.
"""
raw_id_to_parent: dict[str, str | None]
hierarchy_nodes: list[HierarchyNode]
@@ -93,30 +97,37 @@ def _get_failure_id(failure: ConnectorFailure) -> str | None:
return None
class BatchResult(BaseModel):
raw_id_to_parent: dict[str, str | None]
hierarchy_nodes: list[HierarchyNode]
def _extract_from_batch(
doc_list: Sequence[Document | SlimDocument | HierarchyNode | ConnectorFailure],
) -> tuple[set[str], list[HierarchyNode]]:
"""Separate a batch into document IDs and hierarchy nodes.
) -> BatchResult:
"""Separate a batch into document IDs (with parent mapping) and hierarchy nodes.
ConnectorFailure items have their failed document/entity IDs added to the
ID set so that failed-to-retrieve documents are not accidentally pruned.
ID dict so that failed-to-retrieve documents are not accidentally pruned.
"""
ids: set[str] = set()
ids: dict[str, str | None] = {}
hierarchy_nodes: list[HierarchyNode] = []
for item in doc_list:
if isinstance(item, HierarchyNode):
hierarchy_nodes.append(item)
ids.add(item.raw_node_id)
if item.raw_node_id not in ids:
ids[item.raw_node_id] = None
elif isinstance(item, ConnectorFailure):
failed_id = _get_failure_id(item)
if failed_id:
ids.add(failed_id)
ids[failed_id] = None
logger.warning(
f"Failed to retrieve document {failed_id}: " f"{item.failure_message}"
)
else:
ids.add(item.id)
return ids, hierarchy_nodes
parent_raw = getattr(item, "parent_hierarchy_raw_node_id", None)
ids[item.id] = parent_raw
return BatchResult(raw_id_to_parent=ids, hierarchy_nodes=hierarchy_nodes)
def extract_ids_from_runnable_connector(
@@ -132,7 +143,7 @@ def extract_ids_from_runnable_connector(
Optionally, a callback can be passed to handle the length of each document batch.
"""
all_connector_doc_ids: set[str] = set()
all_raw_id_to_parent: dict[str, str | None] = {}
all_hierarchy_nodes: list[HierarchyNode] = []
# Sequence (covariant) lets all the specific list[...] iterator types unify here
@@ -177,15 +188,20 @@ def extract_ids_from_runnable_connector(
"extract_ids_from_runnable_connector: Stop signal detected"
)
batch_ids, batch_nodes = _extract_from_batch(doc_list)
all_connector_doc_ids.update(doc_batch_processing_func(batch_ids))
batch_result = _extract_from_batch(doc_list)
batch_ids = batch_result.raw_id_to_parent
batch_nodes = batch_result.hierarchy_nodes
doc_batch_processing_func(batch_ids)
for k, v in batch_ids.items():
if v is not None or k not in all_raw_id_to_parent:
all_raw_id_to_parent[k] = v
all_hierarchy_nodes.extend(batch_nodes)
if callback:
callback.progress("extract_ids_from_runnable_connector", len(batch_ids))
return SlimConnectorExtractionResult(
doc_ids=all_connector_doc_ids,
raw_id_to_parent=all_raw_id_to_parent,
hierarchy_nodes=all_hierarchy_nodes,
)

View File

@@ -29,6 +29,7 @@ from onyx.configs.constants import CELERY_GENERIC_BEAT_LOCK_TIMEOUT
from onyx.configs.constants import CELERY_PRUNING_LOCK_TIMEOUT
from onyx.configs.constants import CELERY_TASK_WAIT_FOR_FENCE_TIMEOUT
from onyx.configs.constants import DANSWER_REDIS_FUNCTION_LOCK_PREFIX
from onyx.configs.constants import DocumentSource
from onyx.configs.constants import OnyxCeleryPriority
from onyx.configs.constants import OnyxCeleryQueues
from onyx.configs.constants import OnyxCeleryTask
@@ -47,6 +48,8 @@ from onyx.db.enums import AccessType
from onyx.db.enums import ConnectorCredentialPairStatus
from onyx.db.enums import SyncStatus
from onyx.db.enums import SyncType
from onyx.db.hierarchy import link_hierarchy_nodes_to_documents
from onyx.db.hierarchy import update_document_parent_hierarchy_nodes
from onyx.db.hierarchy import upsert_hierarchy_nodes_batch
from onyx.db.models import ConnectorCredentialPair
from onyx.db.sync_record import insert_sync_record
@@ -57,6 +60,8 @@ from onyx.redis.redis_connector_prune import RedisConnectorPrune
from onyx.redis.redis_connector_prune import RedisConnectorPrunePayload
from onyx.redis.redis_hierarchy import cache_hierarchy_nodes_batch
from onyx.redis.redis_hierarchy import ensure_source_node_exists
from onyx.redis.redis_hierarchy import get_node_id_from_raw_id
from onyx.redis.redis_hierarchy import get_source_node_id_from_cache
from onyx.redis.redis_hierarchy import HierarchyNodeCacheEntry
from onyx.redis.redis_pool import get_redis_client
from onyx.redis.redis_pool import get_redis_replica_client
@@ -113,6 +118,38 @@ class PruneCallback(IndexingCallbackBase):
super().progress(tag, amount)
def _resolve_and_update_document_parents(
db_session: Session,
redis_client: Redis,
source: DocumentSource,
raw_id_to_parent: dict[str, str | None],
) -> None:
"""Resolve parent_hierarchy_raw_node_id → parent_hierarchy_node_id for
each document and bulk-update the DB. Mirrors the resolution logic in
run_docfetching.py."""
source_node_id = get_source_node_id_from_cache(redis_client, db_session, source)
resolved: dict[str, int | None] = {}
for doc_id, raw_parent_id in raw_id_to_parent.items():
if raw_parent_id is None:
continue
node_id, found = get_node_id_from_raw_id(redis_client, source, raw_parent_id)
resolved[doc_id] = node_id if found else source_node_id
if not resolved:
return
update_document_parent_hierarchy_nodes(
db_session=db_session,
doc_parent_map=resolved,
commit=True,
)
task_logger.info(
f"Pruning: resolved and updated parent hierarchy for "
f"{len(resolved)} documents (source={source.value})"
)
"""Jobs / utils for kicking off pruning tasks."""
@@ -535,22 +572,22 @@ def connector_pruning_generator_task(
extraction_result = extract_ids_from_runnable_connector(
runnable_connector, callback
)
all_connector_doc_ids = extraction_result.doc_ids
all_connector_doc_ids = extraction_result.raw_id_to_parent
# Process hierarchy nodes (same as docfetching):
# upsert to Postgres and cache in Redis
source = cc_pair.connector.source
redis_client = get_redis_client(tenant_id=tenant_id)
if extraction_result.hierarchy_nodes:
is_connector_public = cc_pair.access_type == AccessType.PUBLIC
redis_client = get_redis_client(tenant_id=tenant_id)
ensure_source_node_exists(
redis_client, db_session, cc_pair.connector.source
)
ensure_source_node_exists(redis_client, db_session, source)
upserted_nodes = upsert_hierarchy_nodes_batch(
db_session=db_session,
nodes=extraction_result.hierarchy_nodes,
source=cc_pair.connector.source,
source=source,
commit=True,
is_connector_public=is_connector_public,
)
@@ -561,7 +598,7 @@ def connector_pruning_generator_task(
]
cache_hierarchy_nodes_batch(
redis_client=redis_client,
source=cc_pair.connector.source,
source=source,
entries=cache_entries,
)
@@ -570,6 +607,26 @@ def connector_pruning_generator_task(
f"hierarchy nodes for cc_pair={cc_pair_id}"
)
ensure_source_node_exists(redis_client, db_session, source)
# Resolve parent_hierarchy_raw_node_id → parent_hierarchy_node_id
# and bulk-update documents, mirroring the docfetching resolution
_resolve_and_update_document_parents(
db_session=db_session,
redis_client=redis_client,
source=source,
raw_id_to_parent=all_connector_doc_ids,
)
# Link hierarchy nodes to documents for sources where pages can be
# both hierarchy nodes AND documents (e.g. Notion, Confluence)
all_doc_id_list = list(all_connector_doc_ids.keys())
link_hierarchy_nodes_to_documents(
db_session=db_session,
document_ids=all_doc_id_list,
source=source,
commit=True,
)
# a list of docs in our local index
all_indexed_document_ids = {
doc.id
@@ -581,7 +638,9 @@ def connector_pruning_generator_task(
}
# generate list of docs to remove (no longer in the source)
doc_ids_to_remove = list(all_indexed_document_ids - all_connector_doc_ids)
doc_ids_to_remove = list(
all_indexed_document_ids - all_connector_doc_ids.keys()
)
task_logger.info(
"Pruning set collected: "

View File

@@ -943,6 +943,9 @@ class ConfluenceConnector(
if include_permissions
else None
),
parent_hierarchy_raw_node_id=self._get_parent_hierarchy_raw_id(
page
),
)
)
@@ -992,6 +995,7 @@ class ConfluenceConnector(
if include_permissions
else None
),
parent_hierarchy_raw_node_id=page_id,
)
)

View File

@@ -781,4 +781,5 @@ def build_slim_document(
return SlimDocument(
id=onyx_document_id_from_drive_file(file),
external_access=external_access,
parent_hierarchy_raw_node_id=(file.get("parents") or [None])[0],
)

View File

@@ -902,6 +902,11 @@ class JiraConnector(
external_access=self._get_project_permissions(
project_key, add_prefix=False
),
parent_hierarchy_raw_node_id=(
self._get_parent_hierarchy_raw_node_id(issue, project_key)
if project_key
else None
),
)
)
current_offset += 1

View File

@@ -385,6 +385,7 @@ class IndexingDocument(Document):
class SlimDocument(BaseModel):
id: str
external_access: ExternalAccess | None = None
parent_hierarchy_raw_node_id: str | None = None
class HierarchyNode(BaseModel):

View File

@@ -772,6 +772,7 @@ def _convert_driveitem_to_slim_document(
drive_name: str,
ctx: ClientContext,
graph_client: GraphClient,
parent_hierarchy_raw_node_id: str | None = None,
) -> SlimDocument:
if driveitem.id is None:
raise ValueError("DriveItem ID is required")
@@ -787,11 +788,15 @@ def _convert_driveitem_to_slim_document(
return SlimDocument(
id=driveitem.id,
external_access=external_access,
parent_hierarchy_raw_node_id=parent_hierarchy_raw_node_id,
)
def _convert_sitepage_to_slim_document(
site_page: dict[str, Any], ctx: ClientContext | None, graph_client: GraphClient
site_page: dict[str, Any],
ctx: ClientContext | None,
graph_client: GraphClient,
parent_hierarchy_raw_node_id: str | None = None,
) -> SlimDocument:
"""Convert a SharePoint site page to a SlimDocument object."""
if site_page.get("id") is None:
@@ -808,6 +813,7 @@ def _convert_sitepage_to_slim_document(
return SlimDocument(
id=id,
external_access=external_access,
parent_hierarchy_raw_node_id=parent_hierarchy_raw_node_id,
)
@@ -1594,12 +1600,22 @@ class SharepointConnector(
)
)
parent_hierarchy_url: str | None = None
if drive_web_url:
parent_hierarchy_url = self._get_parent_hierarchy_url(
site_url, drive_web_url, drive_name, driveitem
)
try:
logger.debug(f"Processing: {driveitem.web_url}")
ctx = self._create_rest_client_context(site_descriptor.url)
doc_batch.append(
_convert_driveitem_to_slim_document(
driveitem, drive_name, ctx, self.graph_client
driveitem,
drive_name,
ctx,
self.graph_client,
parent_hierarchy_raw_node_id=parent_hierarchy_url,
)
)
except Exception as e:
@@ -1619,7 +1635,10 @@ class SharepointConnector(
ctx = self._create_rest_client_context(site_descriptor.url)
doc_batch.append(
_convert_sitepage_to_slim_document(
site_page, ctx, self.graph_client
site_page,
ctx,
self.graph_client,
parent_hierarchy_raw_node_id=site_descriptor.url,
)
)
if len(doc_batch) >= SLIM_BATCH_SIZE:

View File

@@ -565,6 +565,7 @@ def _get_all_doc_ids(
channel_id=channel_id, thread_ts=message["ts"]
),
external_access=external_access,
parent_hierarchy_raw_node_id=channel_id,
)
)

View File

@@ -1,5 +1,7 @@
"""CRUD operations for HierarchyNode."""
from collections import defaultdict
from sqlalchemy import select
from sqlalchemy.orm import Session
@@ -525,6 +527,53 @@ def get_document_parent_hierarchy_node_ids(
return {doc_id: parent_id for doc_id, parent_id in results}
def update_document_parent_hierarchy_nodes(
db_session: Session,
doc_parent_map: dict[str, int | None],
commit: bool = True,
) -> int:
"""Bulk-update Document.parent_hierarchy_node_id for multiple documents.
Only updates rows whose current value differs from the desired value to
avoid unnecessary writes.
Args:
db_session: SQLAlchemy session
doc_parent_map: Mapping of document_id → desired parent_hierarchy_node_id
commit: Whether to commit the transaction
Returns:
Number of documents actually updated
"""
if not doc_parent_map:
return 0
doc_ids = list(doc_parent_map.keys())
existing = get_document_parent_hierarchy_node_ids(db_session, doc_ids)
by_parent: dict[int | None, list[str]] = defaultdict(list)
for doc_id, desired_parent_id in doc_parent_map.items():
current = existing.get(doc_id)
if current == desired_parent_id or doc_id not in existing:
continue
by_parent[desired_parent_id].append(doc_id)
updated = 0
for desired_parent_id, ids in by_parent.items():
db_session.query(Document).filter(Document.id.in_(ids)).update(
{Document.parent_hierarchy_node_id: desired_parent_id},
synchronize_session=False,
)
updated += len(ids)
if commit:
db_session.commit()
elif updated:
db_session.flush()
return updated
def update_hierarchy_node_permissions(
db_session: Session,
raw_node_id: str,

View File

@@ -129,7 +129,7 @@ def get_current_search_settings(db_session: Session) -> SearchSettings:
latest_settings = result.scalars().first()
if not latest_settings:
raise RuntimeError("No search settings specified, DB is not in a valid state")
raise RuntimeError("No search settings specified; DB is not in a valid state.")
return latest_settings

View File

@@ -32,9 +32,6 @@ def get_multipass_config(search_settings: SearchSettings) -> MultipassConfig:
Determines whether to enable multipass and large chunks by examining
the current search settings and the embedder configuration.
"""
if not search_settings:
return MultipassConfig(multipass_indexing=False, enable_large_chunks=False)
multipass = should_use_multipass(search_settings)
enable_large_chunks = SearchSettings.can_use_large_chunks(
multipass, search_settings.model_name, search_settings.provider_type

View File

@@ -26,11 +26,10 @@ def get_default_document_index(
To be used for retrieval only. Indexing should be done through both indices
until Vespa is deprecated.
Pre-existing docstring for this function, although secondary indices are not
currently supported:
Primary index is the index that is used for querying/updating etc. Secondary
index is for when both the currently used index and the upcoming index both
need to be updated, updates are applied to both indices.
need to be updated. Updates are applied to both indices.
WARNING: In that case, get_all_document_indices should be used.
"""
if DISABLE_VECTOR_DB:
return DisabledDocumentIndex(
@@ -51,11 +50,26 @@ def get_default_document_index(
opensearch_retrieval_enabled = get_opensearch_retrieval_state(db_session)
if opensearch_retrieval_enabled:
indexing_setting = IndexingSetting.from_db_model(search_settings)
secondary_indexing_setting = (
IndexingSetting.from_db_model(secondary_search_settings)
if secondary_search_settings
else None
)
return OpenSearchOldDocumentIndex(
index_name=search_settings.index_name,
embedding_dim=indexing_setting.final_embedding_dim,
embedding_precision=indexing_setting.embedding_precision,
secondary_index_name=secondary_index_name,
secondary_embedding_dim=(
secondary_indexing_setting.final_embedding_dim
if secondary_indexing_setting
else None
),
secondary_embedding_precision=(
secondary_indexing_setting.embedding_precision
if secondary_indexing_setting
else None
),
large_chunks_enabled=search_settings.large_chunks_enabled,
secondary_large_chunks_enabled=secondary_large_chunks_enabled,
multitenant=MULTI_TENANT,
@@ -86,8 +100,7 @@ def get_all_document_indices(
Used for indexing only. Until Vespa is deprecated we will index into both
document indices. Retrieval is done through only one index however.
Large chunks and secondary indices are not currently supported so we
hardcode appropriate values.
Large chunks are not currently supported so we hardcode appropriate values.
NOTE: Make sure the Vespa index object is returned first. In the rare event
that there is some conflict between indexing and the migration task, it is
@@ -123,13 +136,36 @@ def get_all_document_indices(
opensearch_document_index: OpenSearchOldDocumentIndex | None = None
if ENABLE_OPENSEARCH_INDEXING_FOR_ONYX:
indexing_setting = IndexingSetting.from_db_model(search_settings)
secondary_indexing_setting = (
IndexingSetting.from_db_model(secondary_search_settings)
if secondary_search_settings
else None
)
opensearch_document_index = OpenSearchOldDocumentIndex(
index_name=search_settings.index_name,
embedding_dim=indexing_setting.final_embedding_dim,
embedding_precision=indexing_setting.embedding_precision,
secondary_index_name=None,
large_chunks_enabled=False,
secondary_large_chunks_enabled=None,
secondary_index_name=(
secondary_search_settings.index_name
if secondary_search_settings
else None
),
secondary_embedding_dim=(
secondary_indexing_setting.final_embedding_dim
if secondary_indexing_setting
else None
),
secondary_embedding_precision=(
secondary_indexing_setting.embedding_precision
if secondary_indexing_setting
else None
),
large_chunks_enabled=search_settings.large_chunks_enabled,
secondary_large_chunks_enabled=(
secondary_search_settings.large_chunks_enabled
if secondary_search_settings
else None
),
multitenant=MULTI_TENANT,
httpx_client=httpx_client,
)

View File

@@ -271,6 +271,9 @@ class OpenSearchOldDocumentIndex(OldDocumentIndex):
embedding_dim: int,
embedding_precision: EmbeddingPrecision,
secondary_index_name: str | None,
secondary_embedding_dim: int | None,
secondary_embedding_precision: EmbeddingPrecision | None,
# NOTE: We do not support large chunks right now.
large_chunks_enabled: bool, # noqa: ARG002
secondary_large_chunks_enabled: bool | None, # noqa: ARG002
multitenant: bool = False,
@@ -286,12 +289,25 @@ class OpenSearchOldDocumentIndex(OldDocumentIndex):
f"Expected {MULTI_TENANT}, got {multitenant}."
)
tenant_id = get_current_tenant_id()
tenant_state = TenantState(tenant_id=tenant_id, multitenant=multitenant)
self._real_index = OpenSearchDocumentIndex(
tenant_state=TenantState(tenant_id=tenant_id, multitenant=multitenant),
tenant_state=tenant_state,
index_name=index_name,
embedding_dim=embedding_dim,
embedding_precision=embedding_precision,
)
self._secondary_real_index: OpenSearchDocumentIndex | None = None
if self.secondary_index_name:
if secondary_embedding_dim is None or secondary_embedding_precision is None:
raise ValueError(
"Bug: Secondary index embedding dimension and precision are not set."
)
self._secondary_real_index = OpenSearchDocumentIndex(
tenant_state=tenant_state,
index_name=self.secondary_index_name,
embedding_dim=secondary_embedding_dim,
embedding_precision=secondary_embedding_precision,
)
@staticmethod
def register_multitenant_indices(
@@ -307,19 +323,38 @@ class OpenSearchOldDocumentIndex(OldDocumentIndex):
self,
primary_embedding_dim: int,
primary_embedding_precision: EmbeddingPrecision,
secondary_index_embedding_dim: int | None, # noqa: ARG002
secondary_index_embedding_precision: EmbeddingPrecision | None, # noqa: ARG002
secondary_index_embedding_dim: int | None,
secondary_index_embedding_precision: EmbeddingPrecision | None,
) -> None:
# Only handle primary index for now, ignore secondary.
return self._real_index.verify_and_create_index_if_necessary(
self._real_index.verify_and_create_index_if_necessary(
primary_embedding_dim, primary_embedding_precision
)
if self.secondary_index_name:
if (
secondary_index_embedding_dim is None
or secondary_index_embedding_precision is None
):
raise ValueError(
"Bug: Secondary index embedding dimension and precision are not set."
)
assert (
self._secondary_real_index is not None
), "Bug: Secondary index is not initialized."
self._secondary_real_index.verify_and_create_index_if_necessary(
secondary_index_embedding_dim, secondary_index_embedding_precision
)
def index(
self,
chunks: list[DocMetadataAwareIndexChunk],
index_batch_params: IndexBatchParams,
) -> set[OldDocumentInsertionRecord]:
"""
NOTE: Do NOT consider the secondary index here. A separate indexing
pipeline will be responsible for indexing to the secondary index. This
design is not ideal and we should reconsider this when revamping index
swapping.
"""
# Convert IndexBatchParams to IndexingMetadata.
chunk_counts: dict[str, IndexingMetadata.ChunkCounts] = {}
for doc_id in index_batch_params.doc_id_to_new_chunk_cnt:
@@ -351,7 +386,20 @@ class OpenSearchOldDocumentIndex(OldDocumentIndex):
tenant_id: str, # noqa: ARG002
chunk_count: int | None,
) -> int:
return self._real_index.delete(doc_id, chunk_count)
"""
NOTE: Remember to handle the secondary index here. There is no separate
pipeline for deleting chunks in the secondary index. This design is not
ideal and we should reconsider this when revamping index swapping.
"""
total_chunks_deleted = self._real_index.delete(doc_id, chunk_count)
if self.secondary_index_name:
assert (
self._secondary_real_index is not None
), "Bug: Secondary index is not initialized."
total_chunks_deleted += self._secondary_real_index.delete(
doc_id, chunk_count
)
return total_chunks_deleted
def update_single(
self,
@@ -362,6 +410,11 @@ class OpenSearchOldDocumentIndex(OldDocumentIndex):
fields: VespaDocumentFields | None,
user_fields: VespaDocumentUserFields | None,
) -> None:
"""
NOTE: Remember to handle the secondary index here. There is no separate
pipeline for updating chunks in the secondary index. This design is not
ideal and we should reconsider this when revamping index swapping.
"""
if fields is None and user_fields is None:
logger.warning(
f"Tried to update document {doc_id} with no updated fields or user fields."
@@ -392,6 +445,11 @@ class OpenSearchOldDocumentIndex(OldDocumentIndex):
try:
self._real_index.update([update_request])
if self.secondary_index_name:
assert (
self._secondary_real_index is not None
), "Bug: Secondary index is not initialized."
self._secondary_real_index.update([update_request])
except NotFoundError:
logger.exception(
f"Tried to update document {doc_id} but at least one of its chunks was not found in OpenSearch. "

View File

@@ -465,6 +465,12 @@ class VespaIndex(DocumentIndex):
chunks: list[DocMetadataAwareIndexChunk],
index_batch_params: IndexBatchParams,
) -> set[OldDocumentInsertionRecord]:
"""
NOTE: Do NOT consider the secondary index here. A separate indexing
pipeline will be responsible for indexing to the secondary index. This
design is not ideal and we should reconsider this when revamping index
swapping.
"""
if len(index_batch_params.doc_id_to_previous_chunk_cnt) != len(
index_batch_params.doc_id_to_new_chunk_cnt
):
@@ -659,6 +665,10 @@ class VespaIndex(DocumentIndex):
"""Note: if the document id does not exist, the update will be a no-op and the
function will complete with no errors or exceptions.
Handle other exceptions if you wish to implement retry behavior
NOTE: Remember to handle the secondary index here. There is no separate
pipeline for updating chunks in the secondary index. This design is not
ideal and we should reconsider this when revamping index swapping.
"""
if fields is None and user_fields is None:
logger.warning(
@@ -679,13 +689,6 @@ class VespaIndex(DocumentIndex):
f"Bug: Tenant ID mismatch. Expected {tenant_state.tenant_id}, got {tenant_id}."
)
vespa_document_index = VespaDocumentIndex(
index_name=self.index_name,
tenant_state=tenant_state,
large_chunks_enabled=self.large_chunks_enabled,
httpx_client=self.httpx_client,
)
project_ids: set[int] | None = None
if user_fields is not None and user_fields.user_projects is not None:
project_ids = set(user_fields.user_projects)
@@ -705,7 +708,20 @@ class VespaIndex(DocumentIndex):
persona_ids=persona_ids,
)
vespa_document_index.update([update_request])
indices = [self.index_name]
if self.secondary_index_name:
indices.append(self.secondary_index_name)
for index_name in indices:
vespa_document_index = VespaDocumentIndex(
index_name=index_name,
tenant_state=tenant_state,
large_chunks_enabled=self.index_to_large_chunks_enabled.get(
index_name, False
),
httpx_client=self.httpx_client,
)
vespa_document_index.update([update_request])
def delete_single(
self,
@@ -714,6 +730,11 @@ class VespaIndex(DocumentIndex):
tenant_id: str,
chunk_count: int | None,
) -> int:
"""
NOTE: Remember to handle the secondary index here. There is no separate
pipeline for deleting chunks in the secondary index. This design is not
ideal and we should reconsider this when revamping index swapping.
"""
tenant_state = TenantState(
tenant_id=get_current_tenant_id(),
multitenant=MULTI_TENANT,
@@ -726,13 +747,25 @@ class VespaIndex(DocumentIndex):
raise ValueError(
f"Bug: Tenant ID mismatch. Expected {tenant_state.tenant_id}, got {tenant_id}."
)
vespa_document_index = VespaDocumentIndex(
index_name=self.index_name,
tenant_state=tenant_state,
large_chunks_enabled=self.large_chunks_enabled,
httpx_client=self.httpx_client,
)
return vespa_document_index.delete(document_id=doc_id, chunk_count=chunk_count)
indices = [self.index_name]
if self.secondary_index_name:
indices.append(self.secondary_index_name)
total_chunks_deleted = 0
for index_name in indices:
vespa_document_index = VespaDocumentIndex(
index_name=index_name,
tenant_state=tenant_state,
large_chunks_enabled=self.index_to_large_chunks_enabled.get(
index_name, False
),
httpx_client=self.httpx_client,
)
total_chunks_deleted += vespa_document_index.delete(
document_id=doc_id, chunk_count=chunk_count
)
return total_chunks_deleted
def id_based_retrieval(
self,

View File

@@ -6,8 +6,11 @@ from sqlalchemy.orm import Session
from onyx.auth.users import current_admin_user
from onyx.auth.users import current_user
from onyx.configs.app_configs import DISABLE_INDEX_UPDATE_ON_SWAP
from onyx.context.search.models import SavedSearchSettings
from onyx.context.search.models import SearchSettingsCreationRequest
from onyx.db.connector_credential_pair import get_connector_credential_pairs
from onyx.db.connector_credential_pair import resync_cc_pair
from onyx.db.engine.sql_engine import get_session
from onyx.db.index_attempt import expire_index_attempts
from onyx.db.llm import fetch_existing_llm_provider
@@ -15,20 +18,25 @@ from onyx.db.llm import update_default_contextual_model
from onyx.db.llm import update_no_default_contextual_rag_provider
from onyx.db.models import IndexModelStatus
from onyx.db.models import User
from onyx.db.search_settings import create_search_settings
from onyx.db.search_settings import delete_search_settings
from onyx.db.search_settings import get_current_search_settings
from onyx.db.search_settings import get_embedding_provider_from_provider_type
from onyx.db.search_settings import get_secondary_search_settings
from onyx.db.search_settings import update_current_search_settings
from onyx.db.search_settings import update_search_settings_status
from onyx.document_index.factory import get_all_document_indices
from onyx.document_index.factory import get_default_document_index
from onyx.file_processing.unstructured import delete_unstructured_api_key
from onyx.file_processing.unstructured import get_unstructured_api_key
from onyx.file_processing.unstructured import update_unstructured_api_key
from onyx.natural_language_processing.search_nlp_models import clean_model_name
from onyx.server.manage.embedding.models import SearchSettingsDeleteRequest
from onyx.server.manage.models import FullModelVersionResponse
from onyx.server.models import IdReturn
from onyx.server.utils_vector_db import require_vector_db
from onyx.utils.logger import setup_logger
from shared_configs.configs import ALT_INDEX_SUFFIX
from shared_configs.configs import MULTI_TENANT
router = APIRouter(prefix="/search-settings")
@@ -41,110 +49,99 @@ def set_new_search_settings(
_: User = Depends(current_admin_user),
db_session: Session = Depends(get_session), # noqa: ARG001
) -> IdReturn:
"""Creates a new EmbeddingModel row and cancels the previous secondary indexing if any
Gives an error if the same model name is used as the current or secondary index
"""
# TODO(andrei): Re-enable.
# NOTE Enable integration external dependency tests in test_search_settings.py
# when this is reenabled. They are currently skipped
logger.error("Setting new search settings is temporarily disabled.")
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail="Setting new search settings is temporarily disabled.",
Creates a new SearchSettings row and cancels the previous secondary indexing
if any exists.
"""
if search_settings_new.index_name:
logger.warning("Index name was specified by request, this is not suggested")
# Disallow contextual RAG for cloud deployments.
if MULTI_TENANT and search_settings_new.enable_contextual_rag:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Contextual RAG disabled in Onyx Cloud",
)
# Validate cloud provider exists or create new LiteLLM provider.
if search_settings_new.provider_type is not None:
cloud_provider = get_embedding_provider_from_provider_type(
db_session, provider_type=search_settings_new.provider_type
)
if cloud_provider is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"No embedding provider exists for cloud embedding type {search_settings_new.provider_type}",
)
validate_contextual_rag_model(
provider_name=search_settings_new.contextual_rag_llm_provider,
model_name=search_settings_new.contextual_rag_llm_name,
db_session=db_session,
)
# if search_settings_new.index_name:
# logger.warning("Index name was specified by request, this is not suggested")
# # Disallow contextual RAG for cloud deployments
# if MULTI_TENANT and search_settings_new.enable_contextual_rag:
# raise HTTPException(
# status_code=status.HTTP_400_BAD_REQUEST,
# detail="Contextual RAG disabled in Onyx Cloud",
# )
search_settings = get_current_search_settings(db_session)
# # Validate cloud provider exists or create new LiteLLM provider
# if search_settings_new.provider_type is not None:
# cloud_provider = get_embedding_provider_from_provider_type(
# db_session, provider_type=search_settings_new.provider_type
# )
if search_settings_new.index_name is None:
# We define index name here.
index_name = f"danswer_chunk_{clean_model_name(search_settings_new.model_name)}"
if (
search_settings_new.model_name == search_settings.model_name
and not search_settings.index_name.endswith(ALT_INDEX_SUFFIX)
):
index_name += ALT_INDEX_SUFFIX
search_values = search_settings_new.model_dump()
search_values["index_name"] = index_name
new_search_settings_request = SavedSearchSettings(**search_values)
else:
new_search_settings_request = SavedSearchSettings(
**search_settings_new.model_dump()
)
# if cloud_provider is None:
# raise HTTPException(
# status_code=status.HTTP_400_BAD_REQUEST,
# detail=f"No embedding provider exists for cloud embedding type {search_settings_new.provider_type}",
# )
secondary_search_settings = get_secondary_search_settings(db_session)
# validate_contextual_rag_model(
# provider_name=search_settings_new.contextual_rag_llm_provider,
# model_name=search_settings_new.contextual_rag_llm_name,
# db_session=db_session,
# )
if secondary_search_settings:
# Cancel any background indexing jobs.
expire_index_attempts(
search_settings_id=secondary_search_settings.id, db_session=db_session
)
# search_settings = get_current_search_settings(db_session)
# Mark previous model as a past model directly.
update_search_settings_status(
search_settings=secondary_search_settings,
new_status=IndexModelStatus.PAST,
db_session=db_session,
)
# if search_settings_new.index_name is None:
# # We define index name here
# index_name = f"danswer_chunk_{clean_model_name(search_settings_new.model_name)}"
# if (
# search_settings_new.model_name == search_settings.model_name
# and not search_settings.index_name.endswith(ALT_INDEX_SUFFIX)
# ):
# index_name += ALT_INDEX_SUFFIX
# search_values = search_settings_new.model_dump()
# search_values["index_name"] = index_name
# new_search_settings_request = SavedSearchSettings(**search_values)
# else:
# new_search_settings_request = SavedSearchSettings(
# **search_settings_new.model_dump()
# )
new_search_settings = create_search_settings(
search_settings=new_search_settings_request, db_session=db_session
)
# secondary_search_settings = get_secondary_search_settings(db_session)
# Ensure the document indices have the new index immediately.
document_indices = get_all_document_indices(search_settings, new_search_settings)
for document_index in document_indices:
document_index.ensure_indices_exist(
primary_embedding_dim=search_settings.final_embedding_dim,
primary_embedding_precision=search_settings.embedding_precision,
secondary_index_embedding_dim=new_search_settings.final_embedding_dim,
secondary_index_embedding_precision=new_search_settings.embedding_precision,
)
# if secondary_search_settings:
# # Cancel any background indexing jobs
# expire_index_attempts(
# search_settings_id=secondary_search_settings.id, db_session=db_session
# )
# Pause index attempts for the currently in-use index to preserve resources.
if DISABLE_INDEX_UPDATE_ON_SWAP:
expire_index_attempts(
search_settings_id=search_settings.id, db_session=db_session
)
for cc_pair in get_connector_credential_pairs(db_session):
resync_cc_pair(
cc_pair=cc_pair,
search_settings_id=new_search_settings.id,
db_session=db_session,
)
# # Mark previous model as a past model directly
# update_search_settings_status(
# search_settings=secondary_search_settings,
# new_status=IndexModelStatus.PAST,
# db_session=db_session,
# )
# new_search_settings = create_search_settings(
# search_settings=new_search_settings_request, db_session=db_session
# )
# # Ensure Vespa has the new index immediately
# get_multipass_config(search_settings)
# get_multipass_config(new_search_settings)
# document_index = get_default_document_index(
# search_settings, new_search_settings, db_session
# )
# document_index.ensure_indices_exist(
# primary_embedding_dim=search_settings.final_embedding_dim,
# primary_embedding_precision=search_settings.embedding_precision,
# secondary_index_embedding_dim=new_search_settings.final_embedding_dim,
# secondary_index_embedding_precision=new_search_settings.embedding_precision,
# )
# # Pause index attempts for the currently in use index to preserve resources
# if DISABLE_INDEX_UPDATE_ON_SWAP:
# expire_index_attempts(
# search_settings_id=search_settings.id, db_session=db_session
# )
# for cc_pair in get_connector_credential_pairs(db_session):
# resync_cc_pair(
# cc_pair=cc_pair,
# search_settings_id=new_search_settings.id,
# db_session=db_session,
# )
# db_session.commit()
# return IdReturn(id=new_search_settings.id)
db_session.commit()
return IdReturn(id=new_search_settings.id)
@router.post("/cancel-new-embedding", dependencies=[Depends(require_vector_db)])

View File

@@ -1,6 +1,5 @@
import datetime
import json
import os
from collections.abc import Generator
from datetime import timedelta
from uuid import UUID
@@ -61,7 +60,6 @@ from onyx.db.persona import get_persona_by_id
from onyx.db.usage import increment_usage
from onyx.db.usage import UsageType
from onyx.db.user_file import get_file_id_by_user_file_id
from onyx.file_processing.extract_file_text import docx_to_txt_filename
from onyx.file_store.file_store import get_default_file_store
from onyx.llm.constants import LlmProviderNames
from onyx.llm.factory import get_default_llm
@@ -812,18 +810,6 @@ def fetch_chat_file(
if not file_record:
raise HTTPException(status_code=404, detail="File not found")
original_file_name = file_record.display_name
if file_record.file_type.startswith(
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"
):
# Check if a converted text file exists for .docx files
txt_file_name = docx_to_txt_filename(original_file_name)
txt_file_id = os.path.join(os.path.dirname(file_id), txt_file_name)
txt_file_record = file_store.read_file_record(txt_file_id)
if txt_file_record:
file_record = txt_file_record
file_id = txt_file_id
media_type = file_record.file_type
file_io = file_store.read_file(file_id, mode="b")

View File

@@ -1,10 +1,20 @@
#!/bin/bash
set -e
cleanup() {
echo "Error occurred. Cleaning up..."
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
COMPOSE_FILE="$SCRIPT_DIR/../../deployment/docker_compose/docker-compose.yml"
COMPOSE_DEV_FILE="$SCRIPT_DIR/../../deployment/docker_compose/docker-compose.dev.yml"
stop_and_remove_containers() {
docker stop onyx_postgres onyx_vespa onyx_redis onyx_minio onyx_code_interpreter 2>/dev/null || true
docker rm onyx_postgres onyx_vespa onyx_redis onyx_minio onyx_code_interpreter 2>/dev/null || true
docker compose -f "$COMPOSE_FILE" -f "$COMPOSE_DEV_FILE" --profile opensearch-enabled stop opensearch 2>/dev/null || true
docker compose -f "$COMPOSE_FILE" -f "$COMPOSE_DEV_FILE" --profile opensearch-enabled rm -f opensearch 2>/dev/null || true
}
cleanup() {
echo "Error occurred. Cleaning up..."
stop_and_remove_containers
}
# Trap errors and output a message, then cleanup
@@ -12,16 +22,26 @@ trap 'echo "Error occurred on line $LINENO. Exiting script." >&2; cleanup' ERR
# Usage of the script with optional volume arguments
# ./restart_containers.sh [vespa_volume] [postgres_volume] [redis_volume]
# [minio_volume] [--keep-opensearch-data]
VESPA_VOLUME=${1:-""} # Default is empty if not provided
POSTGRES_VOLUME=${2:-""} # Default is empty if not provided
REDIS_VOLUME=${3:-""} # Default is empty if not provided
MINIO_VOLUME=${4:-""} # Default is empty if not provided
KEEP_OPENSEARCH_DATA=false
POSITIONAL_ARGS=()
for arg in "$@"; do
if [[ "$arg" == "--keep-opensearch-data" ]]; then
KEEP_OPENSEARCH_DATA=true
else
POSITIONAL_ARGS+=("$arg")
fi
done
VESPA_VOLUME=${POSITIONAL_ARGS[0]:-""}
POSTGRES_VOLUME=${POSITIONAL_ARGS[1]:-""}
REDIS_VOLUME=${POSITIONAL_ARGS[2]:-""}
MINIO_VOLUME=${POSITIONAL_ARGS[3]:-""}
# Stop and remove the existing containers
echo "Stopping and removing existing containers..."
docker stop onyx_postgres onyx_vespa onyx_redis onyx_minio onyx_code_interpreter 2>/dev/null || true
docker rm onyx_postgres onyx_vespa onyx_redis onyx_minio onyx_code_interpreter 2>/dev/null || true
stop_and_remove_containers
# Start the PostgreSQL container with optional volume
echo "Starting PostgreSQL container..."
@@ -39,6 +59,29 @@ else
docker run --detach --name onyx_vespa --hostname vespa-container --publish 8081:8081 --publish 19071:19071 vespaengine/vespa:8
fi
# If OPENSEARCH_ADMIN_PASSWORD is not already set, try loading it from
# .vscode/.env so existing dev setups that stored it there aren't silently
# broken.
VSCODE_ENV="$SCRIPT_DIR/../../.vscode/.env"
if [[ -z "${OPENSEARCH_ADMIN_PASSWORD:-}" && -f "$VSCODE_ENV" ]]; then
set -a
# shellcheck source=/dev/null
source "$VSCODE_ENV"
set +a
fi
# Start the OpenSearch container using the same service from docker-compose that
# our users use, setting OPENSEARCH_INITIAL_ADMIN_PASSWORD from the env's
# OPENSEARCH_ADMIN_PASSWORD if it exists, else defaulting to StrongPassword123!.
# Pass --keep-opensearch-data to preserve the opensearch-data volume across
# restarts, else the volume is deleted so the container starts fresh.
if [[ "$KEEP_OPENSEARCH_DATA" == "false" ]]; then
echo "Deleting opensearch-data volume..."
docker volume rm onyx_opensearch-data 2>/dev/null || true
fi
echo "Starting OpenSearch container..."
docker compose -f "$COMPOSE_FILE" -f "$COMPOSE_DEV_FILE" --profile opensearch-enabled up --force-recreate -d opensearch
# Start the Redis container with optional volume
echo "Starting Redis container..."
if [[ -n "$REDIS_VOLUME" ]]; then
@@ -60,7 +103,6 @@ echo "Starting Code Interpreter container..."
docker run --detach --name onyx_code_interpreter --publish 8000:8000 --user root -v /var/run/docker.sock:/var/run/docker.sock onyxdotapp/code-interpreter:latest bash ./entrypoint.sh code-interpreter-api
# Ensure alembic runs in the correct directory (backend/)
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
PARENT_DIR="$(dirname "$SCRIPT_DIR")"
cd "$PARENT_DIR"

View File

@@ -1,10 +0,0 @@
#!/bin/bash
# We get OPENSEARCH_ADMIN_PASSWORD from the repo .env file.
source "$(dirname "$0")/../../.vscode/.env"
cd "$(dirname "$0")/../../deployment/docker_compose"
# Start OpenSearch.
echo "Forcefully starting fresh OpenSearch container..."
docker compose -f docker-compose.opensearch.yml up --force-recreate -d opensearch

View File

@@ -5,6 +5,8 @@ Verifies that:
1. extract_ids_from_runnable_connector correctly separates hierarchy nodes from doc IDs
2. Extracted hierarchy nodes are correctly upserted to Postgres via upsert_hierarchy_nodes_batch
3. Upserting is idempotent (running twice doesn't duplicate nodes)
4. Document-to-hierarchy-node linkage is updated during pruning
5. link_hierarchy_nodes_to_documents links nodes that are also documents
Uses a mock SlimConnectorWithPermSync that yields known hierarchy nodes and slim documents,
combined with a real PostgreSQL database for verifying persistence.
@@ -27,9 +29,13 @@ from onyx.db.enums import HierarchyNodeType
from onyx.db.hierarchy import ensure_source_node_exists
from onyx.db.hierarchy import get_all_hierarchy_nodes_for_source
from onyx.db.hierarchy import get_hierarchy_node_by_raw_id
from onyx.db.hierarchy import link_hierarchy_nodes_to_documents
from onyx.db.hierarchy import update_document_parent_hierarchy_nodes
from onyx.db.hierarchy import upsert_hierarchy_nodes_batch
from onyx.db.models import Document as DbDocument
from onyx.db.models import HierarchyNode as DBHierarchyNode
from onyx.indexing.indexing_heartbeat import IndexingHeartbeatInterface
from onyx.kg.models import KGStage
# ---------------------------------------------------------------------------
# Constants
@@ -89,8 +95,18 @@ def _make_hierarchy_nodes() -> list[PydanticHierarchyNode]:
]
DOC_PARENT_MAP = {
"msg-001": CHANNEL_A_ID,
"msg-002": CHANNEL_A_ID,
"msg-003": CHANNEL_B_ID,
}
def _make_slim_docs() -> list[SlimDocument | PydanticHierarchyNode]:
return [SlimDocument(id=doc_id) for doc_id in SLIM_DOC_IDS]
return [
SlimDocument(id=doc_id, parent_hierarchy_raw_node_id=DOC_PARENT_MAP.get(doc_id))
for doc_id in SLIM_DOC_IDS
]
class MockSlimConnectorWithPermSync(SlimConnectorWithPermSync):
@@ -126,14 +142,31 @@ class MockSlimConnectorWithPermSync(SlimConnectorWithPermSync):
# ---------------------------------------------------------------------------
def _cleanup_test_hierarchy_nodes(db_session: Session) -> None:
"""Remove all hierarchy nodes for TEST_SOURCE to isolate tests."""
def _cleanup_test_data(db_session: Session) -> None:
"""Remove all test hierarchy nodes and documents to isolate tests."""
for doc_id in SLIM_DOC_IDS:
db_session.query(DbDocument).filter(DbDocument.id == doc_id).delete()
db_session.query(DBHierarchyNode).filter(
DBHierarchyNode.source == TEST_SOURCE
).delete()
db_session.commit()
def _create_test_documents(db_session: Session) -> list[DbDocument]:
"""Insert minimal Document rows for our test doc IDs."""
docs = []
for doc_id in SLIM_DOC_IDS:
doc = DbDocument(
id=doc_id,
semantic_id=doc_id,
kg_stage=KGStage.NOT_STARTED,
)
db_session.add(doc)
docs.append(doc)
db_session.commit()
return docs
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
@@ -147,14 +180,14 @@ def test_pruning_extracts_hierarchy_nodes(db_session: Session) -> None: # noqa:
result = extract_ids_from_runnable_connector(connector, callback=None)
# Doc IDs should include both slim doc IDs and hierarchy node raw_node_ids
# (hierarchy node IDs are added to doc_ids so they aren't pruned)
# (hierarchy node IDs are added to raw_id_to_parent so they aren't pruned)
expected_ids = {
CHANNEL_A_ID,
CHANNEL_B_ID,
CHANNEL_C_ID,
*SLIM_DOC_IDS,
}
assert result.doc_ids == expected_ids
assert result.raw_id_to_parent.keys() == expected_ids
# Hierarchy nodes should be the 3 channels
assert len(result.hierarchy_nodes) == 3
@@ -165,7 +198,7 @@ def test_pruning_extracts_hierarchy_nodes(db_session: Session) -> None: # noqa:
def test_pruning_upserts_hierarchy_nodes_to_db(db_session: Session) -> None:
"""Full flow: extract hierarchy nodes from mock connector, upsert to Postgres,
then verify the DB state (node count, parent relationships, permissions)."""
_cleanup_test_hierarchy_nodes(db_session)
_cleanup_test_data(db_session)
# Step 1: ensure the SOURCE node exists (mirrors what the pruning task does)
source_node = ensure_source_node_exists(db_session, TEST_SOURCE, commit=True)
@@ -230,7 +263,7 @@ def test_pruning_upserts_hierarchy_nodes_public_connector(
) -> None:
"""When the connector's access type is PUBLIC, all hierarchy nodes must be
marked is_public=True regardless of their external_access settings."""
_cleanup_test_hierarchy_nodes(db_session)
_cleanup_test_data(db_session)
ensure_source_node_exists(db_session, TEST_SOURCE, commit=True)
@@ -257,7 +290,7 @@ def test_pruning_upserts_hierarchy_nodes_public_connector(
def test_pruning_hierarchy_node_upsert_idempotency(db_session: Session) -> None:
"""Upserting the same hierarchy nodes twice must not create duplicates.
The second call should update existing rows in place."""
_cleanup_test_hierarchy_nodes(db_session)
_cleanup_test_data(db_session)
ensure_source_node_exists(db_session, TEST_SOURCE, commit=True)
@@ -295,7 +328,7 @@ def test_pruning_hierarchy_node_upsert_idempotency(db_session: Session) -> None:
def test_pruning_hierarchy_node_upsert_updates_fields(db_session: Session) -> None:
"""Upserting a hierarchy node with changed fields should update the existing row."""
_cleanup_test_hierarchy_nodes(db_session)
_cleanup_test_data(db_session)
ensure_source_node_exists(db_session, TEST_SOURCE, commit=True)
@@ -342,3 +375,193 @@ def test_pruning_hierarchy_node_upsert_updates_fields(db_session: Session) -> No
assert db_node.is_public is True
assert db_node.external_user_emails is not None
assert set(db_node.external_user_emails) == {"new_user@example.com"}
# ---------------------------------------------------------------------------
# Document-to-hierarchy-node linkage tests
# ---------------------------------------------------------------------------
def test_extraction_preserves_parent_hierarchy_raw_node_id(
db_session: Session, # noqa: ARG001
) -> None:
"""extract_ids_from_runnable_connector should carry the
parent_hierarchy_raw_node_id from SlimDocument into the raw_id_to_parent dict."""
connector = MockSlimConnectorWithPermSync()
result = extract_ids_from_runnable_connector(connector, callback=None)
for doc_id, expected_parent in DOC_PARENT_MAP.items():
assert (
result.raw_id_to_parent[doc_id] == expected_parent
), f"raw_id_to_parent[{doc_id}] should be {expected_parent}"
# Hierarchy node entries have None parent (they aren't documents)
for channel_id in [CHANNEL_A_ID, CHANNEL_B_ID, CHANNEL_C_ID]:
assert result.raw_id_to_parent[channel_id] is None
def test_update_document_parent_hierarchy_nodes(db_session: Session) -> None:
"""update_document_parent_hierarchy_nodes should set
Document.parent_hierarchy_node_id for each document in the mapping."""
_cleanup_test_data(db_session)
source_node = ensure_source_node_exists(db_session, TEST_SOURCE, commit=True)
upserted = upsert_hierarchy_nodes_batch(
db_session=db_session,
nodes=_make_hierarchy_nodes(),
source=TEST_SOURCE,
commit=True,
is_connector_public=False,
)
node_id_by_raw = {n.raw_node_id: n.id for n in upserted}
# Create documents with no parent set
docs = _create_test_documents(db_session)
for doc in docs:
assert doc.parent_hierarchy_node_id is None
# Build resolved map (same logic as _resolve_and_update_document_parents)
resolved: dict[str, int | None] = {}
for doc_id, raw_parent in DOC_PARENT_MAP.items():
resolved[doc_id] = node_id_by_raw.get(raw_parent, source_node.id)
updated = update_document_parent_hierarchy_nodes(
db_session=db_session,
doc_parent_map=resolved,
commit=True,
)
assert updated == len(SLIM_DOC_IDS)
# Verify each document now points to the correct hierarchy node
db_session.expire_all()
for doc_id, raw_parent in DOC_PARENT_MAP.items():
tmp_doc = db_session.get(DbDocument, doc_id)
assert tmp_doc is not None
doc = tmp_doc
expected_node_id = node_id_by_raw[raw_parent]
assert (
doc.parent_hierarchy_node_id == expected_node_id
), f"Document {doc_id} should point to node for {raw_parent}"
def test_update_document_parent_is_idempotent(db_session: Session) -> None:
"""Running update_document_parent_hierarchy_nodes a second time with the
same mapping should update zero rows."""
_cleanup_test_data(db_session)
ensure_source_node_exists(db_session, TEST_SOURCE, commit=True)
upserted = upsert_hierarchy_nodes_batch(
db_session=db_session,
nodes=_make_hierarchy_nodes(),
source=TEST_SOURCE,
commit=True,
is_connector_public=False,
)
node_id_by_raw = {n.raw_node_id: n.id for n in upserted}
_create_test_documents(db_session)
resolved: dict[str, int | None] = {
doc_id: node_id_by_raw[raw_parent]
for doc_id, raw_parent in DOC_PARENT_MAP.items()
}
first_updated = update_document_parent_hierarchy_nodes(
db_session=db_session,
doc_parent_map=resolved,
commit=True,
)
assert first_updated == len(SLIM_DOC_IDS)
second_updated = update_document_parent_hierarchy_nodes(
db_session=db_session,
doc_parent_map=resolved,
commit=True,
)
assert second_updated == 0
def test_link_hierarchy_nodes_to_documents_for_confluence(
db_session: Session,
) -> None:
"""For sources in SOURCES_WITH_HIERARCHY_NODE_DOCUMENTS (e.g. Confluence),
link_hierarchy_nodes_to_documents should set HierarchyNode.document_id
when a hierarchy node's raw_node_id matches a document ID."""
_cleanup_test_data(db_session)
confluence_source = DocumentSource.CONFLUENCE
# Clean up any existing Confluence hierarchy nodes
db_session.query(DBHierarchyNode).filter(
DBHierarchyNode.source == confluence_source
).delete()
db_session.commit()
ensure_source_node_exists(db_session, confluence_source, commit=True)
# Create a hierarchy node whose raw_node_id matches a document ID
page_node_id = "confluence-page-123"
nodes = [
PydanticHierarchyNode(
raw_node_id=page_node_id,
raw_parent_id=None,
display_name="Test Page",
link="https://wiki.example.com/page/123",
node_type=HierarchyNodeType.PAGE,
),
]
upsert_hierarchy_nodes_batch(
db_session=db_session,
nodes=nodes,
source=confluence_source,
commit=True,
is_connector_public=False,
)
# Verify the node exists but has no document_id yet
db_node = get_hierarchy_node_by_raw_id(db_session, page_node_id, confluence_source)
assert db_node is not None
assert db_node.document_id is None
# Create a document with the same ID as the hierarchy node
doc = DbDocument(
id=page_node_id,
semantic_id="Test Page",
kg_stage=KGStage.NOT_STARTED,
)
db_session.add(doc)
db_session.commit()
# Link nodes to documents
linked = link_hierarchy_nodes_to_documents(
db_session=db_session,
document_ids=[page_node_id],
source=confluence_source,
commit=True,
)
assert linked == 1
# Verify the hierarchy node now has document_id set
db_session.expire_all()
db_node = get_hierarchy_node_by_raw_id(db_session, page_node_id, confluence_source)
assert db_node is not None
assert db_node.document_id == page_node_id
# Cleanup
db_session.query(DbDocument).filter(DbDocument.id == page_node_id).delete()
db_session.query(DBHierarchyNode).filter(
DBHierarchyNode.source == confluence_source
).delete()
db_session.commit()
def test_link_hierarchy_nodes_skips_non_hierarchy_sources(
db_session: Session,
) -> None:
"""link_hierarchy_nodes_to_documents should return 0 for sources that
don't support hierarchy-node-as-document (e.g. Slack, Google Drive)."""
linked = link_hierarchy_nodes_to_documents(
db_session=db_session,
document_ids=SLIM_DOC_IDS,
source=TEST_SOURCE, # Slack — not in SOURCES_WITH_HIERARCHY_NODE_DOCUMENTS
commit=False,
)
assert linked == 0

View File

@@ -11,6 +11,7 @@ from onyx.context.search.models import SavedSearchSettings
from onyx.context.search.models import SearchSettingsCreationRequest
from onyx.db.enums import EmbeddingPrecision
from onyx.db.llm import fetch_default_contextual_rag_model
from onyx.db.llm import fetch_existing_llm_provider
from onyx.db.llm import update_default_contextual_model
from onyx.db.llm import upsert_llm_provider
from onyx.db.models import IndexModelStatus
@@ -37,6 +38,8 @@ def _create_llm_provider_and_model(
model_name: str,
) -> None:
"""Insert an LLM provider with a single visible model configuration."""
if fetch_existing_llm_provider(name=provider_name, db_session=db_session):
return
upsert_llm_provider(
LLMProviderUpsertRequest(
name=provider_name,
@@ -146,8 +149,8 @@ def baseline_search_settings(
)
@pytest.mark.skip(reason="Set new search settings is temporarily disabled.")
@patch("onyx.db.swap_index.get_all_document_indices")
@patch("onyx.server.manage.search_settings.get_all_document_indices")
@patch("onyx.server.manage.search_settings.get_default_document_index")
@patch("onyx.indexing.indexing_pipeline.get_llm_for_contextual_rag")
@patch("onyx.indexing.indexing_pipeline.index_doc_batch_with_handler")
@@ -155,6 +158,7 @@ def test_indexing_pipeline_uses_contextual_rag_settings_from_create(
mock_index_handler: MagicMock,
mock_get_llm: MagicMock,
mock_get_doc_index: MagicMock, # noqa: ARG001
mock_get_all_doc_indices_search_settings: MagicMock, # noqa: ARG001
mock_get_all_doc_indices: MagicMock,
baseline_search_settings: None, # noqa: ARG001
db_session: Session,
@@ -196,8 +200,8 @@ def test_indexing_pipeline_uses_contextual_rag_settings_from_create(
)
@pytest.mark.skip(reason="Set new search settings is temporarily disabled.")
@patch("onyx.db.swap_index.get_all_document_indices")
@patch("onyx.server.manage.search_settings.get_all_document_indices")
@patch("onyx.server.manage.search_settings.get_default_document_index")
@patch("onyx.indexing.indexing_pipeline.get_llm_for_contextual_rag")
@patch("onyx.indexing.indexing_pipeline.index_doc_batch_with_handler")
@@ -205,6 +209,7 @@ def test_indexing_pipeline_uses_updated_contextual_rag_settings(
mock_index_handler: MagicMock,
mock_get_llm: MagicMock,
mock_get_doc_index: MagicMock, # noqa: ARG001
mock_get_all_doc_indices_search_settings: MagicMock, # noqa: ARG001
mock_get_all_doc_indices: MagicMock,
baseline_search_settings: None, # noqa: ARG001
db_session: Session,
@@ -266,7 +271,7 @@ def test_indexing_pipeline_uses_updated_contextual_rag_settings(
)
@pytest.mark.skip(reason="Set new search settings is temporarily disabled.")
@patch("onyx.server.manage.search_settings.get_all_document_indices")
@patch("onyx.server.manage.search_settings.get_default_document_index")
@patch("onyx.indexing.indexing_pipeline.get_llm_for_contextual_rag")
@patch("onyx.indexing.indexing_pipeline.index_doc_batch_with_handler")
@@ -274,6 +279,7 @@ def test_indexing_pipeline_skips_llm_when_contextual_rag_disabled(
mock_index_handler: MagicMock,
mock_get_llm: MagicMock,
mock_get_doc_index: MagicMock, # noqa: ARG001
mock_get_all_doc_indices_search_settings: MagicMock, # noqa: ARG001
baseline_search_settings: None, # noqa: ARG001
db_session: Session,
) -> None:

View File

@@ -42,6 +42,78 @@ class NightlyProviderConfig(BaseModel):
strict: bool
def _stringify_custom_config_value(value: object) -> str:
if isinstance(value, str):
return value
if isinstance(value, (dict, list)):
return json.dumps(value)
return str(value)
def _looks_like_vertex_credentials_payload(
raw_custom_config: dict[object, object],
) -> bool:
normalized_keys = {str(key).strip().lower() for key in raw_custom_config}
provider_specific_keys = {
"vertex_credentials",
"credentials_file",
"vertex_credentials_file",
"google_application_credentials",
"vertex_location",
"location",
"vertex_region",
"region",
}
if normalized_keys & provider_specific_keys:
return False
normalized_type = str(raw_custom_config.get("type", "")).strip().lower()
if normalized_type not in {"service_account", "external_account"}:
return False
# Service account JSON usually includes private_key/client_email, while external
# account JSON includes credential_source. Either shape should be accepted.
has_service_account_markers = any(
key in normalized_keys for key in {"private_key", "client_email"}
)
has_external_account_markers = "credential_source" in normalized_keys
return has_service_account_markers or has_external_account_markers
def _normalize_custom_config(
provider: str, raw_custom_config: dict[object, object]
) -> dict[str, str]:
if provider == "vertex_ai" and _looks_like_vertex_credentials_payload(
raw_custom_config
):
return {"vertex_credentials": json.dumps(raw_custom_config)}
normalized: dict[str, str] = {}
for raw_key, raw_value in raw_custom_config.items():
key = str(raw_key).strip()
key_lower = key.lower()
if provider == "vertex_ai":
if key_lower in {
"vertex_credentials",
"credentials_file",
"vertex_credentials_file",
"google_application_credentials",
}:
key = "vertex_credentials"
elif key_lower in {
"vertex_location",
"location",
"vertex_region",
"region",
}:
key = "vertex_location"
normalized[key] = _stringify_custom_config_value(raw_value)
return normalized
def _env_true(env_var: str, default: bool = False) -> bool:
value = os.environ.get(env_var)
if value is None:
@@ -80,7 +152,9 @@ def _load_provider_config() -> NightlyProviderConfig:
parsed = json.loads(custom_config_json)
if not isinstance(parsed, dict):
raise ValueError(f"{_ENV_CUSTOM_CONFIG_JSON} must be a JSON object")
custom_config = {str(key): str(value) for key, value in parsed.items()}
custom_config = _normalize_custom_config(
provider=provider, raw_custom_config=parsed
)
if provider == "ollama_chat" and api_key and not custom_config:
custom_config = {"OLLAMA_API_KEY": api_key}
@@ -148,6 +222,23 @@ def _validate_provider_config(config: NightlyProviderConfig) -> None:
),
)
if config.provider == "vertex_ai":
has_vertex_credentials = bool(
config.custom_config and config.custom_config.get("vertex_credentials")
)
if not has_vertex_credentials:
configured_keys = (
sorted(config.custom_config.keys()) if config.custom_config else []
)
_skip_or_fail(
strict=config.strict,
message=(
f"{_ENV_CUSTOM_CONFIG_JSON} must include 'vertex_credentials' "
f"for provider '{config.provider}'. "
f"Found keys: {configured_keys}"
),
)
def _assert_integration_mode_enabled() -> None:
assert (
@@ -193,6 +284,7 @@ def _create_provider_payload(
return {
"name": provider_name,
"provider": provider,
"model": model_name,
"api_key": api_key,
"api_base": api_base,
"api_version": api_version,
@@ -208,24 +300,23 @@ def _create_provider_payload(
}
def _ensure_provider_is_default(provider_id: int, admin_user: DATestUser) -> None:
def _ensure_provider_is_default(
provider_id: int, model_name: str, admin_user: DATestUser
) -> None:
list_response = requests.get(
f"{API_SERVER_URL}/admin/llm/provider",
headers=admin_user.headers,
)
list_response.raise_for_status()
providers = list_response.json()
current_default = next(
(provider for provider in providers if provider.get("is_default_provider")),
None,
default_text = list_response.json().get("default_text")
assert default_text is not None, "Expected a default provider after setting default"
assert default_text.get("provider_id") == provider_id, (
f"Expected provider {provider_id} to be default, "
f"found {default_text.get('provider_id')}"
)
assert (
current_default is not None
), "Expected a default provider after setting provider as default"
assert (
current_default["id"] == provider_id
), f"Expected provider {provider_id} to be default, found {current_default['id']}"
default_text.get("model_name") == model_name
), f"Expected default model {model_name}, found {default_text.get('model_name')}"
def _run_chat_assertions(
@@ -326,8 +417,9 @@ def _create_and_test_provider_for_model(
try:
set_default_response = requests.post(
f"{API_SERVER_URL}/admin/llm/provider/{provider_id}/default",
f"{API_SERVER_URL}/admin/llm/default",
headers=admin_user.headers,
json={"provider_id": provider_id, "model_name": model_name},
)
assert set_default_response.status_code == 200, (
f"Setting default provider failed for provider={config.provider} "
@@ -335,7 +427,9 @@ def _create_and_test_provider_for_model(
f"{set_default_response.text}"
)
_ensure_provider_is_default(provider_id=provider_id, admin_user=admin_user)
_ensure_provider_is_default(
provider_id=provider_id, model_name=model_name, admin_user=admin_user
)
_run_chat_assertions(
admin_user=admin_user,
search_tool_id=search_tool_id,

View File

@@ -1,4 +1,3 @@
import pytest
import requests
from tests.integration.common_utils.constants import API_SERVER_URL
@@ -365,7 +364,6 @@ def test_update_contextual_rag_missing_model_name(
assert "Provider name and model name are required" in response.json()["detail"]
@pytest.mark.skip(reason="Set new search settings is temporarily disabled.")
def test_set_new_search_settings_with_contextual_rag(
reset: None, # noqa: ARG001
admin_user: DATestUser,
@@ -394,7 +392,6 @@ def test_set_new_search_settings_with_contextual_rag(
_cancel_new_embedding(admin_user)
@pytest.mark.skip(reason="Set new search settings is temporarily disabled.")
def test_set_new_search_settings_without_contextual_rag(
reset: None, # noqa: ARG001
admin_user: DATestUser,
@@ -419,7 +416,6 @@ def test_set_new_search_settings_without_contextual_rag(
_cancel_new_embedding(admin_user)
@pytest.mark.skip(reason="Set new search settings is temporarily disabled.")
def test_set_new_then_update_inference_settings(
reset: None, # noqa: ARG001
admin_user: DATestUser,
@@ -457,7 +453,6 @@ def test_set_new_then_update_inference_settings(
_cancel_new_embedding(admin_user)
@pytest.mark.skip(reason="Set new search settings is temporarily disabled.")
def test_set_new_search_settings_replaces_previous_secondary(
reset: None, # noqa: ARG001
admin_user: DATestUser,

View File

@@ -1,8 +1,8 @@
#!/bin/bash
set -e
set -euo pipefail
# Expected resource requirements
# Expected resource requirements (overridden below if --lite)
EXPECTED_DOCKER_RAM_GB=10
EXPECTED_DISK_GB=32
@@ -10,6 +10,10 @@ EXPECTED_DISK_GB=32
SHUTDOWN_MODE=false
DELETE_DATA_MODE=false
INCLUDE_CRAFT=false # Disabled by default, use --include-craft to enable
LITE_MODE=false # Disabled by default, use --lite to enable
NO_PROMPT=false
DRY_RUN=false
VERBOSE=false
while [[ $# -gt 0 ]]; do
case $1 in
@@ -25,6 +29,22 @@ while [[ $# -gt 0 ]]; do
INCLUDE_CRAFT=true
shift
;;
--lite)
LITE_MODE=true
shift
;;
--no-prompt)
NO_PROMPT=true
shift
;;
--dry-run)
DRY_RUN=true
shift
;;
--verbose)
VERBOSE=true
shift
;;
--help|-h)
echo "Onyx Installation Script"
echo ""
@@ -32,15 +52,21 @@ while [[ $# -gt 0 ]]; do
echo ""
echo "Options:"
echo " --include-craft Enable Onyx Craft (AI-powered web app building)"
echo " --lite Deploy Onyx Lite (no Vespa, Redis, or model servers)"
echo " --shutdown Stop (pause) Onyx containers"
echo " --delete-data Remove all Onyx data (containers, volumes, and files)"
echo " --no-prompt Run non-interactively with defaults (for CI/automation)"
echo " --dry-run Show what would be done without making changes"
echo " --verbose Show detailed output for debugging"
echo " --help, -h Show this help message"
echo ""
echo "Examples:"
echo " $0 # Install Onyx"
echo " $0 --lite # Install Onyx Lite (minimal deployment)"
echo " $0 --include-craft # Install Onyx with Craft enabled"
echo " $0 --shutdown # Pause Onyx services"
echo " $0 --delete-data # Completely remove Onyx and all data"
echo " $0 --no-prompt # Non-interactive install with defaults"
exit 0
;;
*)
@@ -51,8 +77,116 @@ while [[ $# -gt 0 ]]; do
esac
done
if [[ "$VERBOSE" = true ]]; then
set -x
fi
if [[ "$LITE_MODE" = true ]] && [[ "$INCLUDE_CRAFT" = true ]]; then
echo "ERROR: --lite and --include-craft cannot be used together."
echo "Craft requires services (Vespa, Redis, background workers) that lite mode disables."
exit 1
fi
# Lite mode needs far fewer resources (no Vespa, Redis, or model servers)
if [[ "$LITE_MODE" = true ]]; then
EXPECTED_DOCKER_RAM_GB=4
EXPECTED_DISK_GB=16
fi
INSTALL_ROOT="${INSTALL_PREFIX:-onyx_data}"
LITE_COMPOSE_FILE="docker-compose.onyx-lite.yml"
# Build the -f flags for docker compose. For shutdown/delete-data we auto-detect
# whether the lite overlay was previously downloaded; for install we use --lite.
compose_file_args() {
local args="-f docker-compose.yml"
if [[ "$LITE_MODE" = true ]] || [[ -f "${INSTALL_ROOT}/deployment/${LITE_COMPOSE_FILE}" ]]; then
args="$args -f ${LITE_COMPOSE_FILE}"
fi
echo "$args"
}
# --- Temp file cleanup ---
TMPFILES=()
cleanup_tmpfiles() {
local f
for f in "${TMPFILES[@]:-}"; do
rm -rf "$f" 2>/dev/null || true
done
}
trap cleanup_tmpfiles EXIT
mktempfile() {
local f
f="$(mktemp)"
TMPFILES+=("$f")
echo "$f"
}
# --- Downloader detection (curl with wget fallback) ---
DOWNLOADER=""
detect_downloader() {
if command -v curl &> /dev/null; then
DOWNLOADER="curl"
return 0
fi
if command -v wget &> /dev/null; then
DOWNLOADER="wget"
return 0
fi
echo "ERROR: Neither curl nor wget found. Please install one and retry."
exit 1
}
detect_downloader
download_file() {
local url="$1"
local output="$2"
if [[ "$DOWNLOADER" == "curl" ]]; then
curl -fsSL --retry 3 --retry-delay 2 --retry-connrefused -o "$output" "$url"
else
wget -q --tries=3 --timeout=20 -O "$output" "$url"
fi
}
# --- Interactive prompt helpers ---
is_interactive() {
[[ "$NO_PROMPT" = false ]] && [[ -t 0 ]]
}
prompt_or_default() {
local prompt_text="$1"
local default_value="$2"
if is_interactive; then
read -p "$prompt_text" -r REPLY
if [[ -z "$REPLY" ]]; then
REPLY="$default_value"
fi
else
REPLY="$default_value"
fi
}
prompt_yn_or_default() {
local prompt_text="$1"
local default_value="$2"
if is_interactive; then
read -p "$prompt_text" -n 1 -r
echo ""
else
REPLY="$default_value"
fi
}
prompt_enter_or_skip() {
local prompt_text="$1"
if is_interactive; then
echo -e "$prompt_text"
read -r
fi
}
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
@@ -111,7 +245,7 @@ if [ "$SHUTDOWN_MODE" = true ]; then
fi
# Stop containers (without removing them)
(cd "${INSTALL_ROOT}/deployment" && $COMPOSE_CMD -f docker-compose.yml stop)
(cd "${INSTALL_ROOT}/deployment" && $COMPOSE_CMD $(compose_file_args) stop)
if [ $? -eq 0 ]; then
print_success "Onyx containers stopped (paused)"
else
@@ -140,12 +274,17 @@ if [ "$DELETE_DATA_MODE" = true ]; then
echo " • All downloaded files and configurations"
echo " • All user data and documents"
echo ""
read -p "Are you sure you want to continue? Type 'DELETE' to confirm: " -r
echo ""
if [ "$REPLY" != "DELETE" ]; then
print_info "Operation cancelled."
exit 0
if is_interactive; then
read -p "Are you sure you want to continue? Type 'DELETE' to confirm: " -r
echo ""
if [ "$REPLY" != "DELETE" ]; then
print_info "Operation cancelled."
exit 0
fi
else
print_error "Cannot confirm destructive operation in non-interactive mode."
print_info "Run interactively or remove the ${INSTALL_ROOT} directory manually."
exit 1
fi
print_info "Removing Onyx containers and volumes..."
@@ -164,7 +303,7 @@ if [ "$DELETE_DATA_MODE" = true ]; then
fi
# Stop and remove containers with volumes
(cd "${INSTALL_ROOT}/deployment" && $COMPOSE_CMD -f docker-compose.yml down -v)
(cd "${INSTALL_ROOT}/deployment" && $COMPOSE_CMD $(compose_file_args) down -v)
if [ $? -eq 0 ]; then
print_success "Onyx containers and volumes removed"
else
@@ -198,8 +337,13 @@ echo " \____/|_| |_|\__, /_/\_\ "
echo " __/ | "
echo " |___/ "
echo -e "${NC}"
echo "Welcome to Onyx Installation Script"
echo "===================================="
if [[ "$LITE_MODE" = true ]]; then
echo "Welcome to Onyx Lite Installation Script"
echo "========================================="
else
echo "Welcome to Onyx Installation Script"
echo "===================================="
fi
echo ""
# User acknowledgment section
@@ -207,10 +351,14 @@ echo -e "${YELLOW}${BOLD}This script will:${NC}"
echo "1. Download deployment files for Onyx into a new '${INSTALL_ROOT}' directory"
echo "2. Check your system resources (Docker, memory, disk space)"
echo "3. Guide you through deployment options (version, authentication)"
if [[ "$LITE_MODE" = true ]]; then
echo ""
echo -e "${YELLOW}${BOLD}Lite mode:${NC} Vespa, Redis, and model servers will NOT be started."
echo "This gives you the core chat experience with lower resource requirements."
fi
echo ""
# Only prompt for acknowledgment if running interactively
if [ -t 0 ]; then
if is_interactive; then
echo -e "${YELLOW}${BOLD}Please acknowledge and press Enter to continue...${NC}"
read -r
echo ""
@@ -219,6 +367,26 @@ else
echo ""
fi
# Detect OS (including WSL)
IS_WSL=false
if [[ -n "${WSL_DISTRO_NAME:-}" ]] || grep -qi microsoft /proc/version 2>/dev/null; then
IS_WSL=true
fi
# Dry-run: show plan and exit
if [[ "$DRY_RUN" = true ]]; then
print_info "Dry run mode — showing what would happen:"
echo " • Install root: ${INSTALL_ROOT}"
echo " • Lite mode: ${LITE_MODE}"
echo " • Include Craft: ${INCLUDE_CRAFT}"
echo " • OS type: ${OSTYPE:-unknown} (WSL: ${IS_WSL})"
echo " • Downloader: ${DOWNLOADER}"
echo " • Min RAM: ${EXPECTED_DOCKER_RAM_GB}GB, Min disk: ${EXPECTED_DISK_GB}GB"
echo ""
print_success "Dry run complete (no changes made)"
exit 0
fi
# GitHub repo base URL - using main branch
GITHUB_RAW_URL="https://raw.githubusercontent.com/onyx-dot-app/onyx/main/deployment/docker_compose"
@@ -260,41 +428,35 @@ else
exit 1
fi
# Function to compare version numbers
# Returns 0 if $1 <= $2, 1 if $1 > $2
# Handles missing or non-numeric parts gracefully (treats them as 0)
version_compare() {
# Returns 0 if $1 <= $2, 1 if $1 > $2
local version1=$1
local version2=$2
local version1="${1:-0.0.0}"
local version2="${2:-0.0.0}"
# Split versions into components
local v1_major=$(echo $version1 | cut -d. -f1)
local v1_minor=$(echo $version1 | cut -d. -f2)
local v1_patch=$(echo $version1 | cut -d. -f3)
local v1_major v1_minor v1_patch v2_major v2_minor v2_patch
v1_major=$(echo "$version1" | cut -d. -f1)
v1_minor=$(echo "$version1" | cut -d. -f2)
v1_patch=$(echo "$version1" | cut -d. -f3)
v2_major=$(echo "$version2" | cut -d. -f1)
v2_minor=$(echo "$version2" | cut -d. -f2)
v2_patch=$(echo "$version2" | cut -d. -f3)
local v2_major=$(echo $version2 | cut -d. -f1)
local v2_minor=$(echo $version2 | cut -d. -f2)
local v2_patch=$(echo $version2 | cut -d. -f3)
# Default non-numeric or empty parts to 0
[[ "$v1_major" =~ ^[0-9]+$ ]] || v1_major=0
[[ "$v1_minor" =~ ^[0-9]+$ ]] || v1_minor=0
[[ "$v1_patch" =~ ^[0-9]+$ ]] || v1_patch=0
[[ "$v2_major" =~ ^[0-9]+$ ]] || v2_major=0
[[ "$v2_minor" =~ ^[0-9]+$ ]] || v2_minor=0
[[ "$v2_patch" =~ ^[0-9]+$ ]] || v2_patch=0
# Compare major version
if [ "$v1_major" -lt "$v2_major" ]; then
return 0
elif [ "$v1_major" -gt "$v2_major" ]; then
return 1
fi
if [ "$v1_major" -lt "$v2_major" ]; then return 0
elif [ "$v1_major" -gt "$v2_major" ]; then return 1; fi
# Compare minor version
if [ "$v1_minor" -lt "$v2_minor" ]; then
return 0
elif [ "$v1_minor" -gt "$v2_minor" ]; then
return 1
fi
if [ "$v1_minor" -lt "$v2_minor" ]; then return 0
elif [ "$v1_minor" -gt "$v2_minor" ]; then return 1; fi
# Compare patch version
if [ "$v1_patch" -le "$v2_patch" ]; then
return 0
else
return 1
fi
[ "$v1_patch" -le "$v2_patch" ]
}
# Check Docker daemon
@@ -371,8 +533,7 @@ if [ "$RESOURCE_WARNING" = true ]; then
echo ""
print_warning "Onyx recommends at least ${EXPECTED_DOCKER_RAM_GB}GB RAM and ${EXPECTED_DISK_GB}GB disk space for optimal performance."
echo ""
read -p "Do you want to continue anyway? (y/N): " -n 1 -r
echo ""
prompt_yn_or_default "Do you want to continue anyway? (y/N): " "y"
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
print_info "Installation cancelled. Please allocate more resources and try again."
exit 1
@@ -397,6 +558,9 @@ print_info "This step downloads all necessary configuration files from GitHub...
echo ""
print_info "Downloading the following files:"
echo " • docker-compose.yml - Main Docker Compose configuration"
if [[ "$LITE_MODE" = true ]]; then
echo "${LITE_COMPOSE_FILE} - Lite mode overlay"
fi
echo " • env.template - Environment variables template"
echo " • nginx/app.conf.template - Nginx web server configuration"
echo " • nginx/run-nginx.sh - Nginx startup script"
@@ -406,7 +570,7 @@ echo ""
# Download Docker Compose file
COMPOSE_FILE="${INSTALL_ROOT}/deployment/docker-compose.yml"
print_info "Downloading docker-compose.yml..."
if curl -fsSL -o "$COMPOSE_FILE" "${GITHUB_RAW_URL}/docker-compose.yml" 2>/dev/null; then
if download_file "${GITHUB_RAW_URL}/docker-compose.yml" "$COMPOSE_FILE" 2>/dev/null; then
print_success "Docker Compose file downloaded successfully"
# Check if Docker Compose version is older than 2.24.0 and show warning
@@ -431,8 +595,7 @@ if curl -fsSL -o "$COMPOSE_FILE" "${GITHUB_RAW_URL}/docker-compose.yml" 2>/dev/n
echo ""
print_warning "The installation will continue, but may fail if Docker Compose cannot parse the file."
echo ""
read -p "Do you want to continue anyway? (y/N): " -n 1 -r
echo ""
prompt_yn_or_default "Do you want to continue anyway? (y/N): " "y"
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
print_info "Installation cancelled. Please upgrade Docker Compose or manually edit the docker-compose.yml file."
exit 1
@@ -445,10 +608,23 @@ else
exit 1
fi
# Download lite overlay if --lite was requested
if [[ "$LITE_MODE" = true ]]; then
LITE_FILE="${INSTALL_ROOT}/deployment/${LITE_COMPOSE_FILE}"
print_info "Downloading ${LITE_COMPOSE_FILE} (lite overlay)..."
if download_file "${GITHUB_RAW_URL}/${LITE_COMPOSE_FILE}" "$LITE_FILE" 2>/dev/null; then
print_success "Lite overlay downloaded successfully"
else
print_error "Failed to download lite overlay"
print_info "Please ensure you have internet connection and try again"
exit 1
fi
fi
# Download env.template file
ENV_TEMPLATE="${INSTALL_ROOT}/deployment/env.template"
print_info "Downloading env.template..."
if curl -fsSL -o "$ENV_TEMPLATE" "${GITHUB_RAW_URL}/env.template" 2>/dev/null; then
if download_file "${GITHUB_RAW_URL}/env.template" "$ENV_TEMPLATE" 2>/dev/null; then
print_success "Environment template downloaded successfully"
else
print_error "Failed to download env.template"
@@ -462,7 +638,7 @@ NGINX_BASE_URL="https://raw.githubusercontent.com/onyx-dot-app/onyx/main/deploym
# Download app.conf.template
NGINX_CONFIG="${INSTALL_ROOT}/data/nginx/app.conf.template"
print_info "Downloading nginx configuration template..."
if curl -fsSL -o "$NGINX_CONFIG" "$NGINX_BASE_URL/app.conf.template" 2>/dev/null; then
if download_file "$NGINX_BASE_URL/app.conf.template" "$NGINX_CONFIG" 2>/dev/null; then
print_success "Nginx configuration template downloaded"
else
print_error "Failed to download nginx configuration template"
@@ -473,7 +649,7 @@ fi
# Download run-nginx.sh script
NGINX_RUN_SCRIPT="${INSTALL_ROOT}/data/nginx/run-nginx.sh"
print_info "Downloading nginx startup script..."
if curl -fsSL -o "$NGINX_RUN_SCRIPT" "$NGINX_BASE_URL/run-nginx.sh" 2>/dev/null; then
if download_file "$NGINX_BASE_URL/run-nginx.sh" "$NGINX_RUN_SCRIPT" 2>/dev/null; then
chmod +x "$NGINX_RUN_SCRIPT"
print_success "Nginx startup script downloaded and made executable"
else
@@ -485,7 +661,7 @@ fi
# Download README file
README_FILE="${INSTALL_ROOT}/README.md"
print_info "Downloading README.md..."
if curl -fsSL -o "$README_FILE" "${GITHUB_RAW_URL}/README.md" 2>/dev/null; then
if download_file "${GITHUB_RAW_URL}/README.md" "$README_FILE" 2>/dev/null; then
print_success "README.md downloaded successfully"
else
print_error "Failed to download README.md"
@@ -513,7 +689,7 @@ if [ -d "${INSTALL_ROOT}/deployment" ] && [ -f "${INSTALL_ROOT}/deployment/docke
if [ -n "$COMPOSE_CMD" ]; then
# Check if any containers are running
RUNNING_CONTAINERS=$(cd "${INSTALL_ROOT}/deployment" && $COMPOSE_CMD -f docker-compose.yml ps -q 2>/dev/null | wc -l)
RUNNING_CONTAINERS=$(cd "${INSTALL_ROOT}/deployment" && $COMPOSE_CMD $(compose_file_args) ps -q 2>/dev/null | wc -l)
if [ "$RUNNING_CONTAINERS" -gt 0 ]; then
print_error "Onyx services are currently running!"
echo ""
@@ -534,7 +710,7 @@ if [ -f "$ENV_FILE" ]; then
echo "• Press Enter to restart with current configuration"
echo "• Type 'update' to update to a newer version"
echo ""
read -p "Choose an option [default: restart]: " -r
prompt_or_default "Choose an option [default: restart]: " ""
echo ""
if [ "$REPLY" = "update" ]; then
@@ -543,22 +719,19 @@ if [ -f "$ENV_FILE" ]; then
echo "• Press Enter for latest (recommended)"
echo "• Type a specific tag (e.g., v0.1.0)"
echo ""
# If --include-craft was passed, default to craft-latest
if [ "$INCLUDE_CRAFT" = true ]; then
read -p "Enter tag [default: craft-latest]: " -r VERSION
prompt_or_default "Enter tag [default: craft-latest]: " "craft-latest"
VERSION="$REPLY"
else
read -p "Enter tag [default: latest]: " -r VERSION
prompt_or_default "Enter tag [default: latest]: " "latest"
VERSION="$REPLY"
fi
echo ""
if [ -z "$VERSION" ]; then
if [ "$INCLUDE_CRAFT" = true ]; then
VERSION="craft-latest"
print_info "Selected: craft-latest (Craft enabled)"
else
VERSION="latest"
print_info "Selected: Latest version"
fi
if [ "$INCLUDE_CRAFT" = true ] && [ "$VERSION" = "craft-latest" ]; then
print_info "Selected: craft-latest (Craft enabled)"
elif [ "$VERSION" = "latest" ]; then
print_info "Selected: Latest version"
else
print_info "Selected: $VERSION"
fi
@@ -595,23 +768,21 @@ else
echo "• Press Enter for craft-latest (recommended for Craft)"
echo "• Type a specific tag (e.g., craft-v1.0.0)"
echo ""
read -p "Enter tag [default: craft-latest]: " -r VERSION
prompt_or_default "Enter tag [default: craft-latest]: " "craft-latest"
VERSION="$REPLY"
else
echo "• Press Enter for latest (recommended)"
echo "• Type a specific tag (e.g., v0.1.0)"
echo ""
read -p "Enter tag [default: latest]: " -r VERSION
prompt_or_default "Enter tag [default: latest]: " "latest"
VERSION="$REPLY"
fi
echo ""
if [ -z "$VERSION" ]; then
if [ "$INCLUDE_CRAFT" = true ]; then
VERSION="craft-latest"
print_info "Selected: craft-latest (Craft enabled)"
else
VERSION="latest"
print_info "Selected: Latest tag"
fi
if [ "$INCLUDE_CRAFT" = true ] && [ "$VERSION" = "craft-latest" ]; then
print_info "Selected: craft-latest (Craft enabled)"
elif [ "$VERSION" = "latest" ]; then
print_info "Selected: Latest tag"
else
print_info "Selected: $VERSION"
fi
@@ -686,6 +857,13 @@ else
echo ""
fi
# Reject craft image tags when running in lite mode
if [[ "$LITE_MODE" = true ]] && [[ "${VERSION:-}" == craft-* ]]; then
print_error "Cannot use a craft image tag (${VERSION}) with --lite."
print_info "Craft requires services (Vespa, Redis, background workers) that lite mode disables."
exit 1
fi
# Function to check if a port is available
is_port_available() {
local port=$1
@@ -771,7 +949,7 @@ print_step "Pulling Docker images"
print_info "This may take several minutes depending on your internet connection..."
echo ""
print_info "Downloading Docker images (this may take a while)..."
(cd "${INSTALL_ROOT}/deployment" && $COMPOSE_CMD -f docker-compose.yml pull --quiet)
(cd "${INSTALL_ROOT}/deployment" && $COMPOSE_CMD $(compose_file_args) pull --quiet)
if [ $? -eq 0 ]; then
print_success "Docker images downloaded successfully"
else
@@ -785,9 +963,9 @@ print_info "Launching containers..."
echo ""
if [ "$USE_LATEST" = true ]; then
print_info "Force pulling latest images and recreating containers..."
(cd "${INSTALL_ROOT}/deployment" && $COMPOSE_CMD -f docker-compose.yml up -d --pull always --force-recreate)
(cd "${INSTALL_ROOT}/deployment" && $COMPOSE_CMD $(compose_file_args) up -d --pull always --force-recreate)
else
(cd "${INSTALL_ROOT}/deployment" && $COMPOSE_CMD -f docker-compose.yml up -d)
(cd "${INSTALL_ROOT}/deployment" && $COMPOSE_CMD $(compose_file_args) up -d)
fi
if [ $? -ne 0 ]; then
print_error "Failed to start Onyx services"
@@ -809,7 +987,7 @@ echo ""
# Check for restart loops
print_info "Checking container health status..."
RESTART_ISSUES=false
CONTAINERS=$(cd "${INSTALL_ROOT}/deployment" && $COMPOSE_CMD -f docker-compose.yml ps -q 2>/dev/null)
CONTAINERS=$(cd "${INSTALL_ROOT}/deployment" && $COMPOSE_CMD $(compose_file_args) ps -q 2>/dev/null)
for CONTAINER in $CONTAINERS; do
PROJECT_NAME="$(basename "$INSTALL_ROOT")_deployment_"
@@ -838,7 +1016,7 @@ if [ "$RESTART_ISSUES" = true ]; then
print_error "Some containers are experiencing issues!"
echo ""
print_info "Please check the logs for more information:"
echo " (cd \"${INSTALL_ROOT}/deployment\" && $COMPOSE_CMD -f docker-compose.yml logs)"
echo " (cd \"${INSTALL_ROOT}/deployment\" && $COMPOSE_CMD $(compose_file_args) logs)"
echo ""
print_info "If the issue persists, please contact: founders@onyx.app"
@@ -857,8 +1035,12 @@ check_onyx_health() {
echo ""
while [ $attempt -le $max_attempts ]; do
# Check for successful HTTP responses (200, 301, 302, etc.)
local http_code=$(curl -s -o /dev/null -w "%{http_code}" "http://localhost:$port")
local http_code=""
if [[ "$DOWNLOADER" == "curl" ]]; then
http_code=$(curl -s -o /dev/null -w "%{http_code}" "http://localhost:$port" 2>/dev/null || echo "000")
else
http_code=$(wget -q --spider -S "http://localhost:$port" 2>&1 | grep "HTTP/" | tail -1 | awk '{print $2}' || echo "000")
fi
if echo "$http_code" | grep -qE "^(200|301|302|303|307|308)$"; then
return 0
fi
@@ -914,6 +1096,18 @@ print_info "If authentication is enabled, you can create your admin account here
echo " • Visit http://localhost:${HOST_PORT}/auth/signup to create your admin account"
echo " • The first user created will automatically have admin privileges"
echo ""
if [[ "$LITE_MODE" = true ]]; then
echo ""
print_info "Running in Lite mode — the following services are NOT started:"
echo " • Vespa (vector database)"
echo " • Redis (cache)"
echo " • Model servers (embedding/inference)"
echo " • Background workers (Celery)"
echo ""
print_info "Connectors and RAG search are disabled. LLM chat, tools, user file"
print_info "uploads, Projects, Agent knowledge, and code interpreter still work."
fi
echo ""
print_info "Refer to the README in the ${INSTALL_ROOT} directory for more information."
echo ""
print_info "For help or issues, contact: founders@onyx.app"

View File

@@ -2,45 +2,45 @@
**Import:** `import { Button, type ButtonProps } from "@opal/components";`
A single component that handles both labeled buttons and icon-only buttons. Built on `Interactive.Base` > `Interactive.Container`.
A single component that handles both labeled buttons and icon-only buttons. It replaces the legacy `refresh-components/buttons/Button` and `refresh-components/buttons/IconButton` with a unified API built on `Interactive.Base` > `Interactive.Container`.
## Architecture
```
Interactive.Base <- variant/prominence, transient, disabled, href, onClick, ref
Interactive.Base <- variant/prominence, transient, disabled, href, onClick
└─ Interactive.Container <- height, rounding, padding (derived from `size`), border (auto for secondary)
└─ div.opal-button.interactive-foreground <- flexbox row layout
├─ div > Icon? (lg/md/sm/fit: 1rem, xs/2xs: 0.75rem, shrink-0)
├─ <span>? .opal-button-label (lg: font-main-ui-body, other: font-secondary-body)
└─ div > RightIcon? (same sizing as Icon)
├─ div.p-0.5 > Icon? (compact: 12px, default: 16px, shrink-0)
├─ <span>? .opal-button-label (whitespace-nowrap, font)
└─ div.p-0.5 > RightIcon? (compact: 12px, default: 16px, shrink-0)
```
- **Colors are not in the Button.** `Interactive.Base` sets `background-color` and `--interactive-foreground` per variant/prominence/state. The `.interactive-foreground` utility class on the content div sets `color: var(--interactive-foreground)`, which both the `<span>` text and `stroke="currentColor"` SVG icons inherit automatically.
- **Layout is in `styles.css`.** The CSS classes (`.opal-button`, `.opal-button-label`) handle flexbox alignment, gap, and text styling. Default labels use `font-main-ui-action` (14px/600); compact labels use `font-secondary-action` (12px/600) via a `[data-size="compact"]` selector.
- **Sizing is delegated to `Interactive.Container` presets.** The `size` prop maps to Container height/rounding/padding presets:
- `"default"` -> height 2.25rem, rounding 12px, padding 8px
- `"compact"` -> height 1.75rem, rounding 8px, padding 4px
- **Icon-only buttons render as squares** because `Interactive.Container` enforces `min-width >= height` for every height preset.
- **Border is automatic for `prominence="secondary"`.** The Container receives `border={prominence === "secondary"}` internally.
- **Border is automatic for `prominence="secondary"`.** The Container receives `border={prominence === "secondary"}` internally — there is no external `border` prop.
## Props
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `variant` | `"default" \| "action" \| "danger" \| "none" \| "select" \| "sidebar"` | `"default"` | Color variant (maps to `Interactive.Base`) |
| `prominence` | Depends on `variant` | `"primary"` | Color prominence. `"secondary"` automatically renders a border. |
| `icon` | `IconFunctionComponent` | | Left icon component |
| `children` | `string` | | Button label text. Omit for icon-only buttons |
| `rightIcon` | `IconFunctionComponent` | | Right icon component |
| `size` | `SizeVariant` | `"lg"` | Size preset: `"lg"`, `"md"`, `"sm"`, `"xs"`, `"2xs"`, `"fit"` |
| `type` | `"submit" \| "button" \| "reset"` | `"button"` | HTML button type |
| `width` | `WidthVariant` | `"auto"` | Width preset. `"auto"` shrink-wraps, `"full"` stretches. |
| `foldable` | `boolean` | `false` | When `true`, `icon` and `children` are required; label + rightIcon fold away responsively |
| `responsiveHideText` | `boolean` | `false` | Hides the label below `md` breakpoint (icon-only branch) |
| `tooltip` | `string` | — | Tooltip text shown on hover |
| `variant` | `"default" \| "action" \| "danger" \| "none" \| "select"` | `"default"` | Top-level color variant (maps to `Interactive.Base`) |
| `prominence` | Depends on `variant` | `"primary"` | Color prominence -- e.g. `"primary"`, `"secondary"`, `"tertiary"`, `"internal"` for default/action/danger. `"secondary"` automatically renders a border. |
| `icon` | `IconFunctionComponent` | -- | Left icon component |
| `children` | `string` | -- | Button label text. Omit for icon-only buttons |
| `rightIcon` | `IconFunctionComponent` | -- | Right icon component |
| `size` | `SizeVariant` | `"default"` | Size preset controlling height, rounding, padding, icon size, and font style |
| `tooltip` | `string` | -- | Tooltip text shown on hover |
| `tooltipSide` | `TooltipSide` | `"top"` | Which side the tooltip appears on |
| `selected` | `boolean` | `false` | Selected state (available with `variant="select"` or `"sidebar"`) |
| `transient` | `boolean` | `false` | Forces the transient (hover) visual state |
| `href` | `string` | — | URL; renders an `<a>` wrapper |
| `onClick` | `MouseEventHandler<HTMLElement>` | | Click handler |
| `ref` | `React.Ref<HTMLElement>` | | Ref forwarded to the underlying element via `Interactive.Base` |
| _...and all other `InteractiveBaseProps`_ | | | `group`, `target`, etc. |
| `selected` | `boolean` | `false` | Switches foreground to action-link colours (only available with `variant="select"`) |
| `transient` | `boolean` | `false` | Forces the transient (hover) visual state (data-transient) |
| `disabled` | `boolean` | `false` | Disables the button (data-disabled, aria-disabled) |
| `href` | `string` | -- | URL; renders an `<a>` wrapper instead of Radix Slot |
| `onClick` | `MouseEventHandler<HTMLElement>` | -- | Click handler |
| _...and all other `InteractiveBaseProps`_ | | | `group`, `ref`, etc. |
## Usage examples
@@ -49,28 +49,54 @@ import { Button } from "@opal/components";
import { SvgPlus, SvgArrowRight } from "@opal/icons";
// Primary button with label
<Button onClick={handleClick}>Save changes</Button>
<Button variant="default" onClick={handleClick}>
Save changes
</Button>
// Icon-only button (renders as a square)
<Button icon={SvgPlus} prominence="tertiary" size="sm" />
<Button icon={SvgPlus} prominence="tertiary" size="compact" />
// Labeled button with left icon
<Button icon={SvgPlus} variant="action">Add item</Button>
<Button icon={SvgPlus} variant="action">
Add item
</Button>
// Secondary button (automatically renders a border)
<Button rightIcon={SvgArrowRight} prominence="secondary">Continue</Button>
<Button rightIcon={SvgArrowRight} variant="default" prominence="secondary">
Continue
</Button>
// Danger button, disabled (wrap with Disabled from @opal/core)
<Disabled disabled>
<Button variant="danger" size="md">Delete</Button>
</Disabled>
// Compact danger button, disabled
<Button variant="danger" size="compact" disabled>
Delete
</Button>
// As a link
<Button href="/settings" prominence="tertiary">Settings</Button>
<Button href="/settings" variant="default" prominence="tertiary">
Settings
</Button>
// Full-width submit button
<Button type="submit" width="full">Save</Button>
// Transient state (e.g. inside a popover trigger)
<Button icon={SvgFilter} prominence="tertiary" transient={isOpen} />
// With tooltip
<Button icon={SvgPlus} prominence="tertiary" tooltip="Add item" />
```
## Migration from legacy buttons
| Legacy prop | Opal equivalent |
|-------------|-----------------|
| `main` | `variant="default"` (default, can be omitted) |
| `action` | `variant="action"` |
| `danger` | `variant="danger"` |
| `primary` | `prominence="primary"` (default, can be omitted) |
| `secondary` | `prominence="secondary"` |
| `tertiary` | `prominence="tertiary"` |
| `internal` | `prominence="internal"` |
| `transient={x}` | `transient={x}` |
| `size="md"` | `size="compact"` |
| `size="lg"` | `size="default"` (default, can be omitted) |
| `leftIcon={X}` | `icon={X}` |
| `IconButton icon={X}` | `<Button icon={X} />` (no children = icon-only) |
| `tooltip="..."` | `tooltip="..."` |

View File

@@ -1,10 +1,6 @@
import "@opal/components/buttons/Button/styles.css";
import "@opal/components/tooltip.css";
import {
Interactive,
type InteractiveBaseProps,
useDisabled,
} from "@opal/core";
import { Interactive, type InteractiveBaseProps } from "@opal/core";
import type { SizeVariant, WidthVariant } from "@opal/shared";
import type { TooltipSide } from "@opal/components";
import type { IconFunctionComponent } from "@opal/types";
@@ -114,7 +110,6 @@ function Button({
responsiveHideText = false,
...interactiveBaseProps
}: ButtonProps) {
const { isDisabled } = useDisabled();
const isLarge = size === "lg";
const labelEl = children ? (
@@ -179,7 +174,10 @@ function Button({
);
const resolvedTooltip =
tooltip ?? (foldable && isDisabled && children ? children : undefined);
tooltip ??
(foldable && interactiveBaseProps.disabled && children
? children
: undefined);
if (!resolvedTooltip) return button;

View File

@@ -1,83 +0,0 @@
# LineItemButton
**Import:** `import { LineItemButton, type LineItemButtonProps } from "@opal/components";`
A composite component that wraps `Interactive.Base(select) > Interactive.Container > ContentAction` into a single API. Use it for selectable list rows such as model pickers, menu items, or any row that acts like a button.
## Architecture
```
Interactive.Base (variant="select") <- prominence, selected, onClick, href, ref
└─ Interactive.Container <- type, width, size, rounding (derived from size)
└─ ContentAction <- withInteractive, paddingVariant="fit", widthVariant="full"
├─ Content <- icon, title, description, sizePreset, variant, ...
└─ rightChildren
```
`paddingVariant` is hardcoded to `"fit"` (Container owns the padding) and `widthVariant` is hardcoded to `"full"`. These are not exposed as props.
## Props
### Interactive surface (always `variant="select"`)
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `prominence` | `"light" \| "heavy"` | `"light"` | Interactive select prominence |
| `selected` | `boolean` | — | Whether the item appears selected |
| `onClick` | `MouseEventHandler<HTMLElement>` | — | Click handler |
| `href` | `string` | — | Renders an anchor instead of a div |
| `target` | `string` | — | Anchor target (e.g. `"_blank"`) |
| `group` | `string` | — | Interactive group key |
| `transient` | `boolean` | — | Transient interactive state |
| `ref` | `React.Ref<HTMLElement>` | — | Forwarded ref |
### Sizing
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `size` | `SizeVariant` | `"lg"` | Container height |
| `width` | `WidthVariant` | `"full"` | Container width |
| `type` | `"submit" \| "button" \| "reset"` | — | HTML button type |
| `tooltip` | `string` | — | Tooltip text shown on hover |
| `tooltipSide` | `TooltipSide` | `"top"` | Tooltip side |
### Content (pass-through to ContentAction)
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `title` | `string` | **(required)** | Row label |
| `icon` | `IconFunctionComponent` | — | Left icon |
| `description` | `string` | — | Description below the title |
| `sizePreset` | `SizePreset` | `"headline"` | Content size preset |
| `variant` | `ContentVariant` | `"heading"` | Content layout variant |
| `rightChildren` | `ReactNode` | — | Content after the label (e.g. action button) |
All other `ContentAction` / `Content` props (`editable`, `onTitleChange`, `optional`, `auxIcon`, `tag`, `withInteractive`, etc.) are also passed through.
## Usage
```tsx
import { LineItemButton } from "@opal/components";
// Simple selectable row
<LineItemButton
prominence="heavy"
selected={isSelected}
size="md"
onClick={handleClick}
title="gpt-4o"
sizePreset="main-ui"
variant="section"
/>
// With right-side action
<LineItemButton
prominence="heavy"
selected={isSelected}
onClick={handleClick}
title="claude-opus-4"
sizePreset="main-ui"
variant="section"
rightChildren={<Tag title="Default" color="blue" />}
/>
```

View File

@@ -1,132 +0,0 @@
import "@opal/components/tooltip.css";
import { Interactive, type InteractiveBaseProps } from "@opal/core";
import type { SizeVariant, WidthVariant } from "@opal/shared";
import type { TooltipSide } from "@opal/components";
import type { ContentActionProps } from "@opal/layouts/ContentAction/components";
import { ContentAction } from "@opal/layouts";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type ContentPassthroughProps = Omit<
ContentActionProps,
"paddingVariant" | "widthVariant" | "ref"
>;
interface LineItemButtonProps extends ContentPassthroughProps {
/** Interactive select prominence. @default "light" */
prominence?: "light" | "heavy";
/** Whether this item is selected. */
selected?: boolean;
/** Click handler. */
onClick?: InteractiveBaseProps["onClick"];
/** When provided, renders an anchor instead of a div. */
href?: string;
/** Anchor target (e.g. "_blank"). */
target?: string;
/** Interactive group key. */
group?: string;
/** Transient interactive state. */
transient?: boolean;
/** Forwarded ref. */
ref?: React.Ref<HTMLElement>;
/** Container height. @default "lg" */
size?: SizeVariant;
/** Container width. @default "full" */
width?: WidthVariant;
/** HTML button type. */
type?: "submit" | "button" | "reset";
/** Tooltip text shown on hover. */
tooltip?: string;
/** Which side the tooltip appears on. @default "top" */
tooltipSide?: TooltipSide;
}
// ---------------------------------------------------------------------------
// LineItemButton
// ---------------------------------------------------------------------------
function LineItemButton({
// Interactive surface
prominence = "light",
selected,
onClick,
href,
target,
group,
transient,
ref,
// Sizing
size = "lg",
width = "full",
type,
tooltip,
tooltipSide = "top",
// ContentAction pass-through
...contentActionProps
}: LineItemButtonProps) {
const item = (
<Interactive.Base
variant="select"
prominence={prominence}
selected={selected}
onClick={onClick}
href={href}
target={target}
group={group}
transient={transient}
ref={ref}
>
<Interactive.Container
type={type}
widthVariant={width}
heightVariant={size}
roundingVariant={
size === "lg" ? "default" : size === "2xs" ? "mini" : "compact"
}
>
<ContentAction
{...(contentActionProps as ContentActionProps)}
withInteractive
paddingVariant="fit"
widthVariant="full"
/>
</Interactive.Container>
</Interactive.Base>
);
if (!tooltip) return item;
return (
<TooltipPrimitive.Root>
<TooltipPrimitive.Trigger asChild>{item}</TooltipPrimitive.Trigger>
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
className="opal-tooltip"
side={tooltipSide}
sideOffset={4}
>
{tooltip}
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
</TooltipPrimitive.Root>
);
}
export { LineItemButton, type LineItemButtonProps };

View File

@@ -2,43 +2,42 @@
**Import:** `import { OpenButton, type OpenButtonProps } from "@opal/components";`
A trigger button with a built-in chevron that rotates when open. Hardcodes `variant="select"` and delegates to `Button`, adding automatic open-state detection from Radix `data-state`.
A trigger button with a built-in chevron that rotates when open. Hardcodes `variant="select"` and delegates to `Button`, adding automatic open-state detection from Radix `data-state`. Designed to work automatically with Radix primitives while also supporting explicit control via the `transient` prop.
## Architecture
```
OpenButton
└─ Button (variant="select", rightIcon=ChevronIcon)
└─ Interactive.Base <- select variant, transient, selected, disabled, href, onClick
└─ Interactive.Container
└─ Interactive.Base <- select variant, transient, selected, disabled, href, onClick
└─ Interactive.Container <- height, rounding, padding, border (auto for secondary)
└─ div.opal-button.interactive-foreground
├─ div > Icon?
├─ <span>? .opal-button-label
└─ div > ChevronIcon .opal-open-button-chevron
├─ div.p-0.5 > Icon?
├─ <span>? .opal-button-label
└─ div.p-0.5 > ChevronIcon .opal-open-button-chevron
```
- **Always uses `variant="select"`.** Only exposes `InteractiveBaseSelectVariantProps` (`prominence?: "light" | "heavy"`, `selected?: boolean`).
- **`transient` controls both the chevron and the hover visual state.** When `transient` is true (explicitly or via Radix `data-state="open"`), the chevron rotates 180deg.
- **Open-state detection** is dual-resolution: explicit `transient` prop takes priority; otherwise reads `data-state="open"` from Radix triggers.
- **Always uses `variant="select"`.** OpenButton omits `variant` and `prominence` from its own props; it hardcodes `variant="select"` and only exposes `InteractiveBaseSelectVariantProps` (`prominence?: "light" | "heavy"`, `selected?: boolean`).
- **`transient` controls both the chevron and the hover visual state.** When `transient` is true (explicitly or via Radix `data-state="open"`), the chevron rotates 180° and the `Interactive.Base` hover background activates. There is no separate `open` prop.
- **Open-state detection** is dual-resolution: the explicit `transient` prop takes priority; otherwise the component reads `data-state="open"` injected by Radix triggers (e.g. `Popover.Trigger`).
- **Chevron rotation** is CSS-driven via `.interactive[data-transient="true"] .opal-open-button-chevron { rotate: -180deg }`. The `ChevronIcon` is a stable named component (not an inline function) to preserve React element identity across renders, ensuring CSS transitions fire correctly.
## Props
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `prominence` | `"light" \| "heavy"` | `"light"` | Select prominence |
| `selected` | `boolean` | `false` | Selected foreground state |
| `transient` | `boolean` | | Forces transient state + chevron rotation. Falls back to Radix `data-state`. |
| `icon` | `IconFunctionComponent` | | Left icon component |
| `children` | `string` | | Content between icon and chevron |
| `size` | `SizeVariant` | `"lg"` | Size preset |
| `type` | `"submit" \| "button" \| "reset"` | `"button"` | HTML button type |
| `width` | `WidthVariant` | `"auto"` | Width preset |
| `foldable` | `boolean` | `false` | Foldable mode (inherited from Button) |
| `tooltip` | `string` | | Tooltip text shown on hover |
| `tooltipSide` | `TooltipSide` | `"top"` | Tooltip side |
| `href` | `string` | — | URL; renders an `<a>` wrapper |
| `onClick` | `MouseEventHandler<HTMLElement>` | — | Click handler |
| `ref` | `React.Ref<HTMLElement>` | — | Ref forwarded via Button > Interactive.Base |
| `prominence` | `"light" \| "heavy"` | `"light"` | Select prominence. `"heavy"` shows a tinted background when selected. |
| `selected` | `boolean` | `false` | Switches foreground to action-link colours |
| `transient` | `boolean` | -- | Forces transient (hover) visual state and chevron rotation. Falls back to Radix `data-state="open"` when omitted. |
| `icon` | `IconFunctionComponent` | -- | Left icon component |
| `children` | `string` | -- | Content between icon and chevron |
| `size` | `SizeVariant` | `"default"` | Size preset controlling height, rounding, and padding |
| `tooltip` | `string` | -- | Tooltip text shown on hover |
| `tooltipSide` | `TooltipSide` | `"top"` | Which side the tooltip appears on |
| `disabled` | `boolean` | `false` | Disables the button |
| `href` | `string` | -- | URL; renders an `<a>` wrapper |
| `onClick` | `MouseEventHandler<HTMLElement>` | -- | Click handler |
| _...and all other `ButtonProps` (minus variant props) / `InteractiveBaseProps`_ | | | `group`, `ref`, etc. |
## Usage examples
@@ -46,21 +45,35 @@ OpenButton
import { OpenButton } from "@opal/components";
import { SvgFilter } from "@opal/icons";
// With Radix Popover (auto-detects open state)
// Basic usage with Radix Popover (auto-detects open state from data-state)
<Popover.Trigger asChild>
<OpenButton>Select option</OpenButton>
<OpenButton>
Select option
</OpenButton>
</Popover.Trigger>
// Explicit transient control
// Explicit transient control (chevron rotates AND button shows hover state)
<OpenButton transient={isExpanded} onClick={toggle}>
Advanced settings
</OpenButton>
// With left icon and heavy prominence
// With selected state (action-link foreground)
<OpenButton selected={isActive} transient={isExpanded} onClick={toggle}>
Active filter
</OpenButton>
// With left icon and heavy prominence (tinted background when selected)
<OpenButton icon={SvgFilter} prominence="heavy" selected={isActive}>
Filters
</OpenButton>
// Small sizing
<OpenButton size="sm">More</OpenButton>
// Compact sizing
<OpenButton size="compact">
More
</OpenButton>
// With tooltip
<OpenButton tooltip="Expand filters" icon={SvgFilter}>
Filters
</OpenButton>
```

View File

@@ -13,12 +13,6 @@ export {
type OpenButtonProps,
} from "@opal/components/buttons/OpenButton/components";
/* LineItemButton */
export {
LineItemButton,
type LineItemButtonProps,
} from "@opal/components/buttons/LineItemButton/components";
/* Tag */
export {
Tag,

View File

@@ -1,143 +0,0 @@
# Disabled
**Import:** `import { Disabled, useDisabled } from "@opal/core";`
The **single entry point** for disabling any opal component. No opal component exposes its own `disabled` prop — you always wrap with `<Disabled>`.
## Architecture
```
<Disabled disabled={expr}> ← display: contents div, sets data-disabled + context
└─ <Button type="submit">Save</Button> ← reads useDisabled() for JS concerns
```
Two layers work together:
1. **CSS layer** — the `.opal-disabled[data-disabled] > *` selector applies baseline visuals (opacity, cursor, pointer-events) to all direct children. Any component can be disabled without opting in.
2. **JS layer**`DisabledContext` provides `{ isDisabled, allowClick }`. Components that need JS-level disabled behaviour (e.g. `Interactive.Base` blocks `onClick` and `href`) consume this via the `useDisabled()` hook.
### Why a wrapper, not a prop?
- **Single responsibility:** disabled state lives in one place, not duplicated across every component's prop interface.
- **Composable:** wrap a single button, a form section, or an entire page. Works with any component — opal or otherwise.
- **Consistent:** baseline visuals are guaranteed. Components can layer on custom styling (like `Interactive.Base`'s per-variant disabled colours) without every component reimplementing the pattern.
### How `Interactive.Base` integrates
`Interactive.Base` calls `useDisabled()` internally:
- Sets `data-disabled` and `aria-disabled` on the underlying element (same CSS selectors as before).
- Blocks `onClick` and `href` when `isDisabled && !allowClick`.
- Adds `pointer-events: auto` to its own `[data-disabled]` rule, overriding the baseline `pointer-events: none`. This allows hover events to fire (for tooltips on disabled buttons) while JS handles click blocking.
## Props
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `disabled` | `boolean` | `false` | Whether the children are disabled. |
| `allowClick` | `boolean` | `false` | When `true`, pointer events are **not** blocked — only visual styling is applied. Useful for tooltips on disabled elements. |
| `children` | `ReactNode` | **(required)** | Content to disable. |
| `ref` | `React.Ref<HTMLDivElement>` | — | Forwarded ref to the wrapper `<div>`. |
### Data attributes (set on the wrapper div)
| Attribute | When set | Purpose |
|-----------|----------|---------|
| `data-disabled` | `disabled` is truthy | Triggers baseline CSS; descendants can target for custom styling. |
| `data-allow-click` | `disabled && allowClick` | Overrides `pointer-events: none` so clicks pass through. |
## `useDisabled()` hook
```ts
const { isDisabled, allowClick } = useDisabled();
```
Returns the disabled state from the nearest `<Disabled>` ancestor. When no ancestor exists, returns `{ isDisabled: false, allowClick: false }`.
**When to use:** Component authors call this inside components that need JS-level disabled behaviour — blocking clicks, suppressing navigation, setting `aria-disabled`, rendering a native `disabled` attribute on `<button>`, etc.
**When NOT to use:** If a component only needs visual disabled styling (opacity + cursor), the baseline CSS handles it automatically — no hook needed.
## CSS
```css
/* Always present — no layout impact */
.opal-disabled { display: contents; }
/* Baseline disabled visuals on direct children */
.opal-disabled[data-disabled] > * {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
user-select: none;
}
/* allowClick — let pointer events through */
.opal-disabled[data-disabled][data-allow-click] > * {
pointer-events: auto;
}
```
Components that want custom disabled visuals (e.g. `Interactive.Base` uses per-variant background and foreground colours) override the baseline by:
1. Setting `pointer-events: auto` on their own `[data-disabled]` rule.
2. Using the existing variant CSS (e.g. `.interactive[data-disabled]` rules in `interactive/styles.css`).
This follows the same inert-unless-consumed pattern as `--interactive-foreground`.
## Usage
### Disable a single button
```tsx
import { Disabled } from "@opal/core";
import { Button } from "@opal/components";
<Disabled disabled={isSubmitting}>
<Button type="submit">Save</Button>
</Disabled>
```
### Allow hover for tooltips on a disabled button
```tsx
<Disabled disabled={!canEdit} allowClick>
<Button tooltip="Upgrade to edit" onClick={handleEdit}>Edit</Button>
</Disabled>
```
### Disable an entire section
```tsx
<Disabled disabled={!isEnabled}>
<div>
<InputTypeIn placeholder="Name" />
<Button>Submit</Button>
</div>
</Disabled>
```
### Nesting
`Disabled` wrappers can nest. The innermost wrapper wins for its subtree:
```tsx
<Disabled disabled>
<div>
<Button>Disabled</Button>
<Disabled disabled={false}>
<Button>Enabled (inner wrapper overrides)</Button>
</Disabled>
</div>
</Disabled>
```
## For component authors
If you are building a new opal component that needs to respond to disabled state:
1. **Do NOT add a `disabled` prop.** Consumers wrap with `<Disabled>` instead.
2. **Visual-only?** You're done — baseline CSS handles it.
3. **Need JS behaviour?** Call `useDisabled()` and use `isDisabled` / `allowClick` to block handlers, set `aria-disabled`, render native `disabled` on `<button>`, etc.
4. **Need custom disabled styling?** Add a `[data-disabled]` CSS rule on your component's class. Set `pointer-events: auto` if your component handles click blocking in JS.

View File

@@ -1,86 +0,0 @@
import "@opal/core/disabled/styles.css";
import React, { createContext, useContext } from "react";
// ---------------------------------------------------------------------------
// Context
// ---------------------------------------------------------------------------
interface DisabledState {
isDisabled: boolean;
allowClick: boolean;
}
const DisabledContext = createContext<DisabledState>({
isDisabled: false,
allowClick: false,
});
/**
* Returns the disabled state from the nearest `<Disabled>` ancestor.
*
* Components use this to implement JS-level disabled behaviour (blocking
* onClick, suppressing href navigation, setting `aria-disabled`, etc.)
* without accepting a `disabled` prop of their own.
*/
function useDisabled(): DisabledState {
return useContext(DisabledContext);
}
// ---------------------------------------------------------------------------
// Disabled component
// ---------------------------------------------------------------------------
interface DisabledProps {
/** Whether the children are disabled. @default false */
disabled?: boolean;
/**
* When `true`, pointer events are **not** blocked — only visual styling is
* applied. Useful when a disabled element still needs to respond to hovers
* (e.g. to show a tooltip explaining *why* it is disabled).
*
* @default false
*/
allowClick?: boolean;
children: React.ReactNode;
/** Forwarded ref. */
ref?: React.Ref<HTMLDivElement>;
}
/**
* Wrapper that marks its children as disabled.
*
* Renders a `display: contents` `<div>` so it adds no layout. When `disabled`
* is `true`, baseline CSS (opacity, cursor, pointer-events) is applied to
* direct children. Components that consume `useDisabled()` can layer on their
* own disabled styling.
*
* @example
* ```tsx
* import { Disabled } from "@opal/core";
*
* <Disabled disabled={isSubmitting}>
* <Button type="submit">Save</Button>
* </Disabled>
* ```
*/
function Disabled({ disabled, allowClick, children, ref }: DisabledProps) {
return (
<DisabledContext.Provider
value={{ isDisabled: !!disabled, allowClick: !!allowClick }}
>
<div
ref={ref}
className="opal-disabled"
data-disabled={disabled || undefined}
data-allow-click={disabled && allowClick ? "" : undefined}
>
{children}
</div>
</DisabledContext.Provider>
);
}
export { Disabled, type DisabledProps, useDisabled };

View File

@@ -1,14 +0,0 @@
.opal-disabled {
display: contents;
}
/* Baseline disabled visuals — applied to direct children */
.opal-disabled[data-disabled] > * {
@apply opacity-50 cursor-not-allowed select-none;
pointer-events: none;
}
/* allowClick mode — let pointer events through */
.opal-disabled[data-disabled][data-allow-click] > * {
pointer-events: auto;
}

View File

@@ -1,55 +0,0 @@
# Hoverable
**Import:** `import { Hoverable } from "@opal/core";`
A compound component for hover-to-reveal patterns. Provides context-based group hover detection as well as local CSS `:hover` mode.
## Sub-components
| Sub-component | Role |
|---|---|
| `Hoverable.Root` | Container that tracks hover state for a named group and provides it via React context. |
| `Hoverable.Item` | Element whose visibility is controlled by hover state. Supports local (CSS `:hover`) and group (context-driven) modes. |
## Hoverable.Root Props
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `group` | `string` | **(required)** | Named group key. Must match the `group` on corresponding `Hoverable.Item`s. |
| `widthVariant` | `WidthVariant` | `"auto"` | Width preset for the root `<div>`. `"auto"` or `"full"`. |
| `ref` | `React.Ref<HTMLDivElement>` | — | Ref forwarded to the root `<div>`. |
| _...and all `HTMLAttributes<HTMLDivElement>`_ | | | `onMouseEnter`, `onMouseLeave`, etc. (merged with internal handlers) |
## Hoverable.Item Props
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `group` | `string` | — | When provided, reads hover state from the nearest matching `Hoverable.Root`. When omitted, uses local CSS `:hover`. |
| `variant` | `"opacity-on-hover"` | `"opacity-on-hover"` | The hover-reveal effect to apply. |
| `ref` | `React.Ref<HTMLDivElement>` | — | Ref forwarded to the item `<div>`. |
| _...and all `HTMLAttributes<HTMLDivElement>`_ | | | |
## Usage
```tsx
import { Hoverable } from "@opal/core";
// Group mode — hovering the card reveals the trash icon
<Hoverable.Root group="card" widthVariant="full">
<Card>
<span>Card content</span>
<Hoverable.Item group="card" variant="opacity-on-hover">
<TrashIcon />
</Hoverable.Item>
</Card>
</Hoverable.Root>
// Local mode — hovering the item itself reveals it
<Hoverable.Item variant="opacity-on-hover">
<TrashIcon />
</Hoverable.Item>
```
## Error handling
If `group` is specified on a `Hoverable.Item` but no matching `Hoverable.Root` ancestor exists, the component throws an error at render time.

View File

@@ -2,7 +2,6 @@ import "@opal/core/hoverable/styles.css";
import React, { createContext, useContext, useState, useCallback } from "react";
import { cn } from "@opal/utils";
import type { WithoutStyles } from "@opal/types";
import { widthVariants, type WidthVariant } from "@opal/shared";
// ---------------------------------------------------------------------------
// Context-per-group registry
@@ -39,10 +38,6 @@ interface HoverableRootProps
extends WithoutStyles<React.HTMLAttributes<HTMLDivElement>> {
children: React.ReactNode;
group: string;
/** Width preset. @default "auto" */
widthVariant?: WidthVariant;
/** Ref forwarded to the root `<div>`. */
ref?: React.Ref<HTMLDivElement>;
}
type HoverableItemVariant = "opacity-on-hover";
@@ -52,8 +47,6 @@ interface HoverableItemProps
children: React.ReactNode;
group?: string;
variant?: HoverableItemVariant;
/** Ref forwarded to the item `<div>`. */
ref?: React.Ref<HTMLDivElement>;
}
// ---------------------------------------------------------------------------
@@ -84,8 +77,6 @@ interface HoverableItemProps
function HoverableRoot({
group,
children,
widthVariant = "auto",
ref,
onMouseEnter: consumerMouseEnter,
onMouseLeave: consumerMouseLeave,
...props
@@ -112,13 +103,7 @@ function HoverableRoot({
return (
<GroupContext.Provider value={hovered}>
<div
{...props}
ref={ref}
className={cn(widthVariants[widthVariant])}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
<div {...props} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}>
{children}
</div>
</GroupContext.Provider>
@@ -162,7 +147,6 @@ function HoverableItem({
group,
variant = "opacity-on-hover",
children,
ref,
...props
}: HoverableItemProps) {
const contextValue = useContext(
@@ -181,7 +165,6 @@ function HoverableItem({
return (
<div
{...props}
ref={ref}
className={cn("hoverable-item")}
data-hoverable-variant={variant}
data-hoverable-active={

View File

@@ -1,10 +1,3 @@
/* Disabled */
export {
Disabled,
type DisabledProps,
useDisabled,
} from "@opal/core/disabled/components";
/* Hoverable */
export {
Hoverable,

View File

@@ -147,51 +147,7 @@ The foundational layer for all clickable surfaces in the design system. Defines
| Sub-component | Role |
|---|---|
| `Interactive.Base` | Applies the `.interactive` CSS class and data-attributes for variant and transient states via Radix Slot. |
| `Interactive.Container` | Structural `<div>` (or `<button>` / `<a>`) with flex layout, border, padding, rounding, and height variant presets. |
## Interactive.Base Props
| Prop | Type | Default | Description |
|---|---|---|---|
| `variant` | `"default" \| "action" \| "danger" \| "select" \| "sidebar" \| "none"` | `"default"` | Visual variant. Determines colour table used. `"none"` disables all background/foreground styling. |
| `prominence` | Depends on `variant` (see below) | Depends on `variant` | Visual weight within the variant. |
| `selected` | `boolean` | — | Only for `"select"` and `"sidebar"` variants. Switches foreground to action-link colours (select) or active-item state (sidebar). |
| `group` | `string` | — | Tailwind group class (e.g. `"group/Card"`) for `group-hover:*` utilities on descendants. |
| `transient` | `boolean` | `false` | Forces the hover visual state regardless of actual pointer state. |
| `href` | `string` | — | URL to navigate to. Passed through Slot to the child (Container renders an `<a>`). |
| `target` | `string` | — | Link target (e.g. `"_blank"`). Only used when `href` is provided. |
| `onClick` | `MouseEventHandler<HTMLElement>` | — | Click handler. Blocked when disabled. |
| `ref` | `React.Ref<HTMLElement>` | — | Ref forwarded to the underlying element via Radix Slot. |
### Variant → Prominence mapping
| `variant` | Allowed `prominence` | Default prominence |
|---|---|---|
| `"default"` | `"primary" \| "secondary" \| "tertiary" \| "internal"` | `"primary"` |
| `"action"` | `"primary" \| "secondary" \| "tertiary" \| "internal"` | `"primary"` |
| `"danger"` | `"primary" \| "secondary" \| "tertiary" \| "internal"` | `"primary"` |
| `"select"` | `"light" \| "heavy"` | `"light"` |
| `"sidebar"` | `"light"` | `"light"` |
| `"none"` | *(not accepted)* | — |
## Interactive.Container Props
| Prop | Type | Default | Description |
|---|---|---|---|
| `type` | `"submit" \| "button" \| "reset"` | — | When provided, renders a `<button>` instead of a `<div>`. Mutually exclusive with `href` from Base. |
| `border` | `boolean` | `false` | Applies a 1px border using the theme's border colour. |
| `roundingVariant` | `"default" \| "compact" \| "mini"` | `"default"` | Border-radius preset (see table below). |
| `heightVariant` | `SizeVariant` | `"lg"` | Height, min-width, and padding preset from the shared `SizeVariant` scale. |
| `widthVariant` | `WidthVariant` | `"auto"` | `"auto"` shrink-wraps, `"full"` stretches to fill parent. |
| `ref` | `React.Ref<HTMLElement>` | — | Ref forwarded to the root element (`<div>`, `<button>`, or `<a>`). |
### `roundingVariant` reference
| Value | Class | Radius |
|---|---|---|
| `"default"` | `rounded-12` | 0.75rem (12px) |
| `"compact"` | `rounded-08` | 0.5rem (8px) |
| `"mini"` | `rounded-04` | 0.25rem (4px) |
| `Interactive.Container` | Structural `<div>` with flex layout, border, padding, rounding, and height variant presets. |
## Foreground colour (`--interactive-foreground`)

View File

@@ -11,7 +11,6 @@ import {
widthVariants,
type WidthVariant,
} from "@opal/shared";
import { useDisabled } from "@opal/core/disabled/components";
// ---------------------------------------------------------------------------
// Types
@@ -121,6 +120,18 @@ interface InteractiveBasePropsBase
*/
transient?: boolean;
/**
* When `true`, disables the interactive element.
*
* Sets `data-disabled` and `aria-disabled` attributes. CSS uses `data-disabled`
* to apply disabled styles (muted colors, `cursor-not-allowed`). Click handlers
* and `href` navigation are blocked in JS, but hover events still fire to
* support tooltips explaining why the element is disabled.
*
* @default false
*/
disabled?: boolean;
/**
* URL to navigate to when clicked.
*
@@ -213,11 +224,11 @@ function InteractiveBase({
selected,
group,
transient,
disabled,
href,
target,
...props
}: InteractiveBaseProps) {
const { isDisabled, allowClick } = useDisabled();
const effectiveProminence =
prominence ??
(variant === "select" || variant === "sidebar" ? "light" : "primary");
@@ -233,20 +244,17 @@ function InteractiveBase({
variant !== "none" ? effectiveProminence : undefined,
"data-transient": transient ? "true" : undefined,
"data-selected": selected ? "true" : undefined,
"data-disabled": isDisabled ? "true" : undefined,
"aria-disabled": isDisabled || undefined,
"data-disabled": disabled ? "true" : undefined,
"aria-disabled": disabled || undefined,
};
const { onClick, ...slotProps } = props;
// When disabled and allowClick is false, block href navigation and clicks.
const blockInteraction = isDisabled && !allowClick;
// href, target, and rel are passed through Slot to the child element
// (typically Interactive.Container), which renders an <a> when href is present.
const linkAttrs = href
? {
href: blockInteraction ? undefined : href,
href: disabled ? undefined : href,
target,
rel: target === "_blank" ? "noopener noreferrer" : undefined,
}
@@ -260,9 +268,9 @@ function InteractiveBase({
{...linkAttrs}
{...slotProps}
onClick={
blockInteraction && href
disabled && href
? (e: React.MouseEvent) => e.preventDefault()
: blockInteraction
: disabled
? undefined
: onClick
}

View File

@@ -4,10 +4,6 @@
}
.interactive[data-disabled] {
@apply cursor-not-allowed;
/* Override baseline pointer-events: none from Disabled's > * selector.
Interactive handles click-blocking in JS; it needs pointer events for
hover/tooltips on disabled elements. */
pointer-events: auto;
}
/* Interactive — container */

View File

@@ -40,9 +40,6 @@ interface BodyLayoutProps {
/** Title prominence. Default: `"default"`. */
prominence?: BodyProminence;
/** Ref forwarded to the root `<div>`. */
ref?: React.Ref<HTMLDivElement>;
}
// ---------------------------------------------------------------------------
@@ -83,7 +80,6 @@ function BodyLayout({
sizePreset = "main-ui",
orientation = "inline",
prominence = "default",
ref,
}: BodyLayoutProps) {
const config = BODY_PRESETS[sizePreset];
const titleColorClass =
@@ -91,7 +87,6 @@ function BodyLayout({
return (
<div
ref={ref}
className="opal-content-body"
data-orientation={orientation}
style={{ gap: config.gap }}

View File

@@ -48,12 +48,6 @@ interface ContentLgProps {
/** Size preset. Default: `"headline"`. */
sizePreset?: ContentLgSizePreset;
/** When `true`, the title color hooks into `Interactive.Base`'s `--interactive-foreground` variable. */
withInteractive?: boolean;
/** Ref forwarded to the root `<div>`. */
ref?: React.Ref<HTMLDivElement>;
}
// ---------------------------------------------------------------------------
@@ -92,8 +86,6 @@ function ContentLg({
description,
editable,
onTitleChange,
withInteractive,
ref,
}: ContentLgProps) {
const [editing, setEditing] = useState(false);
const [editValue, setEditValue] = useState(title);
@@ -112,12 +104,7 @@ function ContentLg({
}
return (
<div
ref={ref}
className="opal-content-lg"
data-interactive={withInteractive || undefined}
style={{ gap: config.gap }}
>
<div className="opal-content-lg" style={{ gap: config.gap }}>
{Icon && (
<div
className={cn(

View File

@@ -61,12 +61,6 @@ interface ContentMdProps {
/** Size preset. Default: `"main-ui"`. */
sizePreset?: ContentMdSizePreset;
/** When `true`, the title color hooks into `Interactive.Base`'s `--interactive-foreground` variable. */
withInteractive?: boolean;
/** Ref forwarded to the root `<div>`. */
ref?: React.Ref<HTMLDivElement>;
}
// ---------------------------------------------------------------------------
@@ -136,8 +130,6 @@ function ContentMd({
auxIcon,
tag,
sizePreset = "main-ui",
withInteractive,
ref,
}: ContentMdProps) {
const [editing, setEditing] = useState(false);
const [editValue, setEditValue] = useState(title);
@@ -157,12 +149,7 @@ function ContentMd({
}
return (
<div
ref={ref}
className="opal-content-md"
data-interactive={withInteractive || undefined}
style={{ gap: config.gap }}
>
<div className="opal-content-md" style={{ gap: config.gap }}>
{Icon && (
<div
className={cn(

View File

@@ -40,12 +40,6 @@ interface ContentSmProps {
/** Title prominence. Default: `"default"`. */
prominence?: ContentSmProminence;
/** When `true`, the title color hooks into `Interactive.Base`'s `--interactive-foreground` variable. */
withInteractive?: boolean;
/** Ref forwarded to the root `<div>`. */
ref?: React.Ref<HTMLDivElement>;
}
// ---------------------------------------------------------------------------
@@ -86,18 +80,14 @@ function ContentSm({
sizePreset = "main-ui",
orientation = "inline",
prominence = "default",
withInteractive,
ref,
}: ContentSmProps) {
const config = CONTENT_SM_PRESETS[sizePreset];
return (
<div
ref={ref}
className="opal-content-sm"
data-orientation={orientation}
data-prominence={prominence}
data-interactive={withInteractive || undefined}
style={{ gap: config.gap }}
>
{Icon && (

View File

@@ -60,12 +60,6 @@ interface ContentXlProps {
/** Optional tertiary icon rendered in the icon row. */
moreIcon2?: IconFunctionComponent;
/** When `true`, the title color hooks into `Interactive.Base`'s `--interactive-foreground` variable. */
withInteractive?: boolean;
/** Ref forwarded to the root `<div>`. */
ref?: React.Ref<HTMLDivElement>;
}
// ---------------------------------------------------------------------------
@@ -112,8 +106,6 @@ function ContentXl({
onTitleChange,
moreIcon1: MoreIcon1,
moreIcon2: MoreIcon2,
withInteractive,
ref,
}: ContentXlProps) {
const [editing, setEditing] = useState(false);
const [editValue, setEditValue] = useState(title);
@@ -132,11 +124,7 @@ function ContentXl({
}
return (
<div
ref={ref}
className="opal-content-xl"
data-interactive={withInteractive || undefined}
>
<div className="opal-content-xl">
{(Icon || MoreIcon1 || MoreIcon2) && (
<div className="opal-content-xl-icon-row">
{Icon && (

View File

@@ -52,9 +52,6 @@ interface HeadingLayoutProps {
/** Variant controls icon placement. `"heading"` = top, `"section"` = inline. Default: `"heading"`. */
variant?: HeadingVariant;
/** Ref forwarded to the root `<div>`. */
ref?: React.Ref<HTMLDivElement>;
}
// ---------------------------------------------------------------------------
@@ -94,7 +91,6 @@ function HeadingLayout({
description,
editable,
onTitleChange,
ref,
}: HeadingLayoutProps) {
const [editing, setEditing] = useState(false);
const [editValue, setEditValue] = useState(title);
@@ -116,7 +112,6 @@ function HeadingLayout({
return (
<div
ref={ref}
className="opal-content-heading"
data-icon-placement={iconPlacement}
style={{ gap: iconPlacement === "left" ? config.gap : undefined }}

View File

@@ -61,9 +61,6 @@ interface LabelLayoutProps {
/** Size preset. Default: `"main-ui"`. */
sizePreset?: LabelSizePreset;
/** Ref forwarded to the root `<div>`. */
ref?: React.Ref<HTMLDivElement>;
}
// ---------------------------------------------------------------------------
@@ -133,7 +130,6 @@ function LabelLayout({
auxIcon,
tag,
sizePreset = "main-ui",
ref,
}: LabelLayoutProps) {
const [editing, setEditing] = useState(false);
const [editValue, setEditValue] = useState(title);
@@ -153,7 +149,7 @@ function LabelLayout({
}
return (
<div ref={ref} className="opal-content-label" style={{ gap: config.gap }}>
<div className="opal-content-label" style={{ gap: config.gap }}>
{Icon && (
<div
className={cn(

View File

@@ -30,14 +30,6 @@ A two-axis layout component for displaying icon + title + description rows. Rout
| `main-ui` | 1rem (16px) | `p-0.5` (2px) | `text-03` | 0.25rem (4px) | `font-main-ui-action` | 1.25rem (20px) |
| `secondary` | 0.75rem (12px) | `p-0.5` (2px) | `text-04` | 0.125rem (2px) | `font-secondary-action` | 1rem (16px) |
#### ContentSm presets (variant="body")
| Preset | Icon | Icon padding | Gap | Title font | Line-height |
|---|---|---|---|---|---|
| `main-content` | 1rem (16px) | `p-1` (4px) | 0.125rem (2px) | `font-main-content-body` | 1.5rem (24px) |
| `main-ui` | 1rem (16px) | `p-0.5` (2px) | 0.25rem (4px) | `font-main-ui-action` | 1.25rem (20px) |
| `secondary` | 0.75rem (12px) | `p-0.5` (2px) | 0.125rem (2px) | `font-secondary-action` | 1rem (16px) |
> Icon container height (icon + 2 x padding) always equals the title line-height.
### `variant` — controls structure / layout
@@ -61,42 +53,31 @@ Invalid combinations (e.g. `sizePreset="headline" + variant="body"`) are exclude
## Props
### Common props (all variants)
| Prop | Type | Default | Description |
|---|---|---|---|
| `sizePreset` | `SizePreset` | `"headline"` | Size preset (see tables above) |
| `variant` | `ContentVariant` | `"heading"` | Layout variant |
| `variant` | `ContentVariant` | `"heading"` | Layout variant (see table above) |
| `icon` | `IconFunctionComponent` | — | Optional icon component |
| `title` | `string` | **(required)** | Main title text |
| `description` | `string` | — | Optional description (not available for `variant="body"`) |
| `editable` | `boolean` | `false` | Enable inline editing (not available for `variant="body"`) |
| `description` | `string` | — | Optional description below the title |
| `editable` | `boolean` | `false` | Enable inline editing of the title |
| `onTitleChange` | `(newTitle: string) => void` | — | Called when user commits an edit |
| `widthVariant` | `WidthVariant` | `"auto"` | `"auto"` shrink-wraps, `"full"` stretches |
| `withInteractive` | `boolean` | — | Opts title into `Interactive.Base`'s `--interactive-foreground` color |
| `ref` | `React.Ref<HTMLDivElement>` | — | Ref forwarded to the root `<div>` of the resolved layout |
| `moreIcon1` | `IconFunctionComponent` | — | Secondary icon in icon row (ContentXl only) |
| `moreIcon2` | `IconFunctionComponent` | — | Tertiary icon in icon row (ContentXl only) |
### ContentXl-only props (`variant="heading"`)
## Internal Layouts
| Prop | Type | Default | Description |
|---|---|---|---|
| `moreIcon1` | `IconFunctionComponent` | — | Secondary icon in icon row |
| `moreIcon2` | `IconFunctionComponent` | — | Tertiary icon in icon row |
### ContentXl
### ContentMd-only props (`sizePreset="main-content" / "main-ui" / "secondary"`, `variant="section"`)
For `headline` / `section` presets with `variant="heading"`. Icon row on top (flex-col), supports `moreIcon1` and `moreIcon2` in the icon row. Description is always `font-secondary-body text-text-03`.
| Prop | Type | Default | Description |
|---|---|---|---|
| `optional` | `boolean` | — | Renders "(Optional)" beside the title |
| `auxIcon` | `"info-gray" \| "info-blue" \| "warning" \| "error"` | — | Auxiliary status icon beside the title |
| `tag` | `TagProps` | — | Tag rendered beside the title |
### ContentLg
### ContentSm-only props (`variant="body"`)
For `headline` / `section` presets with `variant="section"`. Always inline (flex-row). Description is always `font-secondary-body text-text-03`.
| Prop | Type | Default | Description |
|---|---|---|---|
| `orientation` | `"vertical" \| "inline" \| "reverse"` | `"inline"` | Layout orientation |
| `prominence` | `"default" \| "muted" \| "muted-2x"` | `"default"` | Title prominence |
### ContentMd
For `main-content` / `main-ui` / `secondary` presets. Always inline. Both `icon` and `description` are optional. Description is always `font-secondary-body text-text-03`.
## Usage Examples
@@ -113,34 +94,42 @@ import SvgSearch from "@opal/icons/search";
description="Configure your agent's behavior"
/>
// ContentXl — with more icons
<Content
icon={SvgSearch}
sizePreset="headline"
variant="heading"
title="Agent Settings"
moreIcon1={SvgStar}
moreIcon2={SvgLock}
/>
// ContentLg — section, icon inline
<Content
icon={SvgSearch}
sizePreset="section"
variant="section"
title="Data Sources"
description="Connected integrations"
/>
// ContentMd — with tag and optional marker
// ContentMd — with icon and description
<Content
icon={SvgSearch}
sizePreset="main-ui"
title="Instructions"
tag={{ title: "New", color: "green" }}
optional
description="Agent system prompt"
/>
// ContentSmbody text
// ContentMdtitle only (no icon, no description)
<Content
icon={SvgSearch}
sizePreset="main-ui"
variant="body"
title="Last updated 2 hours ago"
prominence="muted"
sizePreset="main-content"
title="Featured Agent"
/>
// Editable title
<Content
icon={SvgSearch}
sizePreset="headline"
variant="heading"
title="My Agent"

View File

@@ -59,12 +59,6 @@ interface ContentBaseProps {
* @default "auto"
*/
widthVariant?: WidthVariant;
/** When `true`, the title color hooks into `Interactive.Base`'s `--interactive-foreground` variable. */
withInteractive?: boolean;
/** Ref forwarded to the root `<div>` of the resolved layout. */
ref?: React.Ref<HTMLDivElement>;
}
// ---------------------------------------------------------------------------

View File

@@ -22,7 +22,6 @@
.opal-content-xl-icon-row {
@apply flex flex-row items-center;
gap: 0.25rem;
}
/* ---------------------------------------------------------------------------
@@ -386,28 +385,3 @@
.opal-content-sm[data-prominence="muted-2x"] .opal-content-sm-title {
@apply text-text-02;
}
/* ===========================================================================
Interactive-foreground opt-in
When a Content variant is nested inside an Interactive.Base and
`withInteractive` is set, the title delegates its color to the
`--interactive-foreground` CSS variable controlled by the ancestor
Interactive.Base variant.
=========================================================================== */
.opal-content-xl[data-interactive] .opal-content-xl-title {
color: var(--interactive-foreground);
}
.opal-content-lg[data-interactive] .opal-content-lg-title {
color: var(--interactive-foreground);
}
.opal-content-md[data-interactive] .opal-content-md-title {
color: var(--interactive-foreground);
}
.opal-content-sm[data-interactive] .opal-content-sm-title {
color: var(--interactive-foreground);
}

View File

@@ -3,7 +3,6 @@ import { toast } from "@/hooks/useToast";
import { createApiKey, updateApiKey } from "./lib";
import Modal from "@/refresh-components/Modal";
import { Button } from "@opal/components";
import { Disabled } from "@opal/core";
import Text from "@/refresh-components/texts/Text";
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
import InputComboBox from "@/refresh-components/inputs/InputComboBox";
@@ -139,11 +138,9 @@ export default function OnyxApiKeyForm({
</Modal.Body>
<Modal.Footer>
<Disabled disabled={isSubmitting}>
<Button type="submit">
{isUpdate ? "Update" : "Create"}
</Button>
</Disabled>
<Button type="submit" disabled={isSubmitting}>
{isUpdate ? "Update" : "Create"}
</Button>
</Modal.Footer>
</Form>
)}

View File

@@ -8,7 +8,6 @@ import * as InputLayouts from "@/layouts/input-layouts";
import Card from "@/refresh-components/cards/Card";
import Button from "@/refresh-components/buttons/Button";
import { Button as OpalButton } from "@opal/components";
import { Disabled } from "@opal/core";
import Text from "@/refresh-components/texts/Text";
import Message from "@/refresh-components/messages/Message";
import InfoBlock from "@/refresh-components/messages/InfoBlock";
@@ -246,15 +245,14 @@ function SubscriptionCard({
to make changes.
</Text>
) : disabled ? (
<Disabled disabled={isReconnecting}>
<OpalButton
prominence="secondary"
onClick={handleReconnect}
rightIcon={SvgArrowRight}
>
{isReconnecting ? "Connecting..." : "Connect to Stripe"}
</OpalButton>
</Disabled>
<OpalButton
prominence="secondary"
onClick={handleReconnect}
rightIcon={SvgArrowRight}
disabled={isReconnecting}
>
{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
prominence="secondary"
onClick={handleCancel}
disabled={isSubmitting}
>
Cancel
</OpalButton>
</Section>
<div className="billing-content-area">
@@ -463,15 +463,14 @@ function SeatsCard({
No changes to your billing.
</Text>
)}
<Disabled
<OpalButton
onClick={handleConfirm}
disabled={
isSubmitting || newSeatCount === totalSeats || isBelowMinimum
}
>
<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
prominence="secondary"
onClick={handleStartEdit}
icon={SvgPlus}
disabled={isLoadingUsers || disabled || !billing}
>
Update Seats
</OpalButton>
)}
</Section>
</Section>

View File

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

View File

@@ -7,7 +7,6 @@ import Text from "@/refresh-components/texts/Text";
import InputFile from "@/refresh-components/inputs/InputFile";
import { Section } from "@/layouts/general-layouts";
import * as InputLayouts from "@/layouts/input-layouts";
import { Disabled } from "@opal/core";
import { SvgXCircle, SvgCheckCircle, SvgXOctagon } from "@opal/icons";
import { uploadLicense } from "@/lib/billing/svc";
import { LicenseStatus } from "@/lib/billing/interfaces";
@@ -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
prominence="secondary"
onClick={handleClose}
disabled={isActivating}
>
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
onClick={handleActivate}
disabled={isActivating || !licenseKey.trim() || success}
>
{isActivating
? "Activating..."
: hasLicense
? "Update License"
: "Activate License"}
</Button>
</Section>
</Card>
);

View File

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

View File

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

View File

@@ -8,7 +8,6 @@ import PasswordInputTypeIn from "@/refresh-components/inputs/PasswordInputTypeIn
import Modal from "@/refresh-components/Modal";
import { Button } from "@opal/components";
import { Disabled } from "@opal/core";
import { SvgArrowExchange, SvgOnyxLogo } from "@opal/icons";
import type { IconProps } from "@opal/types";
@@ -248,11 +247,13 @@ export const WebProviderSetupModal = memo(
<Button prominence="secondary" type="button" onClick={onClose}>
Cancel
</Button>
<Disabled disabled={!canConnect || isProcessing}>
<Button type="button" onClick={onConnect}>
{isProcessing ? "Connecting..." : "Connect"}
</Button>
</Disabled>
<Button
type="button"
disabled={!canConnect || isProcessing}
onClick={onConnect}
>
{isProcessing ? "Connecting..." : "Connect"}
</Button>
</Modal.Footer>
</Modal.Content>
</Modal>

View File

@@ -12,7 +12,6 @@ import { ThreeDotsLoader } from "@/components/Loading";
import { Callout } from "@/components/ui/callout";
import Button from "@/refresh-components/buttons/Button";
import { Button as OpalButton } from "@opal/components";
import { Disabled } from "@opal/core";
import { cn } from "@/lib/utils";
import {
SvgArrowExchange,
@@ -1012,28 +1011,25 @@ export default function Page() {
{buttonState.label}
</HoverIconButton>
) : (
<Disabled
<OpalButton
prominence="tertiary"
disabled={
buttonState.disabled || !buttonState.onClick
}
onClick={(e) => {
e.stopPropagation();
buttonState.onClick?.();
}}
rightIcon={
buttonState.icon === "arrow"
? SvgArrowExchange
: buttonState.icon === "arrow-circle"
? SvgArrowRightCircle
: undefined
}
>
<OpalButton
prominence="tertiary"
onClick={(e) => {
e.stopPropagation();
buttonState.onClick?.();
}}
rightIcon={
buttonState.icon === "arrow"
? SvgArrowExchange
: buttonState.icon === "arrow-circle"
? SvgArrowRightCircle
: undefined
}
>
{buttonState.label}
</OpalButton>
</Disabled>
{buttonState.label}
</OpalButton>
)}
</div>
</div>
@@ -1206,28 +1202,25 @@ export default function Page() {
{buttonState.label}
</HoverIconButton>
) : (
<Disabled
<OpalButton
prominence="tertiary"
disabled={
buttonState.disabled || !buttonState.onClick
}
onClick={(e) => {
e.stopPropagation();
buttonState.onClick?.();
}}
rightIcon={
buttonState.icon === "arrow"
? SvgArrowExchange
: buttonState.icon === "arrow-circle"
? SvgArrowRightCircle
: undefined
}
>
<OpalButton
prominence="tertiary"
onClick={(e) => {
e.stopPropagation();
buttonState.onClick?.();
}}
rightIcon={
buttonState.icon === "arrow"
? SvgArrowExchange
: buttonState.icon === "arrow-circle"
? SvgArrowRightCircle
: undefined
}
>
{buttonState.label}
</OpalButton>
</Disabled>
{buttonState.label}
</OpalButton>
)}
</div>
</div>

View File

@@ -2,7 +2,6 @@
import { useState, useRef } from "react";
import { Button } from "@opal/components";
import { Disabled } from "@opal/core";
import {
Table,
TableBody,
@@ -184,25 +183,24 @@ export default function InlineFileManagement({
</Button>
) : (
<>
<Disabled disabled={isSaving}>
<Button
prominence="secondary"
onClick={handleCancel}
icon={SvgX}
>
Cancel
</Button>
</Disabled>
<Disabled
<Button
prominence="secondary"
onClick={handleCancel}
icon={SvgX}
disabled={isSaving}
>
Cancel
</Button>
<Button
onClick={handleSaveClick}
icon={SvgCheck}
disabled={
isSaving ||
(selectedFilesToRemove.size === 0 && filesToAdd.length === 0)
}
>
<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
prominence="secondary"
onClick={() => fileInputRef.current?.click()}
icon={SvgPlusCircle}
disabled={isSaving}
>
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
prominence="secondary"
onClick={() => setShowSaveConfirm(false)}
disabled={isSaving}
>
Cancel
</Button>
<Button onClick={handleConfirmSave} disabled={isSaving}>
{isSaving ? "Saving..." : "Confirm & Save"}
</Button>
</Modal.Footer>
</Modal.Content>
</Modal>

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,7 +11,6 @@ import { TextFormField, SectionHeader } from "@/components/Field";
import { Form, Formik } from "formik";
import { User } from "@/lib/types";
import { Button } from "@opal/components";
import { Disabled } from "@opal/core";
import {
Credential,
GoogleDriveCredentialJson,
@@ -562,11 +561,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 type="submit" disabled={isSubmitting}>
{isSubmitting ? "Creating..." : "Create Credential"}
</Button>
</div>
</Form>
)}
@@ -586,35 +583,34 @@ export const DriveAuthSection = ({
Google Drive account.
</p>
</div>
<Disabled disabled={isAuthenticating}>
<Button
onClick={async () => {
setIsAuthenticating(true);
try {
const [authUrl, errorMsg] = await setupGoogleDriveOAuth({
isAdmin: true,
name: "OAuth (uploaded)",
});
<Button
disabled={isAuthenticating}
onClick={async () => {
setIsAuthenticating(true);
try {
const [authUrl, errorMsg] = await setupGoogleDriveOAuth({
isAdmin: true,
name: "OAuth (uploaded)",
});
if (authUrl) {
router.push(authUrl as Route);
} else {
toast.error(errorMsg);
setIsAuthenticating(false);
}
} catch (error) {
toast.error(
`Failed to authenticate with Google Drive - ${error}`
);
if (authUrl) {
router.push(authUrl as Route);
} else {
toast.error(errorMsg);
setIsAuthenticating(false);
}
}}
>
{isAuthenticating
? "Authenticating..."
: "Authenticate with Google Drive"}
</Button>
</Disabled>
} catch (error) {
toast.error(
`Failed to authenticate with Google Drive - ${error}`
);
setIsAuthenticating(false);
}
}}
>
{isAuthenticating
? "Authenticating..."
: "Authenticate with Google Drive"}
</Button>
</div>
);
}

View File

@@ -1,5 +1,4 @@
import { Button } from "@opal/components";
import { Disabled } from "@opal/core";
import { toast } from "@/hooks/useToast";
import React, { useState, useEffect } from "react";
import { useSWRConfig } from "swr";
@@ -571,11 +570,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 type="submit" disabled={isSubmitting}>
{isSubmitting ? "Creating..." : "Create Credential"}
</Button>
</div>
</Form>
)}
@@ -594,36 +591,35 @@ export const GmailAuthSection = ({
read access to the emails you have access to in your Gmail account.
</p>
</div>
<Disabled disabled={isAuthenticating}>
<Button
onClick={async () => {
setIsAuthenticating(true);
try {
if (buildMode) {
Cookies.set(CRAFT_OAUTH_COOKIE_NAME, "true", {
path: "/",
});
}
const [authUrl, errorMsg] = await setupGmailOAuth({
isAdmin: true,
<Button
disabled={isAuthenticating}
onClick={async () => {
setIsAuthenticating(true);
try {
if (buildMode) {
Cookies.set(CRAFT_OAUTH_COOKIE_NAME, "true", {
path: "/",
});
}
const [authUrl, errorMsg] = await setupGmailOAuth({
isAdmin: true,
});
if (authUrl) {
onOAuthRedirect?.();
router.push(authUrl as Route);
} else {
toast.error(errorMsg);
setIsAuthenticating(false);
}
} catch (error) {
toast.error(`Failed to authenticate with Gmail - ${error}`);
if (authUrl) {
onOAuthRedirect?.();
router.push(authUrl as Route);
} else {
toast.error(errorMsg);
setIsAuthenticating(false);
}
}}
>
{isAuthenticating ? "Authenticating..." : "Authenticate with Gmail"}
</Button>
</Disabled>
} catch (error) {
toast.error(`Failed to authenticate with Gmail - ${error}`);
setIsAuthenticating(false);
}
}}
>
{isAuthenticating ? "Authenticating..." : "Authenticate with Gmail"}
</Button>
</div>
);
}

View File

@@ -0,0 +1,190 @@
"use client";
import { useState } from "react";
import Text from "@/refresh-components/texts/Text";
import TableQualifier from "@/refresh-components/table/TableQualifier";
import { TableSizeProvider } from "@/refresh-components/table/TableSizeContext";
import type { TableSize } from "@/refresh-components/table/TableSizeContext";
import type { QualifierContentType } from "@/refresh-components/table/types";
import { SvgCheckCircle } from "@opal/icons";
// ---------------------------------------------------------------------------
// Content type configurations
// ---------------------------------------------------------------------------
interface ContentConfig {
label: string;
content: QualifierContentType;
extraProps: Record<string, unknown>;
}
const CONTENT_TYPES: ContentConfig[] = [
{
label: "Simple",
content: "simple",
extraProps: {},
},
{
label: "Icon",
content: "icon",
extraProps: { icon: SvgCheckCircle },
},
{
label: "Image",
content: "image",
extraProps: {
imageSrc: "https://picsum.photos/36",
imageAlt: "Placeholder",
},
},
{
label: "Avatar Icon",
content: "avatar-icon",
extraProps: {},
},
{
label: "Avatar User",
content: "avatar-user",
extraProps: { initials: "AJ" },
},
];
// ---------------------------------------------------------------------------
// Row of qualifier states for a single content type
// ---------------------------------------------------------------------------
interface QualifierRowProps {
config: ContentConfig;
}
function QualifierRow({ config }: QualifierRowProps) {
const [selectableSelected, setSelectableSelected] = useState(false);
const [permanentSelected, setPermanentSelected] = useState(true);
return (
<div className="space-y-2">
<Text mainUiAction text02>
{config.label}
</Text>
<div className="flex items-start gap-8">
{/* Default */}
<div className="flex w-20 flex-col items-center gap-2">
<TableQualifier
content={config.content}
selectable={false}
selected={false}
disabled={false}
{...config.extraProps}
/>
<Text secondaryBody text04>
Default
</Text>
</div>
{/* Selectable (hover to reveal checkbox) */}
<div className="flex w-20 flex-col items-center gap-2">
<TableQualifier
content={config.content}
selectable={true}
selected={selectableSelected}
disabled={false}
onSelectChange={setSelectableSelected}
{...config.extraProps}
/>
<Text secondaryBody text04>
Selectable
</Text>
</div>
{/* Selected */}
<div className="flex w-20 flex-col items-center gap-2">
<TableQualifier
content={config.content}
selectable={true}
selected={permanentSelected}
disabled={false}
onSelectChange={setPermanentSelected}
{...config.extraProps}
/>
<Text secondaryBody text04>
Selected
</Text>
</div>
{/* Disabled (unselected) */}
<div className="flex w-20 flex-col items-center gap-2">
<TableQualifier
content={config.content}
selectable={true}
selected={false}
disabled={true}
{...config.extraProps}
/>
<Text secondaryBody text04>
Disabled
</Text>
</div>
{/* Disabled (selected) */}
<div className="flex w-20 flex-col items-center gap-2">
<TableQualifier
content={config.content}
selectable={true}
selected={true}
disabled={true}
{...config.extraProps}
/>
<Text secondaryBody text04>
Disabled+Sel
</Text>
</div>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Size section — all content types at a given size
// ---------------------------------------------------------------------------
interface SizeSectionProps {
size: TableSize;
title: string;
}
function SizeSection({ size, title }: SizeSectionProps) {
return (
<div className="space-y-6">
<Text headingH3>{title}</Text>
<TableSizeProvider size={size}>
<div className="flex flex-col gap-8">
{CONTENT_TYPES.map((config) => (
<QualifierRow key={`${size}-${config.content}`} config={config} />
))}
</div>
</TableSizeProvider>
</div>
);
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default function TableQualifierDemoPage() {
return (
<div className="p-6 space-y-10">
<div className="space-y-4">
<Text headingH2>TableQualifier Demo</Text>
<Text mainContentMuted text03>
All content types, sizes, and interactive states. Hover selectable
variants to reveal the checkbox; click to toggle.
</Text>
</div>
<SizeSection size="regular" title="Regular (36px)" />
<SizeSection size="small" title="Small (28px)" />
</div>
);
}

View File

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

View File

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

View File

@@ -13,7 +13,6 @@ import Card from "@/refresh-components/cards/Card";
import { Callout } from "@/components/ui/callout";
import Message from "@/refresh-components/messages/Message";
import { Button } from "@opal/components";
import { Disabled } from "@opal/core";
import { SvgServer } from "@opal/icons";
import InputSelect from "@/refresh-components/inputs/InputSelect";
import {
@@ -105,16 +104,20 @@ function GuildDetailContent({
width="fit"
gap={0.5}
>
<Disabled disabled={disabled}>
<Button prominence="secondary" onClick={handleEnableAll}>
Enable All
</Button>
</Disabled>
<Disabled disabled={disabled}>
<Button prominence="secondary" onClick={handleDisableAll}>
Disable All
</Button>
</Disabled>
<Button
prominence="secondary"
onClick={handleEnableAll}
disabled={disabled}
>
Enable All
</Button>
<Button
prominence="secondary"
onClick={handleDisableAll}
disabled={disabled}
>
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 onClick={handleSaveChanges} disabled={isUpdateDisabled}>
Update Configuration
</Button>
}
/>
<SettingsLayouts.Body>

View File

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

View File

@@ -7,7 +7,6 @@ import { useCallback, useEffect, useMemo, useState, useRef } from "react";
import Text from "@/refresh-components/texts/Text";
import Button from "@/refresh-components/buttons/Button";
import { Button as OpalButton } from "@opal/components";
import { Disabled } from "@opal/core";
import { WarningCircle, Warning, CaretDownIcon } from "@phosphor-icons/react";
import {
CloudEmbeddingModel,
@@ -377,16 +376,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
onClick={() => {
updateSearch();
navigateToEmbeddingPage("search settings");
}}
disabled={!isOverallFormValid}
>
Update Search
</OpalButton>
{!isOverallFormValid &&
Object.keys(combinedFormErrors).length > 0 && (
<div className="relative group">

View File

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

View File

@@ -1,5 +1,5 @@
import { SvgDownload, SvgKey, SvgRefreshCw } from "@opal/icons";
import { Disabled, Interactive } from "@opal/core";
import { Interactive } from "@opal/core";
import { Section } from "@/layouts/general-layouts";
import { Button } from "@opal/components";
import Text from "@/refresh-components/texts/Text";
@@ -54,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
variant="danger"
onClick={onRegenerate}
disabled={isSubmitting}
>
Regenerate Token
</Button>
}
>
<Section alignItems="start" gap={0.5}>

View File

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

View File

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

View File

@@ -19,7 +19,6 @@ import { NEXT_PUBLIC_CLOUD_ENABLED } from "@/lib/constants";
import PendingUsersTable from "@/components/admin/users/PendingUsersTable";
import CreateButton from "@/refresh-components/buttons/CreateButton";
import { Button } from "@opal/components";
import { Disabled } from "@opal/core";
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
import { Spinner } from "@/components/Spinner";
import { SvgDownloadCloud, SvgUserPlus } from "@opal/icons";
@@ -150,14 +149,13 @@ function UsersTables({
<CardHeader>
<div className="flex justify-between items-center gap-1">
<CardTitle>Current Users</CardTitle>
<Disabled disabled={isDownloadingUsers}>
<Button
icon={SvgDownloadCloud}
onClick={() => downloadAllUsers()}
>
{isDownloadingUsers ? "Downloading..." : "Download CSV"}
</Button>
</Disabled>
<Button
icon={SvgDownloadCloud}
disabled={isDownloadingUsers}
onClick={() => downloadAllUsers()}
>
{isDownloadingUsers ? "Downloading..." : "Download CSV"}
</Button>
</div>
</CardHeader>
<CardContent>

View File

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

View File

@@ -6,7 +6,6 @@ import Title from "@/components/ui/title";
import Text from "@/components/ui/text";
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";
@@ -64,11 +63,9 @@ const ForgotPasswordPage: React.FC = () => {
/>
<div className="flex">
<Disabled disabled={isSubmitting}>
<Button type="submit" width="full">
Reset Password
</Button>
</Disabled>
<Button type="submit" disabled={isSubmitting} width="full">
Reset Password
</Button>
</div>
</Form>
)}

View File

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

View File

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

View File

@@ -6,7 +6,6 @@ import Title from "@/components/ui/title";
import Text from "@/components/ui/text";
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";
@@ -100,11 +99,9 @@ const ResetPasswordPage: React.FC = () => {
/>
<div className="flex">
<Disabled disabled={isSubmitting}>
<Button type="submit" width="full">
Reset Password
</Button>
</Disabled>
<Button type="submit" disabled={isSubmitting} width="full">
Reset Password
</Button>
</div>
</Form>
)}

View File

@@ -14,7 +14,7 @@ import {
} from "react";
import { useRouter } from "next/navigation";
import { cn, isImageFile } from "@/lib/utils";
import { Disabled } from "@opal/core";
import { Disabled } from "@/refresh-components/Disabled";
import {
useUploadFilesContext,
BuildFile,
@@ -373,14 +373,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
icon={SvgPaperclip}
tooltip="Attach Files"
prominence="tertiary"
disabled={disabled}
onClick={() => fileInputRef.current?.click()}
/>
{/* Demo Data indicator pill - only show on welcome page (no session) when demo data is enabled */}
{demoDataEnabled && isWelcomePage && (
<SimpleTooltip

View File

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

View File

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

View File

@@ -2,7 +2,7 @@
import { SvgCheckCircle } from "@opal/icons";
import { cn } from "@/lib/utils";
import { Disabled } from "@opal/core";
import { Disabled } from "@/refresh-components/Disabled";
import Text from "@/refresh-components/texts/Text";
import SimpleTooltip from "@/refresh-components/SimpleTooltip";
import { LLMProviderName, LLMProviderDescriptor } from "@/interfaces/llm";

View File

@@ -1,7 +1,7 @@
"use client";
import { cn } from "@/lib/utils";
import { Disabled } from "@opal/core";
import { Disabled } from "@/refresh-components/Disabled";
import Text from "@/refresh-components/texts/Text";
import {
WorkArea,

View File

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

View File

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

View File

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

View File

@@ -13,7 +13,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";
@@ -261,15 +260,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
prominence="secondary"
icon={SvgUploadCloud}
onClick={() => handleUploadToFolder("/")}
disabled={isUploading}
tooltip={isUploading ? "Uploading..." : "Upload"}
aria-label={isUploading ? "Uploading..." : "Upload"}
/>
</Section>
{isLoading ? (
@@ -383,9 +381,12 @@ export default function UserLibraryModal({
>
Cancel
</Button>
<Disabled disabled={!newFolderName.trim()}>
<Button onClick={handleCreateDirectory}>Create</Button>
</Disabled>
<Button
onClick={handleCreateDirectory}
disabled={!newFolderName.trim()}
>
Create
</Button>
</Modal.Footer>
</Modal.Content>
</Modal>

View File

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

View File

@@ -27,7 +27,6 @@ import {
} from "@/components/ui/table";
import SimpleTooltip from "@/refresh-components/SimpleTooltip";
import { Button } from "@opal/components";
import { Disabled } from "@opal/core";
import { DeleteButton } from "@/components/DeleteButton";
import { Bubble } from "@/components/Bubble";
import { BookmarkIcon, RobotIcon } from "@/components/icons/icons";
@@ -279,17 +278,16 @@ export const GroupDisplay = ({
tooltip="Cannot update group while sync is occurring"
disabled={userGroup.is_up_to_date}
>
<Disabled disabled={!userGroup.is_up_to_date}>
<Button
onClick={() => {
if (userGroup.is_up_to_date) {
setAddMemberFormVisible(true);
}
}}
>
Add Users
</Button>
</Disabled>
<Button
disabled={!userGroup.is_up_to_date}
onClick={() => {
if (userGroup.is_up_to_date) {
setAddMemberFormVisible(true);
}
}}
>
Add Users
</Button>
</SimpleTooltip>
{addMemberFormVisible && (
<AddMemberForm
@@ -380,17 +378,16 @@ export const GroupDisplay = ({
tooltip="Cannot update group while sync is occurring"
disabled={userGroup.is_up_to_date}
>
<Disabled disabled={!userGroup.is_up_to_date}>
<Button
onClick={() => {
if (userGroup.is_up_to_date) {
setAddConnectorFormVisible(true);
}
}}
>
Add Connectors
</Button>
</Disabled>
<Button
disabled={!userGroup.is_up_to_date}
onClick={() => {
if (userGroup.is_up_to_date) {
setAddConnectorFormVisible(true);
}
}}
>
Add Connectors
</Button>
</SimpleTooltip>
{addConnectorFormVisible && (

View File

@@ -16,7 +16,6 @@ import Text from "@/components/ui/text";
import Title from "@/components/ui/title";
import Button from "@/refresh-components/buttons/Button";
import { Button as OpalButton } from "@opal/components";
import { Disabled } from "@opal/core";
import useSWR from "swr";
import React, { useState } from "react";
import { UsageReport } from "./types";
@@ -201,15 +200,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
color={"blue"}
icon={SvgDownloadCloud}
disabled={isLoading || isWaitingForReport}
onClick={() => requestReport()}
>
{isWaitingForReport ? "Generating..." : "Generate Report"}
</OpalButton>
<p className="mt-1 text-xs">
{isWaitingForReport
? "A report is currently being generated. Please wait..."

View File

@@ -10,7 +10,6 @@ import Switch from "@/refresh-components/inputs/Switch";
import CharacterCount from "@/refresh-components/CharacterCount";
import InputImage from "@/refresh-components/inputs/InputImage";
import { Button } from "@opal/components";
import { Disabled } from "@opal/core";
import { useFormikContext } from "formik";
import {
forwardRef,
@@ -313,15 +312,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
prominence="secondary"
disabled={!hasLogo}
onClick={handleLogoEdit}
icon={SvgEdit}
>
Update
</Button>
</div>
</FormField>
</div>

View File

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

View File

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

View File

@@ -2,7 +2,7 @@ import { FormikProps, ErrorMessage } from "formik";
import Text from "@/refresh-components/texts/Text";
import Button from "@/refresh-components/buttons/Button";
import InputComboBox from "@/refresh-components/inputs/InputComboBox/InputComboBox";
import { Disabled } from "@opal/core";
import { Disabled } from "@/refresh-components/Disabled";
import { SvgX } from "@opal/icons";
export type GenericMultiSelectFormType<T extends string> = {
[K in T]: number[];

View File

@@ -2,7 +2,6 @@
import { withFormik, FormikProps, FormikErrors, Form, Field } from "formik";
import Button from "@/refresh-components/buttons/Button";
import { Disabled } from "@opal/core";
const WHITESPACE_SPLIT = /\s+/;
const EMAIL_REGEX = /[^@]+@[^.]+\.[^.]/;
@@ -62,11 +61,9 @@ const AddUserFormRenderer = ({
<div className="text-error text-sm">{errors.emails}</div>
)}
{/* TODO(@raunakab): migrate to opal Button once className/iconClassName is resolved */}
<Disabled disabled={isSubmitting}>
<Button type="submit" className="self-end">
Add
</Button>
</Disabled>
<Button type="submit" disabled={isSubmitting} className="self-end">
Add
</Button>
</Form>
);

View File

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

View File

@@ -3,7 +3,6 @@
import { useState, useEffect } from "react";
import Modal from "@/refresh-components/Modal";
import { Button } from "@opal/components";
import { Disabled } from "@opal/core";
import { Input } from "@/components/ui/input";
import Label from "@/refresh-components/form/Label";
import Text from "@/refresh-components/texts/Text";
@@ -251,12 +250,15 @@ 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
prominence="secondary"
onClick={handleClose}
disabled={isSubmitting}
>
Cancel
</Button>
<Button
type="submit"
disabled={
isSubmitting ||
(isTemplateMode
@@ -266,14 +268,12 @@ export default function MCPApiKeyModal({
: !apiKey.trim())
}
>
<Button type="submit">
{isSubmitting
? "Saving..."
: isAuthenticated
? `Update ${credsType}`
: `Save ${credsType}`}
</Button>
</Disabled>
{isSubmitting
? "Saving..."
: isAuthenticated
? `Update ${credsType}`
: `Save ${credsType}`}
</Button>
</div>
</form>
</Modal.Body>

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