mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-03-10 10:12:40 +00:00
Compare commits
8 Commits
opal/disab
...
chore/upda
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b45b7fc87 | ||
|
|
f46afd70fb | ||
|
|
c5c236d098 | ||
|
|
b18baff4d0 | ||
|
|
eb3e15c195 | ||
|
|
47d9a9e1ac | ||
|
|
aca466b35d | ||
|
|
5176fd7386 |
15
.vscode/launch.json
vendored
15
.vscode/launch.json
vendored
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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: "
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -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],
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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. "
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)])
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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="..."` |
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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" />}
|
||||
/>
|
||||
```
|
||||
@@ -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 };
|
||||
@@ -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>
|
||||
```
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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={
|
||||
|
||||
@@ -1,10 +1,3 @@
|
||||
/* Disabled */
|
||||
export {
|
||||
Disabled,
|
||||
type DisabledProps,
|
||||
useDisabled,
|
||||
} from "@opal/core/disabled/components";
|
||||
|
||||
/* Hoverable */
|
||||
export {
|
||||
Hoverable,
|
||||
|
||||
@@ -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`)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
// ContentSm — body text
|
||||
// ContentMd — title 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"
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
190
web/src/app/admin/demo/table-qualifier/page.tsx
Normal file
190
web/src/app/admin/demo/table-qualifier/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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..."
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user