mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-03-20 07:02:41 +00:00
Compare commits
2 Commits
jamison/wo
...
bo/hook_fr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b03f8753a | ||
|
|
e91e275f9d |
@@ -157,7 +157,9 @@ def _execute_single_retrieval(
|
||||
logger.error(f"Error executing request: {e}")
|
||||
raise e
|
||||
elif _is_rate_limit_error(e):
|
||||
results = _execute_with_retry(retrieval_function(**request_kwargs))
|
||||
results = _execute_with_retry(
|
||||
lambda: retrieval_function(**request_kwargs).execute()
|
||||
)
|
||||
elif e.resp.status == 404 or e.resp.status == 403:
|
||||
if continue_on_404_or_403:
|
||||
logger.debug(f"Error executing request: {e}")
|
||||
|
||||
@@ -42,12 +42,8 @@ class HookUpdateRequest(BaseModel):
|
||||
name: str | None = None
|
||||
endpoint_url: str | None = None
|
||||
api_key: NonEmptySecretStr | None = None
|
||||
fail_strategy: HookFailStrategy | None = (
|
||||
None # if None in model_fields_set, reset to spec default
|
||||
)
|
||||
timeout_seconds: float | None = Field(
|
||||
default=None, gt=0
|
||||
) # if None in model_fields_set, reset to spec default
|
||||
fail_strategy: HookFailStrategy | None = None
|
||||
timeout_seconds: float | None = Field(default=None, gt=0)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def require_at_least_one_field(self) -> "HookUpdateRequest":
|
||||
@@ -60,6 +56,14 @@ class HookUpdateRequest(BaseModel):
|
||||
and not (self.endpoint_url or "").strip()
|
||||
):
|
||||
raise ValueError("endpoint_url cannot be cleared.")
|
||||
if "fail_strategy" in self.model_fields_set and self.fail_strategy is None:
|
||||
raise ValueError(
|
||||
"fail_strategy cannot be null; omit the field to leave it unchanged."
|
||||
)
|
||||
if "timeout_seconds" in self.model_fields_set and self.timeout_seconds is None:
|
||||
raise ValueError(
|
||||
"timeout_seconds cannot be null; omit the field to leave it unchanged."
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
@@ -90,38 +94,28 @@ class HookResponse(BaseModel):
|
||||
fail_strategy: HookFailStrategy
|
||||
timeout_seconds: float # always resolved — None from request is replaced with spec default before DB write
|
||||
is_active: bool
|
||||
is_reachable: bool | None
|
||||
creator_email: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class HookValidateStatus(str, Enum):
|
||||
passed = "passed" # server responded (any status except 401/403)
|
||||
auth_failed = "auth_failed" # server responded with 401 or 403
|
||||
timeout = (
|
||||
"timeout" # TCP connected, but read/write timed out (server exists but slow)
|
||||
)
|
||||
cannot_connect = "cannot_connect" # could not connect to the server
|
||||
|
||||
|
||||
class HookValidateResponse(BaseModel):
|
||||
success: bool
|
||||
status: HookValidateStatus
|
||||
error_message: str | None = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Health models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class HookHealthStatus(str, Enum):
|
||||
healthy = "healthy" # green — reachable, no failures in last 1h
|
||||
degraded = "degraded" # yellow — reachable, failures in last 1h
|
||||
unreachable = "unreachable" # red — is_reachable=false or null
|
||||
|
||||
|
||||
class HookFailureRecord(BaseModel):
|
||||
error_message: str | None = None
|
||||
status_code: int | None = None
|
||||
duration_ms: int | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class HookHealthResponse(BaseModel):
|
||||
status: HookHealthStatus
|
||||
recent_failures: list[HookFailureRecord] = Field(
|
||||
default_factory=list,
|
||||
description="Last 10 failures, newest first",
|
||||
max_length=10,
|
||||
)
|
||||
|
||||
@@ -40,6 +40,14 @@ class HookPointSpec(ABC):
|
||||
docs_url: str | None = None
|
||||
|
||||
def __init_subclass__(cls, **kwargs: object) -> None:
|
||||
"""Enforce that every concrete subclass declares all required class attributes.
|
||||
|
||||
Called automatically by Python whenever a class inherits from HookPointSpec.
|
||||
Abstract subclasses (those still carrying unimplemented abstract methods) are
|
||||
skipped — they are intermediate base classes and may not yet define everything.
|
||||
Only fully concrete subclasses are validated, ensuring a clear TypeError at
|
||||
import time rather than a confusing AttributeError at runtime.
|
||||
"""
|
||||
super().__init_subclass__(**kwargs)
|
||||
# Skip intermediate abstract subclasses — they may still be partially defined.
|
||||
if getattr(cls, "__abstractmethods__", None):
|
||||
|
||||
@@ -530,11 +530,6 @@ class LitellmLLM(LLM):
|
||||
):
|
||||
messages = _strip_tool_content_from_messages(messages)
|
||||
|
||||
# Only pass tool_choice when tools are present — some providers (e.g. Fireworks)
|
||||
# reject requests where tool_choice is explicitly null.
|
||||
if tools and tool_choice is not None:
|
||||
optional_kwargs["tool_choice"] = tool_choice
|
||||
|
||||
response = litellm.completion(
|
||||
mock_response=get_llm_mock_response() or MOCK_LLM_RESPONSE,
|
||||
model=model,
|
||||
@@ -543,6 +538,7 @@ class LitellmLLM(LLM):
|
||||
custom_llm_provider=self._custom_llm_provider or None,
|
||||
messages=messages,
|
||||
tools=tools,
|
||||
tool_choice=tool_choice,
|
||||
stream=stream,
|
||||
temperature=temperature,
|
||||
timeout=timeout_override or self._timeout,
|
||||
|
||||
@@ -77,6 +77,7 @@ from onyx.server.features.default_assistant.api import (
|
||||
)
|
||||
from onyx.server.features.document_set.api import router as document_set_router
|
||||
from onyx.server.features.hierarchy.api import router as hierarchy_router
|
||||
from onyx.server.features.hooks.api import router as hook_router
|
||||
from onyx.server.features.input_prompt.api import (
|
||||
admin_router as admin_input_prompt_router,
|
||||
)
|
||||
@@ -453,6 +454,7 @@ def get_application(lifespan_override: Lifespan | None = None) -> FastAPI:
|
||||
|
||||
register_onyx_exception_handlers(application)
|
||||
|
||||
include_router_with_global_prefix_prepended(application, hook_router)
|
||||
include_router_with_global_prefix_prepended(application, password_router)
|
||||
include_router_with_global_prefix_prepended(application, chat_router)
|
||||
include_router_with_global_prefix_prepended(application, query_router)
|
||||
|
||||
0
backend/onyx/server/features/hooks/__init__.py
Normal file
0
backend/onyx/server/features/hooks/__init__.py
Normal file
510
backend/onyx/server/features/hooks/api.py
Normal file
510
backend/onyx/server/features/hooks/api.py
Normal file
@@ -0,0 +1,510 @@
|
||||
import ipaddress
|
||||
import socket
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter
|
||||
from fastapi import Depends
|
||||
from fastapi import Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from onyx.auth.users import current_admin_user
|
||||
from onyx.auth.users import User
|
||||
from onyx.db.constants import UNSET
|
||||
from onyx.db.constants import UnsetType
|
||||
from onyx.db.engine.sql_engine import get_session
|
||||
from onyx.db.engine.sql_engine import get_session_with_current_tenant
|
||||
from onyx.db.hook import create_hook__no_commit
|
||||
from onyx.db.hook import delete_hook__no_commit
|
||||
from onyx.db.hook import get_hook_by_id
|
||||
from onyx.db.hook import get_hook_execution_logs
|
||||
from onyx.db.hook import get_hooks
|
||||
from onyx.db.hook import update_hook__no_commit
|
||||
from onyx.db.models import Hook
|
||||
from onyx.error_handling.error_codes import OnyxErrorCode
|
||||
from onyx.error_handling.exceptions import OnyxError
|
||||
from onyx.hooks.api_dependencies import require_hook_enabled
|
||||
from onyx.hooks.models import HookCreateRequest
|
||||
from onyx.hooks.models import HookFailureRecord
|
||||
from onyx.hooks.models import HookPointMetaResponse
|
||||
from onyx.hooks.models import HookResponse
|
||||
from onyx.hooks.models import HookUpdateRequest
|
||||
from onyx.hooks.models import HookValidateResponse
|
||||
from onyx.hooks.models import HookValidateStatus
|
||||
from onyx.hooks.registry import get_all_specs
|
||||
from onyx.hooks.registry import get_hook_point_spec
|
||||
from onyx.utils.logger import setup_logger
|
||||
|
||||
logger = setup_logger()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SSRF protection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# RFC 1918 private ranges, loopback, link-local (IMDS at 169.254.169.254),
|
||||
# shared address space, and IPv6 equivalents.
|
||||
_BLOCKED_NETWORKS: list[ipaddress.IPv4Network | ipaddress.IPv6Network] = [
|
||||
ipaddress.ip_network("127.0.0.0/8"), # loopback
|
||||
ipaddress.ip_network("10.0.0.0/8"), # RFC 1918
|
||||
ipaddress.ip_network("172.16.0.0/12"), # RFC 1918
|
||||
ipaddress.ip_network("192.168.0.0/16"), # RFC 1918
|
||||
ipaddress.ip_network("169.254.0.0/16"), # link-local / cloud IMDS
|
||||
ipaddress.ip_network("100.64.0.0/10"), # shared address space (RFC 6598)
|
||||
ipaddress.ip_network("0.0.0.0/8"), # "this" network
|
||||
ipaddress.ip_network("::1/128"), # IPv6 loopback
|
||||
ipaddress.ip_network("fc00::/7"), # IPv6 ULA
|
||||
ipaddress.ip_network("fe80::/10"), # IPv6 link-local
|
||||
]
|
||||
|
||||
|
||||
def _check_ssrf_safety(endpoint_url: str) -> None:
|
||||
"""Raise OnyxError if endpoint_url could be used for SSRF.
|
||||
|
||||
Checks:
|
||||
1. Scheme must be https.
|
||||
2. All IPs the hostname resolves to must be public — private/reserved
|
||||
ranges (RFC 1918, link-local, loopback, cloud IMDS) are blocked.
|
||||
|
||||
Note: this provides a good-faith pre-flight check. DNS rebinding attacks
|
||||
(where the hostname re-resolves to a different IP after this check) are
|
||||
outside the threat model for a single-tenant, admin-only API.
|
||||
"""
|
||||
parsed = urlparse(endpoint_url)
|
||||
|
||||
scheme = (parsed.scheme or "").lower()
|
||||
if scheme != "https":
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.INVALID_INPUT,
|
||||
f"Hook endpoint URL must use https, got: {scheme!r}",
|
||||
)
|
||||
|
||||
hostname = parsed.hostname
|
||||
if not hostname:
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.INVALID_INPUT,
|
||||
f"Could not parse hostname from URL: {endpoint_url}",
|
||||
)
|
||||
|
||||
try:
|
||||
resolved = socket.getaddrinfo(hostname, None)
|
||||
except socket.gaierror as e:
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.INVALID_INPUT,
|
||||
f"Could not resolve hostname {hostname!r}: {e}",
|
||||
)
|
||||
|
||||
for addr_info in resolved:
|
||||
ip_str = addr_info[4][0]
|
||||
try:
|
||||
ip = ipaddress.ip_address(ip_str)
|
||||
except ValueError:
|
||||
continue
|
||||
for blocked in _BLOCKED_NETWORKS:
|
||||
if ip in blocked:
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.INVALID_INPUT,
|
||||
f"Hook endpoint URL resolves to a private or reserved IP address "
|
||||
f"({ip}), which is not permitted.",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _hook_to_response(hook: Hook, creator_email: str | None = None) -> HookResponse:
|
||||
return HookResponse(
|
||||
id=hook.id,
|
||||
name=hook.name,
|
||||
hook_point=hook.hook_point,
|
||||
endpoint_url=hook.endpoint_url,
|
||||
fail_strategy=hook.fail_strategy,
|
||||
timeout_seconds=hook.timeout_seconds,
|
||||
is_active=hook.is_active,
|
||||
is_reachable=hook.is_reachable,
|
||||
creator_email=(
|
||||
creator_email
|
||||
if creator_email is not None
|
||||
else (hook.creator.email if hook.creator else None)
|
||||
),
|
||||
created_at=hook.created_at,
|
||||
updated_at=hook.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _get_hook_or_404(
|
||||
db_session: Session,
|
||||
hook_id: int,
|
||||
include_creator: bool = False,
|
||||
) -> Hook:
|
||||
hook = get_hook_by_id(
|
||||
db_session=db_session,
|
||||
hook_id=hook_id,
|
||||
include_creator=include_creator,
|
||||
)
|
||||
if hook is None:
|
||||
raise OnyxError(OnyxErrorCode.NOT_FOUND, f"Hook {hook_id} not found.")
|
||||
return hook
|
||||
|
||||
|
||||
def _raise_for_validation_failure(validation: HookValidateResponse) -> None:
|
||||
"""Raise an appropriate OnyxError for a non-passed validation result."""
|
||||
if validation.status == HookValidateStatus.auth_failed:
|
||||
raise OnyxError(OnyxErrorCode.CREDENTIAL_INVALID, validation.error_message)
|
||||
if validation.status == HookValidateStatus.timeout:
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.GATEWAY_TIMEOUT,
|
||||
f"Endpoint validation failed: {validation.error_message}",
|
||||
)
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.BAD_GATEWAY,
|
||||
f"Endpoint validation failed: {validation.error_message}",
|
||||
)
|
||||
|
||||
|
||||
def _validate_endpoint(
|
||||
endpoint_url: str,
|
||||
api_key: str | None,
|
||||
timeout_seconds: float,
|
||||
) -> HookValidateResponse:
|
||||
"""Check whether endpoint_url is reachable by sending an empty POST request.
|
||||
|
||||
We use POST since hook endpoints expect POST requests. The server will typically
|
||||
respond with 4xx (missing/invalid body) — that is fine. Any HTTP response means
|
||||
the server is up and routable. A 401/403 response returns auth_failed
|
||||
(not reachable — indicates the api_key is invalid).
|
||||
|
||||
Timeout handling:
|
||||
- ConnectTimeout: TCP handshake never completed → cannot_connect.
|
||||
- ReadTimeout / WriteTimeout: TCP was established, server responded slowly → timeout
|
||||
(operator should consider increasing timeout_seconds).
|
||||
- All other exceptions → cannot_connect.
|
||||
"""
|
||||
_check_ssrf_safety(endpoint_url)
|
||||
headers: dict[str, str] = {}
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
try:
|
||||
with httpx.Client(timeout=timeout_seconds) as client:
|
||||
response = client.post(endpoint_url, headers=headers)
|
||||
if response.status_code in (401, 403):
|
||||
return HookValidateResponse(
|
||||
status=HookValidateStatus.auth_failed,
|
||||
error_message=f"Authentication failed (HTTP {response.status_code})",
|
||||
)
|
||||
return HookValidateResponse(status=HookValidateStatus.passed)
|
||||
except httpx.TimeoutException as exc:
|
||||
# ConnectTimeout: TCP handshake never completed → cannot_connect.
|
||||
# ReadTimeout / WriteTimeout: TCP was established, server just responded slowly → timeout.
|
||||
if isinstance(exc, httpx.ConnectTimeout):
|
||||
logger.warning(
|
||||
"Hook endpoint validation: connect timeout for %s",
|
||||
endpoint_url,
|
||||
exc_info=exc,
|
||||
)
|
||||
return HookValidateResponse(
|
||||
status=HookValidateStatus.cannot_connect, error_message=str(exc)
|
||||
)
|
||||
logger.warning(
|
||||
"Hook endpoint validation: read/write timeout for %s",
|
||||
endpoint_url,
|
||||
exc_info=exc,
|
||||
)
|
||||
return HookValidateResponse(
|
||||
status=HookValidateStatus.timeout,
|
||||
error_message="Endpoint timed out — consider increasing timeout_seconds.",
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Hook endpoint validation: connection error for %s",
|
||||
endpoint_url,
|
||||
exc_info=exc,
|
||||
)
|
||||
return HookValidateResponse(
|
||||
status=HookValidateStatus.cannot_connect, error_message=str(exc)
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
router = APIRouter(prefix="/admin/hooks")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hook endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/specs")
|
||||
def get_hook_point_specs(
|
||||
_: User = Depends(current_admin_user),
|
||||
_hook_enabled: None = Depends(require_hook_enabled),
|
||||
) -> list[HookPointMetaResponse]:
|
||||
return [
|
||||
HookPointMetaResponse(
|
||||
hook_point=spec.hook_point,
|
||||
display_name=spec.display_name,
|
||||
description=spec.description,
|
||||
docs_url=spec.docs_url,
|
||||
input_schema=spec.input_schema,
|
||||
output_schema=spec.output_schema,
|
||||
default_timeout_seconds=spec.default_timeout_seconds,
|
||||
default_fail_strategy=spec.default_fail_strategy,
|
||||
fail_hard_description=spec.fail_hard_description,
|
||||
)
|
||||
for spec in get_all_specs()
|
||||
]
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_hooks(
|
||||
_: User = Depends(current_admin_user),
|
||||
_hook_enabled: None = Depends(require_hook_enabled),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> list[HookResponse]:
|
||||
hooks = get_hooks(db_session=db_session, include_creator=True)
|
||||
return [_hook_to_response(h) for h in hooks]
|
||||
|
||||
|
||||
@router.post("")
|
||||
def create_hook(
|
||||
req: HookCreateRequest,
|
||||
user: User = Depends(current_admin_user),
|
||||
_hook_enabled: None = Depends(require_hook_enabled),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> HookResponse:
|
||||
"""Create a new hook. The endpoint is validated before persisting — creation fails if
|
||||
the endpoint cannot be reached or the api_key is invalid. Hooks are created inactive;
|
||||
use POST /{hook_id}/activate once ready to receive traffic."""
|
||||
spec = get_hook_point_spec(req.hook_point)
|
||||
api_key = req.api_key.get_secret_value() if req.api_key else None
|
||||
validation = _validate_endpoint(
|
||||
endpoint_url=req.endpoint_url,
|
||||
api_key=api_key,
|
||||
timeout_seconds=req.timeout_seconds or spec.default_timeout_seconds,
|
||||
)
|
||||
if validation.status != HookValidateStatus.passed:
|
||||
_raise_for_validation_failure(validation)
|
||||
|
||||
hook = create_hook__no_commit(
|
||||
db_session=db_session,
|
||||
name=req.name,
|
||||
hook_point=req.hook_point,
|
||||
endpoint_url=req.endpoint_url,
|
||||
api_key=api_key,
|
||||
fail_strategy=req.fail_strategy or spec.default_fail_strategy,
|
||||
timeout_seconds=req.timeout_seconds or spec.default_timeout_seconds,
|
||||
creator_id=user.id,
|
||||
)
|
||||
hook.is_reachable = True
|
||||
db_session.commit()
|
||||
return _hook_to_response(hook, creator_email=user.email)
|
||||
|
||||
|
||||
@router.get("/{hook_id}")
|
||||
def get_hook(
|
||||
hook_id: int,
|
||||
_: User = Depends(current_admin_user),
|
||||
_hook_enabled: None = Depends(require_hook_enabled),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> HookResponse:
|
||||
hook = _get_hook_or_404(db_session, hook_id, include_creator=True)
|
||||
return _hook_to_response(hook)
|
||||
|
||||
|
||||
@router.patch("/{hook_id}")
|
||||
def update_hook(
|
||||
hook_id: int,
|
||||
req: HookUpdateRequest,
|
||||
_: User = Depends(current_admin_user),
|
||||
_hook_enabled: None = Depends(require_hook_enabled),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> HookResponse:
|
||||
"""Update hook fields. If endpoint_url, api_key, or timeout_seconds changes, the
|
||||
endpoint is re-validated using the effective values. For active hooks the update is
|
||||
rejected on validation failure, keeping live traffic unaffected. For inactive hooks
|
||||
the update goes through regardless and is_reachable is updated to reflect the result.
|
||||
|
||||
Note: if an active hook's endpoint is currently down, even a timeout_seconds-only
|
||||
increase will be rejected. The recovery flow is: deactivate → update → reactivate.
|
||||
"""
|
||||
# api_key: UNSET = no change, None = clear, value = update
|
||||
api_key: str | None | UnsetType
|
||||
if "api_key" not in req.model_fields_set:
|
||||
api_key = UNSET
|
||||
elif req.api_key is None:
|
||||
api_key = None
|
||||
else:
|
||||
api_key = req.api_key.get_secret_value()
|
||||
|
||||
endpoint_url_changing = "endpoint_url" in req.model_fields_set
|
||||
api_key_changing = not isinstance(api_key, UnsetType)
|
||||
timeout_changing = "timeout_seconds" in req.model_fields_set
|
||||
|
||||
validated_is_reachable: bool | None = None
|
||||
if endpoint_url_changing or api_key_changing or timeout_changing:
|
||||
existing = _get_hook_or_404(db_session, hook_id)
|
||||
effective_url: str = (
|
||||
req.endpoint_url if endpoint_url_changing else existing.endpoint_url # type: ignore[assignment] # endpoint_url is required on create and cannot be cleared on update
|
||||
)
|
||||
effective_api_key: str | None = (
|
||||
(api_key if not isinstance(api_key, UnsetType) else None)
|
||||
if api_key_changing
|
||||
else (
|
||||
existing.api_key.get_value(apply_mask=False)
|
||||
if existing.api_key
|
||||
else None
|
||||
)
|
||||
)
|
||||
effective_timeout: float = (
|
||||
req.timeout_seconds if timeout_changing else existing.timeout_seconds # type: ignore[assignment] # req.timeout_seconds is non-None when timeout_changing (validated by HookUpdateRequest)
|
||||
)
|
||||
validation = _validate_endpoint(
|
||||
endpoint_url=effective_url,
|
||||
api_key=effective_api_key,
|
||||
timeout_seconds=effective_timeout,
|
||||
)
|
||||
if existing.is_active and validation.status != HookValidateStatus.passed:
|
||||
_raise_for_validation_failure(validation)
|
||||
validated_is_reachable = validation.status == HookValidateStatus.passed
|
||||
|
||||
hook = update_hook__no_commit(
|
||||
db_session=db_session,
|
||||
hook_id=hook_id,
|
||||
name=req.name,
|
||||
endpoint_url=(req.endpoint_url if endpoint_url_changing else UNSET),
|
||||
api_key=api_key,
|
||||
fail_strategy=req.fail_strategy,
|
||||
timeout_seconds=req.timeout_seconds,
|
||||
is_reachable=validated_is_reachable,
|
||||
include_creator=True,
|
||||
)
|
||||
db_session.commit()
|
||||
return _hook_to_response(hook)
|
||||
|
||||
|
||||
@router.delete("/{hook_id}")
|
||||
def delete_hook(
|
||||
hook_id: int,
|
||||
_: User = Depends(current_admin_user),
|
||||
_hook_enabled: None = Depends(require_hook_enabled),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> None:
|
||||
delete_hook__no_commit(db_session=db_session, hook_id=hook_id)
|
||||
db_session.commit()
|
||||
|
||||
|
||||
@router.post("/{hook_id}/activate")
|
||||
def activate_hook(
|
||||
hook_id: int,
|
||||
_: User = Depends(current_admin_user),
|
||||
_hook_enabled: None = Depends(require_hook_enabled),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> HookResponse:
|
||||
hook = _get_hook_or_404(db_session, hook_id)
|
||||
if not hook.endpoint_url:
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.INVALID_INPUT, "Hook has no endpoint URL configured."
|
||||
)
|
||||
|
||||
api_key = hook.api_key.get_value(apply_mask=False) if hook.api_key else None
|
||||
validation = _validate_endpoint(
|
||||
endpoint_url=hook.endpoint_url,
|
||||
api_key=api_key,
|
||||
timeout_seconds=hook.timeout_seconds,
|
||||
)
|
||||
if validation.status != HookValidateStatus.passed:
|
||||
# Persist is_reachable=False in a separate session so the request
|
||||
# session has no commits on the failure path and the transaction
|
||||
# boundary stays clean.
|
||||
if hook.is_reachable is not False:
|
||||
with get_session_with_current_tenant() as side_session:
|
||||
update_hook__no_commit(
|
||||
db_session=side_session, hook_id=hook_id, is_reachable=False
|
||||
)
|
||||
side_session.commit()
|
||||
_raise_for_validation_failure(validation)
|
||||
|
||||
hook = update_hook__no_commit(
|
||||
db_session=db_session,
|
||||
hook_id=hook_id,
|
||||
is_active=True,
|
||||
is_reachable=True,
|
||||
include_creator=True,
|
||||
)
|
||||
db_session.commit()
|
||||
return _hook_to_response(hook)
|
||||
|
||||
|
||||
@router.post("/{hook_id}/validate")
|
||||
def validate_hook(
|
||||
hook_id: int,
|
||||
_: User = Depends(current_admin_user),
|
||||
_hook_enabled: None = Depends(require_hook_enabled),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> HookValidateResponse:
|
||||
hook = _get_hook_or_404(db_session, hook_id)
|
||||
if not hook.endpoint_url:
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.INVALID_INPUT, "Hook has no endpoint URL configured."
|
||||
)
|
||||
|
||||
api_key = hook.api_key.get_value(apply_mask=False) if hook.api_key else None
|
||||
validation = _validate_endpoint(
|
||||
endpoint_url=hook.endpoint_url,
|
||||
api_key=api_key,
|
||||
timeout_seconds=hook.timeout_seconds,
|
||||
)
|
||||
validation_passed = validation.status == HookValidateStatus.passed
|
||||
if hook.is_reachable != validation_passed:
|
||||
update_hook__no_commit(
|
||||
db_session=db_session, hook_id=hook_id, is_reachable=validation_passed
|
||||
)
|
||||
db_session.commit()
|
||||
return validation
|
||||
|
||||
|
||||
@router.post("/{hook_id}/deactivate")
|
||||
def deactivate_hook(
|
||||
hook_id: int,
|
||||
_: User = Depends(current_admin_user),
|
||||
_hook_enabled: None = Depends(require_hook_enabled),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> HookResponse:
|
||||
hook = update_hook__no_commit(
|
||||
db_session=db_session,
|
||||
hook_id=hook_id,
|
||||
is_active=False,
|
||||
include_creator=True,
|
||||
)
|
||||
db_session.commit()
|
||||
return _hook_to_response(hook)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Execution log endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/{hook_id}/execution-logs")
|
||||
def list_hook_execution_logs(
|
||||
hook_id: int,
|
||||
limit: int = Query(default=50, ge=1, le=100),
|
||||
_: User = Depends(current_admin_user),
|
||||
_hook_enabled: None = Depends(require_hook_enabled),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> list[HookFailureRecord]:
|
||||
_get_hook_or_404(db_session, hook_id)
|
||||
logs = get_hook_execution_logs(db_session=db_session, hook_id=hook_id, limit=limit)
|
||||
return [
|
||||
HookFailureRecord(
|
||||
error_message=log.error_message,
|
||||
status_code=log.status_code,
|
||||
duration_ms=log.duration_ms,
|
||||
created_at=log.created_at,
|
||||
)
|
||||
for log in logs
|
||||
]
|
||||
@@ -1,4 +1,3 @@
|
||||
import hashlib
|
||||
import mimetypes
|
||||
from io import BytesIO
|
||||
from typing import Any
|
||||
@@ -84,14 +83,6 @@ class PythonTool(Tool[PythonToolOverrideKwargs]):
|
||||
def __init__(self, tool_id: int, emitter: Emitter) -> None:
|
||||
super().__init__(emitter=emitter)
|
||||
self._id = tool_id
|
||||
# Cache of (filename, content_hash) -> ci_file_id to avoid re-uploading
|
||||
# the same file on every tool call iteration within the same agent session.
|
||||
# Filename is included in the key so two files with identical bytes but
|
||||
# different names each get their own upload slot.
|
||||
# TTL assumption: code-interpreter file TTLs (typically hours) greatly
|
||||
# exceed the lifetime of a single agent session (at most MAX_LLM_CYCLES
|
||||
# iterations, typically a few minutes), so stale-ID eviction is not needed.
|
||||
self._uploaded_file_cache: dict[tuple[str, str], str] = {}
|
||||
|
||||
@property
|
||||
def id(self) -> int:
|
||||
@@ -191,13 +182,8 @@ class PythonTool(Tool[PythonToolOverrideKwargs]):
|
||||
for ind, chat_file in enumerate(chat_files):
|
||||
file_name = chat_file.filename or f"file_{ind}"
|
||||
try:
|
||||
content_hash = hashlib.sha256(chat_file.content).hexdigest()
|
||||
cache_key = (file_name, content_hash)
|
||||
ci_file_id = self._uploaded_file_cache.get(cache_key)
|
||||
if ci_file_id is None:
|
||||
# Upload to Code Interpreter
|
||||
ci_file_id = client.upload_file(chat_file.content, file_name)
|
||||
self._uploaded_file_cache[cache_key] = ci_file_id
|
||||
# Upload to Code Interpreter
|
||||
ci_file_id = client.upload_file(chat_file.content, file_name)
|
||||
|
||||
# Stage for execution
|
||||
files_to_stage.append({"path": file_name, "file_id": ci_file_id})
|
||||
@@ -313,10 +299,14 @@ class PythonTool(Tool[PythonToolOverrideKwargs]):
|
||||
f"Failed to delete Code Interpreter generated file {ci_file_id}: {e}"
|
||||
)
|
||||
|
||||
# Note: staged input files are intentionally not deleted here because
|
||||
# _uploaded_file_cache reuses their file_ids across iterations. They are
|
||||
# orphaned when the session ends, but the code interpreter cleans up
|
||||
# stale files on its own TTL.
|
||||
# Cleanup staged input files
|
||||
for file_mapping in files_to_stage:
|
||||
try:
|
||||
client.delete_file(file_mapping["file_id"])
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to delete Code Interpreter staged file {file_mapping['file_id']}: {e}"
|
||||
)
|
||||
|
||||
# Emit file_ids once files are processed
|
||||
if generated_file_ids:
|
||||
|
||||
@@ -1219,16 +1219,15 @@ def test_code_interpreter_receives_chat_files(
|
||||
finally:
|
||||
ci_mod.CodeInterpreterClient.__init__.__defaults__ = original_defaults
|
||||
|
||||
# Verify: file uploaded and code executed via streaming.
|
||||
# Verify: file uploaded, code executed via streaming, staged file cleaned up
|
||||
assert len(mock_ci_server.get_requests(method="POST", path="/v1/files")) == 1
|
||||
assert (
|
||||
len(mock_ci_server.get_requests(method="POST", path="/v1/execute/stream")) == 1
|
||||
)
|
||||
|
||||
# Staged input files are intentionally NOT deleted — PythonTool caches their
|
||||
# file IDs across agent-loop iterations to avoid re-uploading on every call.
|
||||
# The code interpreter cleans them up via its own TTL.
|
||||
assert len(mock_ci_server.get_requests(method="DELETE")) == 0
|
||||
delete_requests = mock_ci_server.get_requests(method="DELETE")
|
||||
assert len(delete_requests) == 1
|
||||
assert delete_requests[0].path.startswith("/v1/files/")
|
||||
|
||||
execute_body = mock_ci_server.get_requests(
|
||||
method="POST", path="/v1/execute/stream"
|
||||
|
||||
@@ -256,6 +256,7 @@ def test_multiple_tool_calls(default_multi_llm: LitellmLLM) -> None:
|
||||
{"role": "user", "content": "What's the weather and time in New York?"}
|
||||
],
|
||||
tools=tools,
|
||||
tool_choice=None,
|
||||
stream=True,
|
||||
temperature=0.0, # Default value from GEN_AI_TEMPERATURE
|
||||
timeout=30,
|
||||
@@ -411,6 +412,7 @@ def test_multiple_tool_calls_streaming(default_multi_llm: LitellmLLM) -> None:
|
||||
{"role": "user", "content": "What's the weather and time in New York?"}
|
||||
],
|
||||
tools=tools,
|
||||
tool_choice=None,
|
||||
stream=True,
|
||||
temperature=0.0, # Default value from GEN_AI_TEMPERATURE
|
||||
timeout=30,
|
||||
@@ -1429,36 +1431,3 @@ def test_strip_tool_content_merges_consecutive_tool_results() -> None:
|
||||
assert "sunny 72F" in merged
|
||||
assert "tc_2" in merged
|
||||
assert "headline news" in merged
|
||||
|
||||
|
||||
def test_no_tool_choice_sent_when_no_tools(default_multi_llm: LitellmLLM) -> None:
|
||||
"""Regression test for providers (e.g. Fireworks) that reject tool_choice=null.
|
||||
|
||||
When no tools are provided, tool_choice must not be forwarded to
|
||||
litellm.completion() at all — not even as None.
|
||||
"""
|
||||
messages: LanguageModelInput = [UserMessage(content="Hello!")]
|
||||
|
||||
mock_stream_chunks = [
|
||||
litellm.ModelResponse(
|
||||
id="chatcmpl-123",
|
||||
choices=[
|
||||
litellm.Choices(
|
||||
delta=_create_delta(role="assistant", content="Hello!"),
|
||||
finish_reason="stop",
|
||||
index=0,
|
||||
)
|
||||
],
|
||||
model="gpt-3.5-turbo",
|
||||
),
|
||||
]
|
||||
|
||||
with patch("litellm.completion") as mock_completion:
|
||||
mock_completion.return_value = mock_stream_chunks
|
||||
|
||||
default_multi_llm.invoke(messages, tools=None)
|
||||
|
||||
_, kwargs = mock_completion.call_args
|
||||
assert (
|
||||
"tool_choice" not in kwargs
|
||||
), "tool_choice must not be sent to providers when no tools are provided"
|
||||
|
||||
278
backend/tests/unit/onyx/server/features/hooks/test_api.py
Normal file
278
backend/tests/unit/onyx/server/features/hooks/test_api.py
Normal file
@@ -0,0 +1,278 @@
|
||||
"""Unit tests for onyx.server.features.hooks.api helpers.
|
||||
|
||||
Covers:
|
||||
- _check_ssrf_safety: scheme enforcement and private-IP blocklist
|
||||
- _validate_endpoint: httpx exception → HookValidateStatus mapping
|
||||
ConnectTimeout → cannot_connect (TCP handshake never completed)
|
||||
ConnectError → cannot_connect (DNS / TLS failure)
|
||||
ReadTimeout et al. → timeout (TCP connected, server slow)
|
||||
Any other exc → cannot_connect
|
||||
- _raise_for_validation_failure: HookValidateStatus → OnyxError mapping
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from onyx.error_handling.error_codes import OnyxErrorCode
|
||||
from onyx.error_handling.exceptions import OnyxError
|
||||
from onyx.hooks.models import HookValidateResponse
|
||||
from onyx.hooks.models import HookValidateStatus
|
||||
from onyx.server.features.hooks.api import _check_ssrf_safety
|
||||
from onyx.server.features.hooks.api import _raise_for_validation_failure
|
||||
from onyx.server.features.hooks.api import _validate_endpoint
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_URL = "https://example.com/hook"
|
||||
_API_KEY = "secret"
|
||||
_TIMEOUT = 5.0
|
||||
|
||||
|
||||
def _mock_response(status_code: int) -> MagicMock:
|
||||
response = MagicMock()
|
||||
response.status_code = status_code
|
||||
return response
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _check_ssrf_safety
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCheckSsrfSafety:
|
||||
def _call(self, url: str) -> None:
|
||||
_check_ssrf_safety(url)
|
||||
|
||||
# --- scheme checks ---
|
||||
|
||||
def test_https_is_allowed(self) -> None:
|
||||
with patch("onyx.server.features.hooks.api.socket.getaddrinfo") as mock_dns:
|
||||
mock_dns.return_value = [(None, None, None, None, ("93.184.216.34", 0))]
|
||||
self._call("https://example.com/hook") # must not raise
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"url", ["http://example.com/hook", "ftp://example.com/hook"]
|
||||
)
|
||||
def test_non_https_scheme_rejected(self, url: str) -> None:
|
||||
with pytest.raises(OnyxError) as exc_info:
|
||||
self._call(url)
|
||||
assert exc_info.value.error_code == OnyxErrorCode.INVALID_INPUT
|
||||
assert "https" in (exc_info.value.detail or "").lower()
|
||||
|
||||
# --- private IP blocklist ---
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"ip",
|
||||
[
|
||||
pytest.param("127.0.0.1", id="loopback"),
|
||||
pytest.param("10.0.0.1", id="RFC1918-A"),
|
||||
pytest.param("172.16.0.1", id="RFC1918-B"),
|
||||
pytest.param("192.168.1.1", id="RFC1918-C"),
|
||||
pytest.param("169.254.169.254", id="link-local-IMDS"),
|
||||
pytest.param("100.64.0.1", id="shared-address-space"),
|
||||
pytest.param("::1", id="IPv6-loopback"),
|
||||
pytest.param("fc00::1", id="IPv6-ULA"),
|
||||
pytest.param("fe80::1", id="IPv6-link-local"),
|
||||
],
|
||||
)
|
||||
def test_private_ip_is_blocked(self, ip: str) -> None:
|
||||
with (
|
||||
patch("onyx.server.features.hooks.api.socket.getaddrinfo") as mock_dns,
|
||||
pytest.raises(OnyxError) as exc_info,
|
||||
):
|
||||
mock_dns.return_value = [(None, None, None, None, (ip, 0))]
|
||||
self._call("https://internal.example.com/hook")
|
||||
assert exc_info.value.error_code == OnyxErrorCode.INVALID_INPUT
|
||||
assert ip in (exc_info.value.detail or "")
|
||||
|
||||
def test_public_ip_is_allowed(self) -> None:
|
||||
with patch("onyx.server.features.hooks.api.socket.getaddrinfo") as mock_dns:
|
||||
mock_dns.return_value = [(None, None, None, None, ("93.184.216.34", 0))]
|
||||
self._call("https://example.com/hook") # must not raise
|
||||
|
||||
def test_dns_resolution_failure_raises(self) -> None:
|
||||
import socket
|
||||
|
||||
with (
|
||||
patch(
|
||||
"onyx.server.features.hooks.api.socket.getaddrinfo",
|
||||
side_effect=socket.gaierror("name not found"),
|
||||
),
|
||||
pytest.raises(OnyxError) as exc_info,
|
||||
):
|
||||
self._call("https://no-such-host.example.com/hook")
|
||||
assert exc_info.value.error_code == OnyxErrorCode.INVALID_INPUT
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _validate_endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestValidateEndpoint:
|
||||
def _call(self, *, api_key: str | None = _API_KEY) -> HookValidateResponse:
|
||||
# Bypass SSRF check — tested separately in TestCheckSsrfSafety.
|
||||
with patch("onyx.server.features.hooks.api._check_ssrf_safety"):
|
||||
return _validate_endpoint(
|
||||
endpoint_url=_URL,
|
||||
api_key=api_key,
|
||||
timeout_seconds=_TIMEOUT,
|
||||
)
|
||||
|
||||
@patch("onyx.server.features.hooks.api.httpx.Client")
|
||||
def test_2xx_returns_passed(self, mock_client_cls: MagicMock) -> None:
|
||||
mock_client_cls.return_value.__enter__.return_value.post.return_value = (
|
||||
_mock_response(200)
|
||||
)
|
||||
assert self._call().status == HookValidateStatus.passed
|
||||
|
||||
@patch("onyx.server.features.hooks.api.httpx.Client")
|
||||
def test_5xx_returns_passed(self, mock_client_cls: MagicMock) -> None:
|
||||
mock_client_cls.return_value.__enter__.return_value.post.return_value = (
|
||||
_mock_response(500)
|
||||
)
|
||||
assert self._call().status == HookValidateStatus.passed
|
||||
|
||||
@patch("onyx.server.features.hooks.api.httpx.Client")
|
||||
@pytest.mark.parametrize("status_code", [401, 403])
|
||||
def test_401_403_returns_auth_failed(
|
||||
self, mock_client_cls: MagicMock, status_code: int
|
||||
) -> None:
|
||||
mock_client_cls.return_value.__enter__.return_value.post.return_value = (
|
||||
_mock_response(status_code)
|
||||
)
|
||||
result = self._call()
|
||||
assert result.status == HookValidateStatus.auth_failed
|
||||
assert str(status_code) in (result.error_message or "")
|
||||
|
||||
@patch("onyx.server.features.hooks.api.httpx.Client")
|
||||
def test_4xx_non_auth_returns_passed(self, mock_client_cls: MagicMock) -> None:
|
||||
mock_client_cls.return_value.__enter__.return_value.post.return_value = (
|
||||
_mock_response(422)
|
||||
)
|
||||
assert self._call().status == HookValidateStatus.passed
|
||||
|
||||
@patch("onyx.server.features.hooks.api.httpx.Client")
|
||||
def test_connect_timeout_returns_cannot_connect(
|
||||
self, mock_client_cls: MagicMock
|
||||
) -> None:
|
||||
mock_client_cls.return_value.__enter__.return_value.post.side_effect = (
|
||||
httpx.ConnectTimeout("timed out")
|
||||
)
|
||||
assert self._call().status == HookValidateStatus.cannot_connect
|
||||
|
||||
@patch("onyx.server.features.hooks.api.httpx.Client")
|
||||
@pytest.mark.parametrize(
|
||||
"exc",
|
||||
[
|
||||
httpx.ReadTimeout("read timeout"),
|
||||
httpx.WriteTimeout("write timeout"),
|
||||
httpx.PoolTimeout("pool timeout"),
|
||||
],
|
||||
)
|
||||
def test_read_write_pool_timeout_returns_timeout(
|
||||
self, mock_client_cls: MagicMock, exc: httpx.TimeoutException
|
||||
) -> None:
|
||||
mock_client_cls.return_value.__enter__.return_value.post.side_effect = exc
|
||||
assert self._call().status == HookValidateStatus.timeout
|
||||
|
||||
@patch("onyx.server.features.hooks.api.httpx.Client")
|
||||
def test_connect_error_returns_cannot_connect(
|
||||
self, mock_client_cls: MagicMock
|
||||
) -> None:
|
||||
# Covers DNS failures, TLS errors, and other connection-level errors.
|
||||
mock_client_cls.return_value.__enter__.return_value.post.side_effect = (
|
||||
httpx.ConnectError("name resolution failed")
|
||||
)
|
||||
assert self._call().status == HookValidateStatus.cannot_connect
|
||||
|
||||
@patch("onyx.server.features.hooks.api.httpx.Client")
|
||||
def test_arbitrary_exception_returns_cannot_connect(
|
||||
self, mock_client_cls: MagicMock
|
||||
) -> None:
|
||||
mock_client_cls.return_value.__enter__.return_value.post.side_effect = (
|
||||
ConnectionRefusedError("refused")
|
||||
)
|
||||
assert self._call().status == HookValidateStatus.cannot_connect
|
||||
|
||||
@patch("onyx.server.features.hooks.api.httpx.Client")
|
||||
def test_api_key_sent_as_bearer(self, mock_client_cls: MagicMock) -> None:
|
||||
mock_post = mock_client_cls.return_value.__enter__.return_value.post
|
||||
mock_post.return_value = _mock_response(200)
|
||||
self._call(api_key="mykey")
|
||||
_, kwargs = mock_post.call_args
|
||||
assert kwargs["headers"]["Authorization"] == "Bearer mykey"
|
||||
|
||||
@patch("onyx.server.features.hooks.api.httpx.Client")
|
||||
def test_no_api_key_omits_auth_header(self, mock_client_cls: MagicMock) -> None:
|
||||
mock_post = mock_client_cls.return_value.__enter__.return_value.post
|
||||
mock_post.return_value = _mock_response(200)
|
||||
self._call(api_key=None)
|
||||
_, kwargs = mock_post.call_args
|
||||
assert "Authorization" not in kwargs["headers"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _raise_for_validation_failure
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRaiseForValidationFailure:
|
||||
@pytest.mark.parametrize(
|
||||
"status, expected_code",
|
||||
[
|
||||
(HookValidateStatus.auth_failed, OnyxErrorCode.CREDENTIAL_INVALID),
|
||||
(HookValidateStatus.timeout, OnyxErrorCode.GATEWAY_TIMEOUT),
|
||||
(HookValidateStatus.cannot_connect, OnyxErrorCode.BAD_GATEWAY),
|
||||
],
|
||||
)
|
||||
def test_raises_correct_error_code(
|
||||
self, status: HookValidateStatus, expected_code: OnyxErrorCode
|
||||
) -> None:
|
||||
validation = HookValidateResponse(status=status, error_message="some error")
|
||||
with pytest.raises(OnyxError) as exc_info:
|
||||
_raise_for_validation_failure(validation)
|
||||
assert exc_info.value.error_code == expected_code
|
||||
|
||||
def test_auth_failed_passes_error_message_directly(self) -> None:
|
||||
validation = HookValidateResponse(
|
||||
status=HookValidateStatus.auth_failed, error_message="bad credentials"
|
||||
)
|
||||
with pytest.raises(OnyxError) as exc_info:
|
||||
_raise_for_validation_failure(validation)
|
||||
assert exc_info.value.detail == "bad credentials"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"status", [HookValidateStatus.timeout, HookValidateStatus.cannot_connect]
|
||||
)
|
||||
def test_timeout_and_cannot_connect_wrap_error_message(
|
||||
self, status: HookValidateStatus
|
||||
) -> None:
|
||||
validation = HookValidateResponse(status=status, error_message="raw error")
|
||||
with pytest.raises(OnyxError) as exc_info:
|
||||
_raise_for_validation_failure(validation)
|
||||
assert exc_info.value.detail == "Endpoint validation failed: raw error"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HookValidateStatus enum string values (API contract)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestHookValidateStatusValues:
|
||||
@pytest.mark.parametrize(
|
||||
"status, expected",
|
||||
[
|
||||
(HookValidateStatus.passed, "passed"),
|
||||
(HookValidateStatus.auth_failed, "auth_failed"),
|
||||
(HookValidateStatus.timeout, "timeout"),
|
||||
(HookValidateStatus.cannot_connect, "cannot_connect"),
|
||||
],
|
||||
)
|
||||
def test_string_values(self, status: HookValidateStatus, expected: str) -> None:
|
||||
assert status == expected
|
||||
@@ -1,208 +0,0 @@
|
||||
"""Unit tests for PythonTool file-upload caching.
|
||||
|
||||
Verifies that PythonTool reuses code-interpreter file IDs across multiple
|
||||
run() calls within the same session instead of re-uploading identical content
|
||||
on every agent loop iteration.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import patch
|
||||
|
||||
from onyx.tools.models import ChatFile
|
||||
from onyx.tools.models import PythonToolOverrideKwargs
|
||||
from onyx.tools.tool_implementations.python.code_interpreter_client import (
|
||||
StreamResultEvent,
|
||||
)
|
||||
from onyx.tools.tool_implementations.python.python_tool import PythonTool
|
||||
|
||||
TOOL_MODULE = "onyx.tools.tool_implementations.python.python_tool"
|
||||
|
||||
|
||||
def _make_stream_result() -> StreamResultEvent:
|
||||
return StreamResultEvent(
|
||||
exit_code=0,
|
||||
timed_out=False,
|
||||
duration_ms=10,
|
||||
files=[],
|
||||
)
|
||||
|
||||
|
||||
def _make_tool() -> PythonTool:
|
||||
emitter = MagicMock()
|
||||
return PythonTool(tool_id=1, emitter=emitter)
|
||||
|
||||
|
||||
def _make_override(files: list[ChatFile]) -> PythonToolOverrideKwargs:
|
||||
return PythonToolOverrideKwargs(chat_files=files)
|
||||
|
||||
|
||||
def _run_tool(tool: PythonTool, mock_client: MagicMock, files: list[ChatFile]) -> None:
|
||||
"""Call tool.run() with a mocked CodeInterpreterClient context manager."""
|
||||
from onyx.server.query_and_chat.placement import Placement
|
||||
|
||||
mock_client.execute_streaming.return_value = iter([_make_stream_result()])
|
||||
|
||||
ctx = MagicMock()
|
||||
ctx.__enter__ = MagicMock(return_value=mock_client)
|
||||
ctx.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
placement = Placement(turn_index=0, tab_index=0)
|
||||
override = _make_override(files)
|
||||
|
||||
with patch(f"{TOOL_MODULE}.CodeInterpreterClient", return_value=ctx):
|
||||
tool.run(placement=placement, override_kwargs=override, code="print('hi')")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cache hit: same content uploaded in a second call reuses the file_id
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@patch(f"{TOOL_MODULE}.CODE_INTERPRETER_BASE_URL", "http://fake:8000")
|
||||
def test_same_file_uploaded_only_once_across_two_runs() -> None:
|
||||
tool = _make_tool()
|
||||
client = MagicMock()
|
||||
client.upload_file.return_value = "file-id-abc"
|
||||
|
||||
pptx_content = b"fake pptx bytes"
|
||||
files = [ChatFile(filename="report.pptx", content=pptx_content)]
|
||||
|
||||
_run_tool(tool, client, files)
|
||||
_run_tool(tool, client, files)
|
||||
|
||||
# upload_file should only have been called once across both runs
|
||||
client.upload_file.assert_called_once_with(pptx_content, "report.pptx")
|
||||
|
||||
|
||||
@patch(f"{TOOL_MODULE}.CODE_INTERPRETER_BASE_URL", "http://fake:8000")
|
||||
def test_cached_file_id_is_staged_on_second_run() -> None:
|
||||
tool = _make_tool()
|
||||
client = MagicMock()
|
||||
client.upload_file.return_value = "file-id-abc"
|
||||
|
||||
files = [ChatFile(filename="data.pptx", content=b"content")]
|
||||
|
||||
_run_tool(tool, client, files)
|
||||
|
||||
# On the second run, execute_streaming should still receive the file
|
||||
client.execute_streaming.return_value = iter([_make_stream_result()])
|
||||
ctx = MagicMock()
|
||||
ctx.__enter__ = MagicMock(return_value=client)
|
||||
ctx.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
from onyx.server.query_and_chat.placement import Placement
|
||||
|
||||
placement = Placement(turn_index=1, tab_index=0)
|
||||
with patch(f"{TOOL_MODULE}.CodeInterpreterClient", return_value=ctx):
|
||||
tool.run(
|
||||
placement=placement,
|
||||
override_kwargs=_make_override(files),
|
||||
code="print('hi')",
|
||||
)
|
||||
|
||||
# The second execute_streaming call should include the file
|
||||
_, kwargs = client.execute_streaming.call_args
|
||||
staged_files = kwargs.get("files") or []
|
||||
assert any(f["file_id"] == "file-id-abc" for f in staged_files)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cache miss: different content triggers a new upload
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@patch(f"{TOOL_MODULE}.CODE_INTERPRETER_BASE_URL", "http://fake:8000")
|
||||
def test_different_file_content_uploaded_separately() -> None:
|
||||
tool = _make_tool()
|
||||
client = MagicMock()
|
||||
client.upload_file.side_effect = ["file-id-v1", "file-id-v2"]
|
||||
|
||||
file_v1 = ChatFile(filename="report.pptx", content=b"version 1")
|
||||
file_v2 = ChatFile(filename="report.pptx", content=b"version 2")
|
||||
|
||||
_run_tool(tool, client, [file_v1])
|
||||
_run_tool(tool, client, [file_v2])
|
||||
|
||||
assert client.upload_file.call_count == 2
|
||||
|
||||
|
||||
@patch(f"{TOOL_MODULE}.CODE_INTERPRETER_BASE_URL", "http://fake:8000")
|
||||
def test_multiple_distinct_files_each_uploaded_once() -> None:
|
||||
tool = _make_tool()
|
||||
client = MagicMock()
|
||||
client.upload_file.side_effect = ["id-a", "id-b"]
|
||||
|
||||
files = [
|
||||
ChatFile(filename="a.pptx", content=b"aaa"),
|
||||
ChatFile(filename="b.xlsx", content=b"bbb"),
|
||||
]
|
||||
|
||||
_run_tool(tool, client, files)
|
||||
_run_tool(tool, client, files)
|
||||
|
||||
# Two distinct files — each uploaded exactly once
|
||||
assert client.upload_file.call_count == 2
|
||||
|
||||
|
||||
@patch(f"{TOOL_MODULE}.CODE_INTERPRETER_BASE_URL", "http://fake:8000")
|
||||
def test_same_content_different_filename_uploaded_separately() -> None:
|
||||
# Identical bytes but different names must each get their own upload slot
|
||||
# so both files appear under their respective paths in the workspace.
|
||||
tool = _make_tool()
|
||||
client = MagicMock()
|
||||
client.upload_file.side_effect = ["id-v1", "id-v2"]
|
||||
|
||||
same_bytes = b"shared content"
|
||||
files = [
|
||||
ChatFile(filename="report_v1.csv", content=same_bytes),
|
||||
ChatFile(filename="report_v2.csv", content=same_bytes),
|
||||
]
|
||||
|
||||
_run_tool(tool, client, files)
|
||||
|
||||
assert client.upload_file.call_count == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# No cross-instance sharing: a fresh PythonTool re-uploads everything
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@patch(f"{TOOL_MODULE}.CODE_INTERPRETER_BASE_URL", "http://fake:8000")
|
||||
def test_new_tool_instance_re_uploads_file() -> None:
|
||||
client = MagicMock()
|
||||
client.upload_file.side_effect = ["id-session-1", "id-session-2"]
|
||||
|
||||
files = [ChatFile(filename="deck.pptx", content=b"slide data")]
|
||||
|
||||
tool_session_1 = _make_tool()
|
||||
_run_tool(tool_session_1, client, files)
|
||||
|
||||
tool_session_2 = _make_tool()
|
||||
_run_tool(tool_session_2, client, files)
|
||||
|
||||
# Different instances — each uploads independently
|
||||
assert client.upload_file.call_count == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Upload failure: failed upload is not cached, retried next run
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@patch(f"{TOOL_MODULE}.CODE_INTERPRETER_BASE_URL", "http://fake:8000")
|
||||
def test_upload_failure_not_cached() -> None:
|
||||
tool = _make_tool()
|
||||
client = MagicMock()
|
||||
# First call raises, second succeeds
|
||||
client.upload_file.side_effect = [Exception("network error"), "file-id-ok"]
|
||||
|
||||
files = [ChatFile(filename="slides.pptx", content=b"data")]
|
||||
|
||||
# First run — upload fails, file is skipped but not cached
|
||||
_run_tool(tool, client, files)
|
||||
|
||||
# Second run — should attempt upload again
|
||||
_run_tool(tool, client, files)
|
||||
|
||||
assert client.upload_file.call_count == 2
|
||||
File diff suppressed because it is too large
Load Diff
@@ -207,16 +207,6 @@ prompt_yn_or_default() {
|
||||
fi
|
||||
}
|
||||
|
||||
confirm_action() {
|
||||
local description="$1"
|
||||
prompt_yn_or_default "Install ${description}? (Y/n) [default: Y] " "Y"
|
||||
if [[ "$REPLY" =~ ^[Nn] ]]; then
|
||||
print_warning "Skipping: ${description}"
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
@@ -405,11 +395,6 @@ fi
|
||||
|
||||
if ! command -v docker &> /dev/null; then
|
||||
if [[ "$OSTYPE" == "linux-gnu"* ]] || [[ -n "${WSL_DISTRO_NAME:-}" ]]; then
|
||||
print_info "Docker is required but not installed."
|
||||
if ! confirm_action "Docker Engine"; then
|
||||
print_error "Docker is required to run Onyx."
|
||||
exit 1
|
||||
fi
|
||||
install_docker_linux
|
||||
if ! command -v docker &> /dev/null; then
|
||||
print_error "Docker installation failed."
|
||||
@@ -426,11 +411,7 @@ if command -v docker &> /dev/null \
|
||||
&& ! command -v docker-compose &> /dev/null \
|
||||
&& { [[ "$OSTYPE" == "linux-gnu"* ]] || [[ -n "${WSL_DISTRO_NAME:-}" ]]; }; then
|
||||
|
||||
print_info "Docker Compose is required but not installed."
|
||||
if ! confirm_action "Docker Compose plugin"; then
|
||||
print_error "Docker Compose is required to run Onyx."
|
||||
exit 1
|
||||
fi
|
||||
print_info "Docker Compose not found — installing plugin..."
|
||||
COMPOSE_ARCH="$(uname -m)"
|
||||
COMPOSE_URL="https://github.com/docker/compose/releases/latest/download/docker-compose-linux-${COMPOSE_ARCH}"
|
||||
COMPOSE_DIR="/usr/local/lib/docker/cli-plugins"
|
||||
@@ -581,31 +562,10 @@ version_compare() {
|
||||
|
||||
# Check Docker daemon
|
||||
if ! docker info &> /dev/null; then
|
||||
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||
print_info "Docker daemon is not running. Starting Docker Desktop..."
|
||||
open -a Docker
|
||||
# Wait up to 120 seconds for Docker to be ready
|
||||
DOCKER_WAIT=0
|
||||
DOCKER_MAX_WAIT=120
|
||||
while ! docker info &> /dev/null; do
|
||||
if [ $DOCKER_WAIT -ge $DOCKER_MAX_WAIT ]; then
|
||||
print_error "Docker Desktop did not start within ${DOCKER_MAX_WAIT} seconds."
|
||||
print_info "Please start Docker Desktop manually and re-run this script."
|
||||
exit 1
|
||||
fi
|
||||
printf "\r\033[KWaiting for Docker Desktop to start... (%ds)" "$DOCKER_WAIT"
|
||||
sleep 2
|
||||
DOCKER_WAIT=$((DOCKER_WAIT + 2))
|
||||
done
|
||||
echo ""
|
||||
print_success "Docker Desktop is now running"
|
||||
else
|
||||
print_error "Docker daemon is not running. Please start Docker."
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
print_success "Docker daemon is running"
|
||||
print_error "Docker daemon is not running. Please start Docker."
|
||||
exit 1
|
||||
fi
|
||||
print_success "Docker daemon is running"
|
||||
|
||||
# Check Docker resources
|
||||
print_step "Verifying Docker resources"
|
||||
@@ -785,7 +745,6 @@ print_success "All configuration files ready"
|
||||
# Set up deployment configuration
|
||||
print_step "Setting up deployment configs"
|
||||
ENV_FILE="${INSTALL_ROOT}/deployment/.env"
|
||||
ENV_TEMPLATE="${INSTALL_ROOT}/deployment/env.template"
|
||||
# Check if services are already running
|
||||
if [ -d "${INSTALL_ROOT}/deployment" ] && [ -f "${INSTALL_ROOT}/deployment/docker-compose.yml" ]; then
|
||||
# Determine compose command
|
||||
@@ -1125,25 +1084,6 @@ else
|
||||
USE_LATEST=false
|
||||
fi
|
||||
|
||||
# For pinned version tags, re-download config files from that tag so the
|
||||
# compose file matches the images being pulled (the initial download used main).
|
||||
if [[ "$USE_LATEST" = false ]] && [[ "$USE_LOCAL_FILES" = false ]]; then
|
||||
PINNED_BASE="https://raw.githubusercontent.com/onyx-dot-app/onyx/${CURRENT_IMAGE_TAG}/deployment"
|
||||
print_info "Fetching config files matching tag ${CURRENT_IMAGE_TAG}..."
|
||||
if download_file "${PINNED_BASE}/docker_compose/docker-compose.yml" "${INSTALL_ROOT}/deployment/docker-compose.yml" 2>/dev/null; then
|
||||
download_file "${PINNED_BASE}/data/nginx/app.conf.template" "${INSTALL_ROOT}/data/nginx/app.conf.template" 2>/dev/null || true
|
||||
download_file "${PINNED_BASE}/data/nginx/run-nginx.sh" "${INSTALL_ROOT}/data/nginx/run-nginx.sh" 2>/dev/null || true
|
||||
chmod +x "${INSTALL_ROOT}/data/nginx/run-nginx.sh"
|
||||
if [[ "$LITE_MODE" = true ]]; then
|
||||
download_file "${PINNED_BASE}/docker_compose/${LITE_COMPOSE_FILE}" \
|
||||
"${INSTALL_ROOT}/deployment/${LITE_COMPOSE_FILE}" 2>/dev/null || true
|
||||
fi
|
||||
print_success "Config files updated to match ${CURRENT_IMAGE_TAG}"
|
||||
else
|
||||
print_warning "Tag ${CURRENT_IMAGE_TAG} not found on GitHub — using main branch configs"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Pull Docker images with reduced output
|
||||
print_step "Pulling Docker images"
|
||||
print_info "This may take several minutes depending on your internet connection..."
|
||||
|
||||
@@ -17,7 +17,6 @@ import (
|
||||
type RunCIOptions struct {
|
||||
DryRun bool
|
||||
Yes bool
|
||||
Rerun bool
|
||||
}
|
||||
|
||||
// NewRunCICommand creates a new run-ci command
|
||||
@@ -50,7 +49,6 @@ Example usage:
|
||||
|
||||
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "Perform all local operations but skip pushing to remote and creating PRs")
|
||||
cmd.Flags().BoolVar(&opts.Yes, "yes", false, "Skip confirmation prompts and automatically proceed")
|
||||
cmd.Flags().BoolVar(&opts.Rerun, "rerun", false, "Update an existing CI PR with the latest fork changes to re-trigger CI")
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -109,44 +107,19 @@ func runCI(cmd *cobra.Command, args []string, opts *RunCIOptions) {
|
||||
log.Fatalf("PR #%s is not from a fork - CI should already run automatically", prNumber)
|
||||
}
|
||||
|
||||
// Create the CI branch
|
||||
ciBranch := fmt.Sprintf("run-ci/%s", prNumber)
|
||||
prTitle := fmt.Sprintf("chore: [Running GitHub actions for #%s]", prNumber)
|
||||
prBody := fmt.Sprintf("This PR runs GitHub Actions CI for #%s.\n\n- [x] Override Linear Check\n\n**This PR should be closed (not merged) after CI completes.**", prNumber)
|
||||
|
||||
// Check if a CI PR already exists for this branch
|
||||
existingPRURL, err := findExistingCIPR(ciBranch)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to check for existing CI PR: %v", err)
|
||||
}
|
||||
|
||||
if existingPRURL != "" && !opts.Rerun {
|
||||
log.Infof("A CI PR already exists for #%s: %s", prNumber, existingPRURL)
|
||||
log.Info("Run with --rerun to update it with the latest fork changes and re-trigger CI.")
|
||||
return
|
||||
}
|
||||
|
||||
if opts.Rerun && existingPRURL == "" {
|
||||
log.Warn("--rerun was specified but no existing open CI PR was found. A new PR will be created.")
|
||||
}
|
||||
|
||||
if existingPRURL != "" && opts.Rerun {
|
||||
log.Infof("Existing CI PR found: %s", existingPRURL)
|
||||
log.Info("Will update the CI branch with the latest fork changes to re-trigger CI.")
|
||||
}
|
||||
|
||||
// Confirm before proceeding
|
||||
if !opts.Yes {
|
||||
action := "Create CI branch"
|
||||
if existingPRURL != "" {
|
||||
action = "Update existing CI branch"
|
||||
}
|
||||
if !prompt.Confirm(fmt.Sprintf("%s for PR #%s? (yes/no): ", action, prNumber)) {
|
||||
if !prompt.Confirm(fmt.Sprintf("Create CI branch for PR #%s? (yes/no): ", prNumber)) {
|
||||
log.Info("Exiting...")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Create the CI branch
|
||||
ciBranch := fmt.Sprintf("run-ci/%s", prNumber)
|
||||
prTitle := fmt.Sprintf("chore: [Running GitHub actions for #%s]", prNumber)
|
||||
prBody := fmt.Sprintf("This PR runs GitHub Actions CI for #%s.\n\n- [x] Override Linear Check\n\n**This PR should be closed (not merged) after CI completes.**", prNumber)
|
||||
|
||||
// Fetch the fork's branch
|
||||
if forkRepo == "" {
|
||||
log.Fatalf("Could not determine fork repository - headRepositoryOwner or headRepository.name is empty")
|
||||
@@ -185,11 +158,7 @@ func runCI(cmd *cobra.Command, args []string, opts *RunCIOptions) {
|
||||
|
||||
if opts.DryRun {
|
||||
log.Warnf("[DRY RUN] Would push CI branch: %s", ciBranch)
|
||||
if existingPRURL == "" {
|
||||
log.Warnf("[DRY RUN] Would create PR: %s", prTitle)
|
||||
} else {
|
||||
log.Warnf("[DRY RUN] Would update existing PR: %s", existingPRURL)
|
||||
}
|
||||
log.Warnf("[DRY RUN] Would create PR: %s", prTitle)
|
||||
// Switch back to original branch
|
||||
if err := git.RunCommand("switch", "--quiet", originalBranch); err != nil {
|
||||
log.Warnf("Failed to switch back to original branch: %v", err)
|
||||
@@ -207,17 +176,6 @@ func runCI(cmd *cobra.Command, args []string, opts *RunCIOptions) {
|
||||
log.Fatalf("Failed to push CI branch: %v", err)
|
||||
}
|
||||
|
||||
if existingPRURL != "" {
|
||||
// PR already exists - force push is enough to re-trigger CI
|
||||
log.Infof("Switching back to original branch: %s", originalBranch)
|
||||
if err := git.RunCommand("switch", "--quiet", originalBranch); err != nil {
|
||||
log.Warnf("Failed to switch back to original branch: %v", err)
|
||||
}
|
||||
log.Infof("CI PR updated successfully: %s", existingPRURL)
|
||||
log.Info("The force push will re-trigger CI. Remember to close (not merge) this PR after CI completes!")
|
||||
return
|
||||
}
|
||||
|
||||
// Create PR using GitHub CLI
|
||||
log.Info("Creating PR...")
|
||||
prURL, err := createCIPR(ciBranch, prInfo.BaseRefName, prTitle, prBody)
|
||||
@@ -259,39 +217,6 @@ func getPRInfo(prNumber string) (*PRInfo, error) {
|
||||
return &prInfo, nil
|
||||
}
|
||||
|
||||
// findExistingCIPR checks if an open PR already exists for the given CI branch.
|
||||
// Returns the PR URL if found, or empty string if not.
|
||||
func findExistingCIPR(headBranch string) (string, error) {
|
||||
cmd := exec.Command("gh", "pr", "list",
|
||||
"--head", headBranch,
|
||||
"--state", "open",
|
||||
"--json", "url",
|
||||
)
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
return "", fmt.Errorf("%w: %s", err, string(exitErr.Stderr))
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
|
||||
var prs []struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
if err := json.Unmarshal(output, &prs); err != nil {
|
||||
log.Debugf("Failed to parse PR list JSON: %v (raw: %s)", err, string(output))
|
||||
return "", fmt.Errorf("failed to parse PR list: %w", err)
|
||||
}
|
||||
|
||||
if len(prs) == 0 {
|
||||
log.Debugf("No existing open PRs found for branch %s", headBranch)
|
||||
return "", nil
|
||||
}
|
||||
|
||||
log.Debugf("Found existing PR for branch %s: %s", headBranch, prs[0].URL)
|
||||
return prs[0].URL, nil
|
||||
}
|
||||
|
||||
// createCIPR creates a pull request for CI using the GitHub CLI
|
||||
func createCIPR(headBranch, baseBranch, title, body string) (string, error) {
|
||||
cmd := exec.Command("gh", "pr", "create",
|
||||
|
||||
@@ -52,8 +52,3 @@ export {
|
||||
type PaginationProps,
|
||||
type PaginationSize,
|
||||
} from "@opal/components/pagination/components";
|
||||
|
||||
/* Table */
|
||||
export { Table } from "@opal/components/table/components";
|
||||
export { createTableColumns } from "@opal/components/table/columns";
|
||||
export type { DataTableProps } from "@opal/components/table/components";
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
# Table
|
||||
|
||||
Config-driven table component with sorting, pagination, column visibility,
|
||||
row selection, drag-and-drop reordering, and server-side mode.
|
||||
|
||||
## Usage
|
||||
|
||||
```tsx
|
||||
import { Table, createTableColumns } from "@opal/components";
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
status: "active" | "invited";
|
||||
}
|
||||
|
||||
const tc = createTableColumns<User>();
|
||||
|
||||
const columns = [
|
||||
tc.qualifier({ content: "avatar-user", getInitials: (r) => r.name?.[0] ?? "?" }),
|
||||
tc.column("email", {
|
||||
header: "Name",
|
||||
weight: 22,
|
||||
minWidth: 140,
|
||||
cell: (email, row) => <span>{row.name ?? email}</span>,
|
||||
}),
|
||||
tc.column("status", {
|
||||
header: "Status",
|
||||
weight: 14,
|
||||
cell: (status) => <span>{status}</span>,
|
||||
}),
|
||||
tc.actions(),
|
||||
];
|
||||
|
||||
function UsersTable({ users }: { users: User[] }) {
|
||||
return (
|
||||
<Table
|
||||
data={users}
|
||||
columns={columns}
|
||||
getRowId={(r) => r.id}
|
||||
pageSize={10}
|
||||
footer={{ mode: "summary" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Props
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `data` | `TData[]` | required | Row data array |
|
||||
| `columns` | `OnyxColumnDef<TData>[]` | required | Column definitions from `createTableColumns()` |
|
||||
| `getRowId` | `(row: TData) => string` | required | Unique row identifier |
|
||||
| `pageSize` | `number` | `10` | Rows per page (`Infinity` disables pagination) |
|
||||
| `size` | `"md" \| "lg"` | `"lg"` | Density variant |
|
||||
| `footer` | `DataTableFooterConfig` | — | Footer mode (`"selection"` or `"summary"`) |
|
||||
| `initialSorting` | `SortingState` | — | Initial sort state |
|
||||
| `initialColumnVisibility` | `VisibilityState` | — | Initial column visibility |
|
||||
| `draggable` | `DataTableDraggableConfig` | — | Enable drag-and-drop reordering |
|
||||
| `onSelectionChange` | `(ids: string[]) => void` | — | Selection callback |
|
||||
| `onRowClick` | `(row: TData) => void` | — | Row click handler |
|
||||
| `searchTerm` | `string` | — | Global text filter |
|
||||
| `height` | `number \| string` | — | Max scrollable height |
|
||||
| `headerBackground` | `string` | — | Sticky header background |
|
||||
| `serverSide` | `ServerSideConfig` | — | Server-side pagination/sorting/filtering |
|
||||
| `emptyState` | `ReactNode` | — | Empty state content |
|
||||
|
||||
## Column Builder
|
||||
|
||||
`createTableColumns<TData>()` returns a builder with:
|
||||
|
||||
- `tc.qualifier(opts)` — leading avatar/icon/checkbox column
|
||||
- `tc.column(accessor, opts)` — data column with sorting/resizing
|
||||
- `tc.displayColumn(opts)` — non-accessor custom column
|
||||
- `tc.actions(opts)` — trailing actions column with visibility/sorting popovers
|
||||
|
||||
## Footer Modes
|
||||
|
||||
- **`"selection"`** — shows selection count, optional view/clear buttons, count pagination
|
||||
- **`"summary"`** — shows "Showing X~Y of Z", list pagination, optional extra element
|
||||
@@ -1,148 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { Table, createTableColumns } from "@opal/components";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sample data
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role: "admin" | "user" | "viewer";
|
||||
status: "active" | "invited" | "inactive";
|
||||
}
|
||||
|
||||
const USERS: User[] = [
|
||||
{
|
||||
id: "1",
|
||||
email: "alice@example.com",
|
||||
name: "Alice Johnson",
|
||||
role: "admin",
|
||||
status: "active",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
email: "bob@example.com",
|
||||
name: "Bob Smith",
|
||||
role: "user",
|
||||
status: "active",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
email: "carol@example.com",
|
||||
name: "Carol White",
|
||||
role: "viewer",
|
||||
status: "invited",
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
email: "dave@example.com",
|
||||
name: "Dave Brown",
|
||||
role: "user",
|
||||
status: "inactive",
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
email: "eve@example.com",
|
||||
name: "Eve Davis",
|
||||
role: "admin",
|
||||
status: "active",
|
||||
},
|
||||
{
|
||||
id: "6",
|
||||
email: "frank@example.com",
|
||||
name: "Frank Miller",
|
||||
role: "viewer",
|
||||
status: "active",
|
||||
},
|
||||
{
|
||||
id: "7",
|
||||
email: "grace@example.com",
|
||||
name: "Grace Lee",
|
||||
role: "user",
|
||||
status: "invited",
|
||||
},
|
||||
{
|
||||
id: "8",
|
||||
email: "hank@example.com",
|
||||
name: "Hank Wilson",
|
||||
role: "user",
|
||||
status: "active",
|
||||
},
|
||||
{
|
||||
id: "9",
|
||||
email: "iris@example.com",
|
||||
name: "Iris Taylor",
|
||||
role: "viewer",
|
||||
status: "active",
|
||||
},
|
||||
{
|
||||
id: "10",
|
||||
email: "jack@example.com",
|
||||
name: "Jack Moore",
|
||||
role: "admin",
|
||||
status: "active",
|
||||
},
|
||||
{
|
||||
id: "11",
|
||||
email: "kate@example.com",
|
||||
name: "Kate Anderson",
|
||||
role: "user",
|
||||
status: "inactive",
|
||||
},
|
||||
{
|
||||
id: "12",
|
||||
email: "leo@example.com",
|
||||
name: "Leo Thomas",
|
||||
role: "viewer",
|
||||
status: "active",
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Columns
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const tc = createTableColumns<User>();
|
||||
|
||||
const columns = [
|
||||
tc.qualifier({
|
||||
content: "avatar-user",
|
||||
getInitials: (r) =>
|
||||
r.name
|
||||
.split(" ")
|
||||
.map((n) => n[0])
|
||||
.join(""),
|
||||
}),
|
||||
tc.column("name", { header: "Name", weight: 25, minWidth: 120 }),
|
||||
tc.column("email", { header: "Email", weight: 30, minWidth: 160 }),
|
||||
tc.column("role", { header: "Role", weight: 15, minWidth: 80 }),
|
||||
tc.column("status", { header: "Status", weight: 15, minWidth: 80 }),
|
||||
tc.actions(),
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Story
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const meta: Meta<typeof Table> = {
|
||||
title: "opal/components/Table",
|
||||
component: Table,
|
||||
tags: ["autodocs"],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Table>;
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => (
|
||||
<Table
|
||||
data={USERS}
|
||||
columns={columns}
|
||||
getRowId={(r) => r.id}
|
||||
pageSize={8}
|
||||
footer={{ mode: "summary" }}
|
||||
/>
|
||||
),
|
||||
};
|
||||
@@ -1,71 +0,0 @@
|
||||
import React from "react";
|
||||
import { cn } from "@opal/utils";
|
||||
import type { WithoutStyles } from "@/types";
|
||||
import type { ExtremaSizeVariants, SizeVariants } from "@opal/types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type TableSize = Extract<SizeVariants, "md" | "lg">;
|
||||
type TableVariant = "rows" | "cards";
|
||||
type TableQualifier = "simple" | "avatar" | "icon";
|
||||
type SelectionBehavior = "no-select" | "single-select" | "multi-select";
|
||||
|
||||
interface TableProps
|
||||
extends WithoutStyles<React.TableHTMLAttributes<HTMLTableElement>> {
|
||||
ref?: React.Ref<HTMLTableElement>;
|
||||
/** Size preset for the table. @default "lg" */
|
||||
size?: TableSize;
|
||||
/** Visual row variant. @default "cards" */
|
||||
variant?: TableVariant;
|
||||
/** Row selection behavior. @default "no-select" */
|
||||
selectionBehavior?: SelectionBehavior;
|
||||
/** Leading qualifier column type. @default null */
|
||||
qualifier?: TableQualifier;
|
||||
/** Height behavior. `"fit"` = shrink to content, `"full"` = fill available space. */
|
||||
heightVariant?: ExtremaSizeVariants;
|
||||
/** Explicit pixel width for the table (e.g. from `table.getTotalSize()`).
|
||||
* When provided the table uses exactly this width instead of stretching
|
||||
* to fill its container, which prevents `table-layout: fixed` from
|
||||
* redistributing extra space across columns on resize. */
|
||||
width?: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function Table({
|
||||
ref,
|
||||
size = "lg",
|
||||
variant = "cards",
|
||||
selectionBehavior = "no-select",
|
||||
qualifier = "simple",
|
||||
heightVariant,
|
||||
width,
|
||||
...props
|
||||
}: TableProps) {
|
||||
return (
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn("border-separate border-spacing-0", !width && "min-w-full")}
|
||||
style={{ tableLayout: "fixed", width }}
|
||||
data-size={size}
|
||||
data-variant={variant}
|
||||
data-selection={selectionBehavior}
|
||||
data-qualifier={qualifier}
|
||||
data-height={heightVariant}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default Table;
|
||||
export type {
|
||||
TableProps,
|
||||
TableSize,
|
||||
TableVariant,
|
||||
TableQualifier,
|
||||
SelectionBehavior,
|
||||
};
|
||||
@@ -1,164 +0,0 @@
|
||||
/* Imports shared timing tokens (--interactive-duration, --interactive-easing) */
|
||||
@import "@opal/core/interactive/shared.css";
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Table primitives — data-attribute driven styling
|
||||
* Follows the same pattern as card.css / line-item.css.
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
/* ---- TableCell ---- */
|
||||
|
||||
.tbl-cell[data-size="lg"] {
|
||||
@apply px-1 py-0.5;
|
||||
}
|
||||
.tbl-cell[data-size="md"] {
|
||||
@apply pl-0.5 pr-1.5 py-1.5;
|
||||
}
|
||||
|
||||
.tbl-cell-inner[data-size="lg"] {
|
||||
@apply h-10 px-1;
|
||||
}
|
||||
.tbl-cell-inner[data-size="md"] {
|
||||
@apply h-6 px-0.5;
|
||||
}
|
||||
|
||||
/* ---- TableHead ---- */
|
||||
|
||||
.table-head {
|
||||
@apply relative sticky top-0 z-20;
|
||||
background: var(--table-header-bg, transparent);
|
||||
}
|
||||
.table-head[data-size="lg"] {
|
||||
@apply px-2 py-1;
|
||||
}
|
||||
.table-head[data-size="md"] {
|
||||
@apply px-2 py-1;
|
||||
}
|
||||
.table-head[data-bottom-border] {
|
||||
@apply border-b border-transparent hover:border-border-03;
|
||||
}
|
||||
|
||||
/* Inner text wrapper */
|
||||
.table-head[data-size="lg"] .table-head-label {
|
||||
@apply py-2 px-0.5;
|
||||
}
|
||||
.table-head[data-size="md"] .table-head-label {
|
||||
@apply py-1;
|
||||
}
|
||||
|
||||
/* Sort button wrapper */
|
||||
.table-head[data-size="lg"] .table-head-sort {
|
||||
@apply py-1.5;
|
||||
}
|
||||
|
||||
/* ---- TableRow (base) ---- */
|
||||
|
||||
.tbl-row > td {
|
||||
@apply bg-background-tint-00;
|
||||
transition: background-color var(--interactive-duration)
|
||||
var(--interactive-easing);
|
||||
}
|
||||
|
||||
.tbl-row[data-selected] > td {
|
||||
@apply bg-[var(--action-link-01)];
|
||||
}
|
||||
|
||||
.tbl-row[data-disabled] {
|
||||
@apply pointer-events-none;
|
||||
}
|
||||
|
||||
/* Suppress default focus ring on rows — the row bg is the indicator */
|
||||
.tbl-row:focus,
|
||||
.tbl-row:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* ---- variant="rows" — traditional borders, no gaps ---- */
|
||||
|
||||
table[data-variant="rows"] .tbl-row > td {
|
||||
@apply border-b border-border-01;
|
||||
}
|
||||
|
||||
/* Hover/focus only for selectable tables */
|
||||
table[data-variant="rows"][data-selection="single-select"] .tbl-row,
|
||||
table[data-variant="rows"][data-selection="multi-select"] .tbl-row {
|
||||
@apply cursor-pointer;
|
||||
}
|
||||
table[data-variant="rows"][data-selection="single-select"] .tbl-row:hover > td,
|
||||
table[data-variant="rows"][data-selection="multi-select"] .tbl-row:hover > td {
|
||||
@apply bg-background-tint-02;
|
||||
}
|
||||
table[data-variant="rows"] .tbl-row:focus-visible > td,
|
||||
table[data-variant="rows"] .tbl-row:has(:focus-visible) > td {
|
||||
@apply bg-action-link-01;
|
||||
}
|
||||
|
||||
/* ---- variant="cards" — rounded cards with gap ---- */
|
||||
|
||||
table[data-variant="cards"] .tbl-row > td {
|
||||
@apply bg-clip-padding border-y-[2px] border-x-0 border-transparent;
|
||||
}
|
||||
table[data-variant="cards"] .tbl-row > td:first-child {
|
||||
@apply rounded-l-12;
|
||||
}
|
||||
table[data-variant="cards"] .tbl-row > td:last-child {
|
||||
@apply rounded-r-12;
|
||||
}
|
||||
|
||||
/* When a drag handle is present the second-to-last td gets the rounding */
|
||||
table[data-variant="cards"] .tbl-row[data-drag-handle] > td:nth-last-child(2) {
|
||||
@apply rounded-r-12;
|
||||
}
|
||||
table[data-variant="cards"] .tbl-row[data-drag-handle] > td:last-child {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
/* Hover/focus only for selectable tables */
|
||||
table[data-variant="cards"][data-selection="single-select"] .tbl-row,
|
||||
table[data-variant="cards"][data-selection="multi-select"] .tbl-row {
|
||||
@apply cursor-pointer;
|
||||
}
|
||||
table[data-variant="cards"][data-selection="single-select"] .tbl-row:hover > td,
|
||||
table[data-variant="cards"][data-selection="multi-select"] .tbl-row:hover > td {
|
||||
@apply bg-background-tint-02;
|
||||
}
|
||||
table[data-variant="cards"] .tbl-row:focus-visible > td,
|
||||
table[data-variant="cards"] .tbl-row:has(:focus-visible) > td {
|
||||
@apply bg-action-link-01;
|
||||
}
|
||||
|
||||
/* ---- QualifierContainer ---- */
|
||||
|
||||
.tbl-qualifier[data-type="head"] {
|
||||
@apply w-px whitespace-nowrap py-1 sticky top-0 z-20;
|
||||
background: var(--table-header-bg, transparent);
|
||||
}
|
||||
.tbl-qualifier[data-type="head"][data-size="md"] {
|
||||
@apply py-0.5;
|
||||
}
|
||||
|
||||
.tbl-qualifier[data-type="cell"] {
|
||||
@apply w-px whitespace-nowrap py-1;
|
||||
}
|
||||
.tbl-qualifier[data-type="cell"][data-size="md"] {
|
||||
@apply py-0.5;
|
||||
}
|
||||
|
||||
/* ---- ActionsContainer ---- */
|
||||
|
||||
.tbl-actions {
|
||||
@apply sticky right-0 w-px whitespace-nowrap px-1;
|
||||
}
|
||||
.tbl-actions[data-type="head"] {
|
||||
@apply z-30 sticky top-0 px-2 py-1;
|
||||
background: var(--table-header-bg, transparent);
|
||||
}
|
||||
|
||||
/* ---- Footer ---- */
|
||||
|
||||
.table-footer[data-size="lg"] {
|
||||
@apply min-h-[2.75rem];
|
||||
}
|
||||
.table-footer[data-size="md"] {
|
||||
@apply min-h-[2.25rem];
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { Hoverable } from "@opal/core";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Meta
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const meta: Meta = {
|
||||
title: "Core/Hoverable",
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stories
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Group mode — hovering the root reveals hidden items. */
|
||||
export const GroupMode: StoryObj = {
|
||||
render: () => (
|
||||
<Hoverable.Root group="demo">
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.75rem",
|
||||
padding: "1rem",
|
||||
border: "1px solid var(--border-02)",
|
||||
borderRadius: "0.5rem",
|
||||
minWidth: 260,
|
||||
}}
|
||||
>
|
||||
<span style={{ color: "var(--text-01)" }}>Hover this card</span>
|
||||
<Hoverable.Item group="demo" variant="opacity-on-hover">
|
||||
<span style={{ color: "var(--text-03)" }}>✓ Revealed</span>
|
||||
</Hoverable.Item>
|
||||
</div>
|
||||
</Hoverable.Root>
|
||||
),
|
||||
};
|
||||
|
||||
/** Local mode — hovering the item itself reveals it (no Root needed). */
|
||||
export const LocalMode: StoryObj = {
|
||||
render: () => (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.75rem",
|
||||
padding: "1rem",
|
||||
}}
|
||||
>
|
||||
<span style={{ color: "var(--text-01)" }}>Hover the icon →</span>
|
||||
<Hoverable.Item variant="opacity-on-hover">
|
||||
<span style={{ fontSize: "1.25rem" }}>🗑</span>
|
||||
</Hoverable.Item>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
/** Multiple independent groups on the same page. */
|
||||
export const MultipleGroups: StoryObj = {
|
||||
render: () => (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
|
||||
{(["alpha", "beta"] as const).map((group) => (
|
||||
<Hoverable.Root key={group} group={group}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.75rem",
|
||||
padding: "1rem",
|
||||
border: "1px solid var(--border-02)",
|
||||
borderRadius: "0.5rem",
|
||||
}}
|
||||
>
|
||||
<span style={{ color: "var(--text-01)" }}>Group: {group}</span>
|
||||
<Hoverable.Item group={group} variant="opacity-on-hover">
|
||||
<span style={{ color: "var(--text-03)" }}>✓ Revealed</span>
|
||||
</Hoverable.Item>
|
||||
</div>
|
||||
</Hoverable.Root>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
/** Multiple items revealed by a single root. */
|
||||
export const MultipleItems: StoryObj = {
|
||||
render: () => (
|
||||
<Hoverable.Root group="multi">
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.75rem",
|
||||
padding: "1rem",
|
||||
border: "1px solid var(--border-02)",
|
||||
borderRadius: "0.5rem",
|
||||
}}
|
||||
>
|
||||
<span style={{ color: "var(--text-01)" }}>Hover to reveal all</span>
|
||||
<Hoverable.Item group="multi" variant="opacity-on-hover">
|
||||
<span>Edit</span>
|
||||
</Hoverable.Item>
|
||||
<Hoverable.Item group="multi" variant="opacity-on-hover">
|
||||
<span>Delete</span>
|
||||
</Hoverable.Item>
|
||||
<Hoverable.Item group="multi" variant="opacity-on-hover">
|
||||
<span>Share</span>
|
||||
</Hoverable.Item>
|
||||
</div>
|
||||
</Hoverable.Root>
|
||||
),
|
||||
};
|
||||
|
||||
/** Nested groups — inner and outer hover independently. */
|
||||
export const NestedGroups: StoryObj = {
|
||||
render: () => (
|
||||
<Hoverable.Root group="outer">
|
||||
<div
|
||||
style={{
|
||||
padding: "1rem",
|
||||
border: "1px solid var(--border-02)",
|
||||
borderRadius: "0.5rem",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "0.75rem",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
|
||||
<span style={{ color: "var(--text-01)" }}>Outer card</span>
|
||||
<Hoverable.Item group="outer" variant="opacity-on-hover">
|
||||
<span style={{ color: "var(--text-03)" }}>Outer action</span>
|
||||
</Hoverable.Item>
|
||||
</div>
|
||||
|
||||
<Hoverable.Root group="inner">
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.75rem",
|
||||
padding: "0.75rem",
|
||||
border: "1px solid var(--border-03)",
|
||||
borderRadius: "0.375rem",
|
||||
}}
|
||||
>
|
||||
<span style={{ color: "var(--text-02)" }}>Inner card</span>
|
||||
<Hoverable.Item group="inner" variant="opacity-on-hover">
|
||||
<span style={{ color: "var(--text-03)" }}>Inner action</span>
|
||||
</Hoverable.Item>
|
||||
</div>
|
||||
</Hoverable.Root>
|
||||
</div>
|
||||
</Hoverable.Root>
|
||||
),
|
||||
};
|
||||
127
web/lib/opal/src/core/hoverable/Hoverable.stories.tsx
Normal file
127
web/lib/opal/src/core/hoverable/Hoverable.stories.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { Hoverable } from "@opal/core";
|
||||
import SvgX from "@opal/icons/x";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared styles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const cardStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: "1rem",
|
||||
padding: "0.75rem 1rem",
|
||||
borderRadius: "0.5rem",
|
||||
border: "1px solid var(--border-02)",
|
||||
background: "var(--background-neutral-01)",
|
||||
minWidth: 220,
|
||||
};
|
||||
|
||||
const labelStyle: React.CSSProperties = {
|
||||
fontSize: "0.875rem",
|
||||
fontWeight: 500,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Meta
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const meta: Meta = {
|
||||
title: "Core/Hoverable",
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stories
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Local hover mode -- no `group` prop on the Item.
|
||||
* The icon only appears when you hover directly over the Item element itself.
|
||||
*/
|
||||
export const LocalHover: StoryObj = {
|
||||
render: () => (
|
||||
<div style={cardStyle}>
|
||||
<span style={labelStyle}>Hover this card area</span>
|
||||
|
||||
<Hoverable.Item variant="opacity-on-hover">
|
||||
<SvgX width={16} height={16} />
|
||||
</Hoverable.Item>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
/**
|
||||
* Group hover mode -- hovering anywhere inside the Root reveals the Item.
|
||||
*/
|
||||
export const GroupHover: StoryObj = {
|
||||
render: () => (
|
||||
<Hoverable.Root group="card">
|
||||
<div style={cardStyle}>
|
||||
<span style={labelStyle}>Hover anywhere on this card</span>
|
||||
|
||||
<Hoverable.Item group="card" variant="opacity-on-hover">
|
||||
<SvgX width={16} height={16} />
|
||||
</Hoverable.Item>
|
||||
</div>
|
||||
</Hoverable.Root>
|
||||
),
|
||||
};
|
||||
|
||||
/**
|
||||
* Nested groups demonstrating isolation.
|
||||
*
|
||||
* - Hovering the outer card reveals only the outer icon.
|
||||
* - Hovering the inner card reveals only the inner icon.
|
||||
*/
|
||||
export const NestedGroups: StoryObj = {
|
||||
render: () => (
|
||||
<Hoverable.Root group="outer">
|
||||
<div
|
||||
style={{
|
||||
...cardStyle,
|
||||
flexDirection: "column",
|
||||
alignItems: "stretch",
|
||||
gap: "0.75rem",
|
||||
padding: "1rem",
|
||||
minWidth: 300,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<span style={labelStyle}>Outer card</span>
|
||||
|
||||
<Hoverable.Item group="outer" variant="opacity-on-hover">
|
||||
<SvgX width={16} height={16} />
|
||||
</Hoverable.Item>
|
||||
</div>
|
||||
|
||||
<Hoverable.Root group="inner">
|
||||
<div
|
||||
style={{
|
||||
...cardStyle,
|
||||
background: "var(--background-neutral-02)",
|
||||
}}
|
||||
>
|
||||
<span style={labelStyle}>Inner card</span>
|
||||
|
||||
<Hoverable.Item group="inner" variant="opacity-on-hover">
|
||||
<SvgX width={16} height={16} />
|
||||
</Hoverable.Item>
|
||||
</div>
|
||||
</Hoverable.Root>
|
||||
</div>
|
||||
</Hoverable.Root>
|
||||
),
|
||||
};
|
||||
@@ -15,21 +15,13 @@
|
||||
initial-value: transparent;
|
||||
}
|
||||
|
||||
/* Shared timing tokens — used by .interactive and other surfaces (e.g. table rows) */
|
||||
:root {
|
||||
--interactive-duration: 150ms;
|
||||
--interactive-easing: ease-in-out;
|
||||
}
|
||||
|
||||
/* Base interactive surface */
|
||||
.interactive {
|
||||
@apply cursor-pointer select-none;
|
||||
transition:
|
||||
background-color var(--interactive-duration) var(--interactive-easing),
|
||||
--interactive-foreground var(--interactive-duration)
|
||||
var(--interactive-easing),
|
||||
--interactive-foreground-icon var(--interactive-duration)
|
||||
var(--interactive-easing);
|
||||
background-color 150ms ease-in-out,
|
||||
--interactive-foreground 150ms ease-in-out,
|
||||
--interactive-foreground-icon 150ms ease-in-out;
|
||||
}
|
||||
.interactive[data-disabled] {
|
||||
@apply cursor-not-allowed;
|
||||
|
||||
@@ -2,14 +2,7 @@
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@opal/*": ["./src/*"],
|
||||
// TODO (@raunakab): Remove this once the table component migration is
|
||||
// complete. The table internals still import app-layer modules (e.g.
|
||||
// @/refresh-components/texts/Text, @/refresh-components/Popover) via the
|
||||
// @/ alias. Without this entry the IDE cannot resolve those paths since
|
||||
// opal's tsconfig only defines @opal/*. Once all @/ deps are replaced
|
||||
// with opal-internal equivalents, this line should be deleted.
|
||||
"@/*": ["../../src/*"]
|
||||
"@opal/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from "@/refresh-pages/admin/GroupsPage";
|
||||
143
web/src/app/css/table.css
Normal file
143
web/src/app/css/table.css
Normal file
@@ -0,0 +1,143 @@
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Table primitives — data-attribute driven styling
|
||||
* Follows the same pattern as card.css / line-item.css.
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
/* ---- TableCell ---- */
|
||||
|
||||
.tbl-cell[data-size="regular"] {
|
||||
@apply px-1 py-0.5;
|
||||
}
|
||||
.tbl-cell[data-size="small"] {
|
||||
@apply pl-0.5 pr-1.5 py-1.5;
|
||||
}
|
||||
|
||||
.tbl-cell-inner[data-size="regular"] {
|
||||
@apply h-10 px-1;
|
||||
}
|
||||
.tbl-cell-inner[data-size="small"] {
|
||||
@apply h-6 px-0.5;
|
||||
}
|
||||
|
||||
/* ---- TableHead ---- */
|
||||
|
||||
.table-head {
|
||||
@apply relative sticky top-0 z-20;
|
||||
background: var(--table-header-bg, transparent);
|
||||
}
|
||||
.table-head[data-size="regular"] {
|
||||
@apply px-2 py-1;
|
||||
}
|
||||
.table-head[data-size="small"] {
|
||||
@apply p-1.5;
|
||||
}
|
||||
.table-head[data-bottom-border] {
|
||||
@apply border-b border-transparent hover:border-border-03;
|
||||
}
|
||||
|
||||
/* Inner text wrapper */
|
||||
.table-head[data-size="regular"] .table-head-label {
|
||||
@apply py-2 px-0.5;
|
||||
}
|
||||
.table-head[data-size="small"] .table-head-label {
|
||||
@apply py-1;
|
||||
}
|
||||
|
||||
/* Sort button wrapper */
|
||||
.table-head[data-size="regular"] .table-head-sort {
|
||||
@apply py-1.5;
|
||||
}
|
||||
|
||||
/* ---- TableRow ---- */
|
||||
|
||||
.tbl-row > td {
|
||||
@apply bg-background-tint-00;
|
||||
}
|
||||
|
||||
.tbl-row[data-variant="table"] > td {
|
||||
@apply border-b border-border-01;
|
||||
}
|
||||
|
||||
.tbl-row[data-variant="list"] > td {
|
||||
@apply bg-clip-padding border-y-[4px] border-x-0 border-transparent;
|
||||
}
|
||||
.tbl-row[data-variant="list"] > td:first-child {
|
||||
@apply rounded-l-12;
|
||||
}
|
||||
.tbl-row[data-variant="list"] > td:last-child {
|
||||
@apply rounded-r-12;
|
||||
}
|
||||
|
||||
/* When a drag handle is present the second-to-last td gets the rounding */
|
||||
.tbl-row[data-variant="list"][data-drag-handle] > td:nth-last-child(2) {
|
||||
@apply rounded-r-12;
|
||||
}
|
||||
.tbl-row[data-variant="list"][data-drag-handle] > td:last-child {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
/* ---- Row states (list variant) ---- */
|
||||
|
||||
.tbl-row[data-variant="list"]:hover > td {
|
||||
@apply bg-background-tint-02;
|
||||
}
|
||||
.tbl-row[data-variant="list"]:focus-visible > td,
|
||||
.tbl-row[data-variant="list"]:has(:focus-visible) > td {
|
||||
@apply bg-action-link-01;
|
||||
}
|
||||
|
||||
.tbl-row[data-disabled] {
|
||||
@apply pointer-events-none;
|
||||
}
|
||||
|
||||
/* ---- Row states (table variant) ---- */
|
||||
|
||||
.tbl-row[data-variant="table"]:hover > td {
|
||||
@apply bg-background-tint-02;
|
||||
}
|
||||
.tbl-row[data-variant="table"]:focus-visible > td,
|
||||
.tbl-row[data-variant="table"]:has(:focus-visible) > td {
|
||||
@apply bg-action-link-01;
|
||||
}
|
||||
|
||||
/* Suppress default focus ring on rows — the row bg is the indicator */
|
||||
.tbl-row:focus,
|
||||
.tbl-row:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* ---- QualifierContainer ---- */
|
||||
|
||||
.tbl-qualifier[data-type="head"] {
|
||||
@apply w-px whitespace-nowrap py-1 sticky top-0 z-20;
|
||||
background: var(--table-header-bg, transparent);
|
||||
}
|
||||
.tbl-qualifier[data-type="head"][data-size="small"] {
|
||||
@apply py-0.5;
|
||||
}
|
||||
|
||||
.tbl-qualifier[data-type="cell"] {
|
||||
@apply w-px whitespace-nowrap py-1 pl-1;
|
||||
}
|
||||
.tbl-qualifier[data-type="cell"][data-size="small"] {
|
||||
@apply py-0.5 pl-0.5;
|
||||
}
|
||||
|
||||
/* ---- ActionsContainer ---- */
|
||||
|
||||
.tbl-actions {
|
||||
@apply sticky right-0 w-px whitespace-nowrap;
|
||||
}
|
||||
.tbl-actions[data-type="head"] {
|
||||
@apply z-30 sticky top-0;
|
||||
background: var(--table-header-bg, transparent);
|
||||
}
|
||||
|
||||
/* ---- Footer ---- */
|
||||
|
||||
.table-footer[data-size="regular"] {
|
||||
@apply min-h-[2.75rem];
|
||||
}
|
||||
.table-footer[data-size="small"] {
|
||||
@apply min-h-[2.25rem];
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from "@/refresh-pages/admin/GroupsPage";
|
||||
@@ -16,6 +16,7 @@
|
||||
@import "css/sizes.css";
|
||||
@import "css/square-button.css";
|
||||
@import "css/switch.css";
|
||||
@import "css/table.css";
|
||||
@import "css/z-index.css";
|
||||
|
||||
/* KH Teka Font */
|
||||
|
||||
@@ -451,19 +451,13 @@ const ModalHeader = React.forwardRef<HTMLDivElement, ModalHeaderProps>(
|
||||
);
|
||||
|
||||
return (
|
||||
<Section
|
||||
ref={ref}
|
||||
padding={0.5}
|
||||
alignItems="start"
|
||||
height="fit"
|
||||
{...props}
|
||||
>
|
||||
<Section ref={ref} padding={1} alignItems="start" height="fit" {...props}>
|
||||
<Section
|
||||
flexDirection="row"
|
||||
justifyContent="between"
|
||||
alignItems="start"
|
||||
gap={0}
|
||||
padding={0.5}
|
||||
padding={0}
|
||||
>
|
||||
<div className="relative w-full">
|
||||
{/* Close button is absolutely positioned because:
|
||||
@@ -491,6 +485,7 @@ const ModalHeader = React.forwardRef<HTMLDivElement, ModalHeaderProps>(
|
||||
</DialogPrimitive.Title>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{children}
|
||||
</Section>
|
||||
);
|
||||
|
||||
@@ -9,8 +9,6 @@ export interface SeparatorProps
|
||||
noPadding?: boolean;
|
||||
/** Custom horizontal padding in rem. Overrides the default padding. */
|
||||
paddingXRem?: number;
|
||||
/** Custom vertical padding in rem. Overrides the default padding. */
|
||||
paddingYRem?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -39,7 +37,7 @@ const Separator = React.forwardRef(
|
||||
{
|
||||
noPadding,
|
||||
paddingXRem,
|
||||
paddingYRem,
|
||||
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
@@ -58,12 +56,6 @@ const Separator = React.forwardRef(
|
||||
paddingRight: `${paddingXRem}rem`,
|
||||
}
|
||||
: {}),
|
||||
...(paddingYRem != null
|
||||
? {
|
||||
paddingTop: `${paddingYRem}rem`,
|
||||
paddingBottom: `${paddingYRem}rem`,
|
||||
}
|
||||
: {}),
|
||||
}}
|
||||
className={cn(
|
||||
isHorizontal ? "w-full" : "h-full",
|
||||
|
||||
@@ -112,11 +112,9 @@ function MemoryItem({
|
||||
/>
|
||||
</Disabled>
|
||||
</Section>
|
||||
<div
|
||||
className={isFocused ? "visible" : "invisible h-0 overflow-hidden"}
|
||||
>
|
||||
{isFocused && (
|
||||
<CharacterCount value={memory.content} limit={MAX_MEMORY_LENGTH} />
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useTableSize } from "@opal/components/table/TableSizeContext";
|
||||
import type { TableSize } from "@opal/components/table/TableSizeContext";
|
||||
import { useTableSize } from "@/refresh-components/table/TableSizeContext";
|
||||
import type { TableSize } from "@/refresh-components/table/TableSizeContext";
|
||||
|
||||
interface ActionsContainerProps {
|
||||
type: "head" | "cell";
|
||||
@@ -7,9 +7,10 @@ import {
|
||||
type RowData,
|
||||
type VisibilityState,
|
||||
} from "@tanstack/react-table";
|
||||
import { Button, LineItemButton, Tag } from "@opal/components";
|
||||
import { Button } from "@opal/components";
|
||||
import { SvgColumn, SvgCheck } from "@opal/icons";
|
||||
import Popover from "@/refresh-components/Popover";
|
||||
import LineItem from "@/refresh-components/buttons/LineItem";
|
||||
import Divider from "@/refresh-components/Divider";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -19,20 +20,18 @@ import Divider from "@/refresh-components/Divider";
|
||||
interface ColumnVisibilityPopoverProps<TData extends RowData = RowData> {
|
||||
table: Table<TData>;
|
||||
columnVisibility: VisibilityState;
|
||||
size?: "md" | "lg";
|
||||
size?: "regular" | "small";
|
||||
}
|
||||
|
||||
function ColumnVisibilityPopover<TData extends RowData>({
|
||||
table,
|
||||
columnVisibility,
|
||||
size = "lg",
|
||||
size = "regular",
|
||||
}: ColumnVisibilityPopoverProps<TData>) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
// User-defined columns only (exclude internal qualifier/actions)
|
||||
const dataColumns = table
|
||||
const hideableColumns = table
|
||||
.getAllLeafColumns()
|
||||
.filter((col) => !col.id.startsWith("__") && col.id !== "qualifier");
|
||||
.filter((col) => col.getCanHide());
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
@@ -40,8 +39,8 @@ function ColumnVisibilityPopover<TData extends RowData>({
|
||||
<Button
|
||||
icon={SvgColumn}
|
||||
interaction={open ? "hover" : "rest"}
|
||||
size={size === "md" ? "sm" : "md"}
|
||||
prominence="tertiary"
|
||||
size={size === "small" ? "sm" : "md"}
|
||||
prominence="internal"
|
||||
tooltip="Columns"
|
||||
/>
|
||||
</Popover.Trigger>
|
||||
@@ -49,8 +48,7 @@ function ColumnVisibilityPopover<TData extends RowData>({
|
||||
<Popover.Content width="lg" align="end" side="bottom">
|
||||
<Divider showTitle text="Shown Columns" />
|
||||
<Popover.Menu>
|
||||
{dataColumns.map((column) => {
|
||||
const canHide = column.getCanHide();
|
||||
{hideableColumns.map((column) => {
|
||||
const isVisible = columnVisibility[column.id] !== false;
|
||||
const label =
|
||||
typeof column.columnDef.header === "string"
|
||||
@@ -58,23 +56,17 @@ function ColumnVisibilityPopover<TData extends RowData>({
|
||||
: column.id;
|
||||
|
||||
return (
|
||||
<LineItemButton
|
||||
<LineItem
|
||||
key={column.id}
|
||||
selectVariant="select-heavy"
|
||||
state={isVisible ? "selected" : "empty"}
|
||||
title={label}
|
||||
sizePreset="main-ui"
|
||||
rightChildren={
|
||||
!canHide ? (
|
||||
<div className="flex items-center">
|
||||
<Tag title="Always Shown" color="blue" />
|
||||
</div>
|
||||
) : isVisible ? (
|
||||
<SvgCheck size={16} className="text-action-link-05" />
|
||||
) : undefined
|
||||
}
|
||||
onClick={canHide ? () => column.toggleVisibility() : undefined}
|
||||
/>
|
||||
selected={isVisible}
|
||||
emphasized
|
||||
rightChildren={isVisible ? <SvgCheck size={16} /> : undefined}
|
||||
onClick={() => {
|
||||
column.toggleVisibility();
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</LineItem>
|
||||
);
|
||||
})}
|
||||
</Popover.Menu>
|
||||
@@ -88,7 +80,7 @@ function ColumnVisibilityPopover<TData extends RowData>({
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface CreateColumnVisibilityColumnOptions {
|
||||
size?: "md" | "lg";
|
||||
size?: "regular" | "small";
|
||||
}
|
||||
|
||||
function createColumnVisibilityColumn<TData>(
|
||||
@@ -1,56 +1,39 @@
|
||||
"use client";
|
||||
"use no memo";
|
||||
|
||||
import "@opal/components/table/styles.css";
|
||||
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { flexRender } from "@tanstack/react-table";
|
||||
import useDataTable, {
|
||||
toOnyxSortDirection,
|
||||
} from "@opal/components/table/hooks/useDataTable";
|
||||
import useColumnWidths from "@opal/components/table/hooks/useColumnWidths";
|
||||
import useDraggableRows from "@opal/components/table/hooks/useDraggableRows";
|
||||
import TableElement from "@opal/components/table/TableElement";
|
||||
import TableHeader from "@opal/components/table/TableHeader";
|
||||
import TableBody from "@opal/components/table/TableBody";
|
||||
import TableRow from "@opal/components/table/TableRow";
|
||||
import TableHead from "@opal/components/table/TableHead";
|
||||
import TableCell from "@opal/components/table/TableCell";
|
||||
import TableQualifier from "@opal/components/table/TableQualifier";
|
||||
import QualifierContainer from "@opal/components/table/QualifierContainer";
|
||||
import ActionsContainer from "@opal/components/table/ActionsContainer";
|
||||
import DragOverlayRow from "@opal/components/table/DragOverlayRow";
|
||||
import Footer from "@opal/components/table/Footer";
|
||||
import Checkbox from "@/refresh-components/inputs/Checkbox";
|
||||
import { TableSizeProvider } from "@opal/components/table/TableSizeContext";
|
||||
import { ColumnVisibilityPopover } from "@opal/components/table/ColumnVisibilityPopover";
|
||||
import { SortingPopover } from "@opal/components/table/ColumnSortabilityPopover";
|
||||
import type { WidthConfig } from "@opal/components/table/hooks/useColumnWidths";
|
||||
} from "@/refresh-components/table/hooks/useDataTable";
|
||||
import useColumnWidths from "@/refresh-components/table/hooks/useColumnWidths";
|
||||
import useDraggableRows from "@/refresh-components/table/hooks/useDraggableRows";
|
||||
import Table from "@/refresh-components/table/Table";
|
||||
import TableHeader from "@/refresh-components/table/TableHeader";
|
||||
import TableBody from "@/refresh-components/table/TableBody";
|
||||
import TableRow from "@/refresh-components/table/TableRow";
|
||||
import TableHead from "@/refresh-components/table/TableHead";
|
||||
import TableCell from "@/refresh-components/table/TableCell";
|
||||
import TableQualifier from "@/refresh-components/table/TableQualifier";
|
||||
import QualifierContainer from "@/refresh-components/table/QualifierContainer";
|
||||
import ActionsContainer from "@/refresh-components/table/ActionsContainer";
|
||||
import DragOverlayRow from "@/refresh-components/table/DragOverlayRow";
|
||||
import Footer from "@/refresh-components/table/Footer";
|
||||
import { TableSizeProvider } from "@/refresh-components/table/TableSizeContext";
|
||||
import { ColumnVisibilityPopover } from "@/refresh-components/table/ColumnVisibilityPopover";
|
||||
import { SortingPopover } from "@/refresh-components/table/SortingPopover";
|
||||
import type { WidthConfig } from "@/refresh-components/table/hooks/useColumnWidths";
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import { cn } from "@opal/utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type {
|
||||
DataTableProps as BaseDataTableProps,
|
||||
DataTableProps,
|
||||
DataTableFooterConfig,
|
||||
OnyxColumnDef,
|
||||
OnyxDataColumn,
|
||||
OnyxQualifierColumn,
|
||||
OnyxActionsColumn,
|
||||
} from "@opal/components/table/types";
|
||||
import type { TableSize } from "@opal/components/table/TableSizeContext";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Qualifier × SelectionBehavior
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type Qualifier = "simple" | "avatar" | "icon";
|
||||
type SelectionBehavior = "no-select" | "single-select" | "multi-select";
|
||||
|
||||
export type DataTableProps<TData> = BaseDataTableProps<TData> & {
|
||||
/** Leading qualifier column type. @default "simple" */
|
||||
qualifier?: Qualifier;
|
||||
/** Row selection behavior. @default "no-select" */
|
||||
selectionBehavior?: SelectionBehavior;
|
||||
};
|
||||
} from "@/refresh-components/table/types";
|
||||
import type { TableSize } from "@/refresh-components/table/TableSizeContext";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal: resolve size-dependent widths and build TanStack columns
|
||||
@@ -74,7 +57,6 @@ function processColumns<TData>(
|
||||
const columnMinWidths: Record<string, number> = {};
|
||||
const columnKindMap = new Map<string, OnyxColumnDef<TData>>();
|
||||
let qualifierColumn: OnyxQualifierColumn<TData> | null = null;
|
||||
let firstDataColumnSeen = false;
|
||||
|
||||
for (const col of columns) {
|
||||
const resolvedWidth =
|
||||
@@ -88,12 +70,6 @@ function processColumns<TData>(
|
||||
"fixed" in resolvedWidth ? resolvedWidth.fixed : resolvedWidth.weight,
|
||||
};
|
||||
|
||||
// First data column is never hideable
|
||||
if (col.kind === "data" && !firstDataColumnSeen) {
|
||||
firstDataColumnSeen = true;
|
||||
clonedDef.enableHiding = false;
|
||||
}
|
||||
|
||||
tanstackColumns.push(clonedDef);
|
||||
|
||||
const id = col.id;
|
||||
@@ -137,10 +113,10 @@ function processColumns<TData>(
|
||||
* tc.actions(),
|
||||
* ];
|
||||
*
|
||||
* <Table data={items} columns={columns} footer={{}} />
|
||||
* <DataTable data={items} columns={columns} footer={{ mode: "selection" }} />
|
||||
* ```
|
||||
*/
|
||||
export function Table<TData>(props: DataTableProps<TData>) {
|
||||
export default function DataTable<TData>(props: DataTableProps<TData>) {
|
||||
const {
|
||||
data,
|
||||
columns,
|
||||
@@ -150,10 +126,7 @@ export function Table<TData>(props: DataTableProps<TData>) {
|
||||
initialColumnVisibility,
|
||||
draggable,
|
||||
footer,
|
||||
size = "lg",
|
||||
variant = "cards",
|
||||
qualifier = "simple",
|
||||
selectionBehavior = "no-select",
|
||||
size = "regular",
|
||||
onSelectionChange,
|
||||
onRowClick,
|
||||
searchTerm,
|
||||
@@ -165,37 +138,9 @@ export function Table<TData>(props: DataTableProps<TData>) {
|
||||
|
||||
const effectivePageSize = pageSize ?? (footer ? 10 : data.length);
|
||||
|
||||
// Whether the qualifier column should exist in the DOM.
|
||||
// "simple" only gets a qualifier column for multi-select (checkboxes).
|
||||
// "simple" + no-select/single-select = no qualifier column — single-select
|
||||
// uses row-level background coloring instead.
|
||||
const hasQualifierColumn =
|
||||
qualifier !== "simple" || selectionBehavior === "multi-select";
|
||||
|
||||
// 1. Process columns (memoized on columns + size)
|
||||
const { tanstackColumns, widthConfig, qualifierColumn, columnKindMap } =
|
||||
useMemo(() => {
|
||||
const processed = processColumns(columns, size);
|
||||
if (!hasQualifierColumn) {
|
||||
// Remove qualifier from TanStack columns and width config entirely
|
||||
return {
|
||||
...processed,
|
||||
tanstackColumns: processed.tanstackColumns.filter(
|
||||
(c) => c.id !== "qualifier"
|
||||
),
|
||||
widthConfig: {
|
||||
...processed.widthConfig,
|
||||
fixedColumnIds: new Set(
|
||||
Array.from(processed.widthConfig.fixedColumnIds).filter(
|
||||
(id) => id !== "qualifier"
|
||||
)
|
||||
),
|
||||
},
|
||||
qualifierColumn: null,
|
||||
};
|
||||
}
|
||||
return processed;
|
||||
}, [columns, size, hasQualifierColumn]);
|
||||
useMemo(() => processColumns(columns, size), [columns, size]);
|
||||
|
||||
// 2. Call useDataTable
|
||||
const {
|
||||
@@ -210,9 +155,7 @@ export function Table<TData>(props: DataTableProps<TData>) {
|
||||
selectedRowIds,
|
||||
clearSelection,
|
||||
toggleAllPageRowsSelected,
|
||||
toggleAllRowsSelected,
|
||||
isAllPageRowsSelected,
|
||||
isAllRowsSelected,
|
||||
isViewingSelected,
|
||||
enterViewMode,
|
||||
exitViewMode,
|
||||
@@ -250,6 +193,16 @@ export function Table<TData>(props: DataTableProps<TData>) {
|
||||
);
|
||||
}
|
||||
}, [!!serverSide, !!draggable]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
const footerShowView =
|
||||
footer?.mode === "selection" ? footer.showView : undefined;
|
||||
useEffect(() => {
|
||||
if (process.env.NODE_ENV !== "production" && serverSide && footerShowView) {
|
||||
console.warn(
|
||||
"DataTable: `showView` is ignored when `serverSide` is enabled. " +
|
||||
"View mode requires client-side filtering."
|
||||
);
|
||||
}
|
||||
}, [!!serverSide, !!footerShowView]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
const effectiveDraggable = serverSide ? undefined : draggable;
|
||||
const draggableReturn = useDraggableRows({
|
||||
data,
|
||||
@@ -259,11 +212,10 @@ export function Table<TData>(props: DataTableProps<TData>) {
|
||||
});
|
||||
|
||||
const hasDraggable = !!effectiveDraggable;
|
||||
const rowVariant = hasDraggable ? "table" : "list";
|
||||
|
||||
const isSelectable = selectionBehavior !== "no-select";
|
||||
const isMultiSelect = selectionBehavior === "multi-select";
|
||||
// Checkboxes appear for any selectable table
|
||||
const showQualifierCheckbox = isSelectable;
|
||||
const isSelectable =
|
||||
qualifierColumn != null && qualifierColumn.selectable !== false;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render
|
||||
@@ -272,13 +224,11 @@ export function Table<TData>(props: DataTableProps<TData>) {
|
||||
const isServerLoading = !!serverSide?.isLoading;
|
||||
|
||||
function renderFooter(footerConfig: DataTableFooterConfig) {
|
||||
// Mode derived from selectionBehavior — single/multi-select use selection
|
||||
// footer, no-select uses summary footer.
|
||||
if (isSelectable) {
|
||||
if (footerConfig.mode === "selection") {
|
||||
return (
|
||||
<Footer
|
||||
mode="selection"
|
||||
multiSelect={isMultiSelect}
|
||||
multiSelect={footerConfig.multiSelect !== false}
|
||||
selectionState={selectionState}
|
||||
selectedCount={selectedCount}
|
||||
onClear={
|
||||
@@ -289,24 +239,22 @@ export function Table<TData>(props: DataTableProps<TData>) {
|
||||
})
|
||||
}
|
||||
onView={
|
||||
!serverSide
|
||||
footerConfig.showView
|
||||
? isViewingSelected
|
||||
? exitViewMode
|
||||
: enterViewMode
|
||||
: undefined
|
||||
}
|
||||
isViewingSelected={isViewingSelected}
|
||||
pageSize={resolvedPageSize}
|
||||
totalItems={totalItems}
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
units={footerConfig.units}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Summary mode (no-select only)
|
||||
// Summary mode
|
||||
const rangeStart =
|
||||
totalItems === 0
|
||||
? 0
|
||||
@@ -327,7 +275,6 @@ export function Table<TData>(props: DataTableProps<TData>) {
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
leftExtra={footerConfig.leftExtra}
|
||||
units={footerConfig.units}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -356,10 +303,7 @@ export function Table<TData>(props: DataTableProps<TData>) {
|
||||
: undefined),
|
||||
}}
|
||||
>
|
||||
<TableElement
|
||||
size={size}
|
||||
variant={variant}
|
||||
selectionBehavior={selectionBehavior}
|
||||
<Table
|
||||
width={
|
||||
Object.keys(columnWidths).length > 0
|
||||
? Object.values(columnWidths).reduce((sum, w) => sum + w, 0)
|
||||
@@ -367,7 +311,7 @@ export function Table<TData>(props: DataTableProps<TData>) {
|
||||
}
|
||||
>
|
||||
<colgroup>
|
||||
{table.getVisibleLeafColumns().map((col) => (
|
||||
{table.getAllLeafColumns().map((col) => (
|
||||
<col
|
||||
key={col.id}
|
||||
style={
|
||||
@@ -384,26 +328,28 @@ export function Table<TData>(props: DataTableProps<TData>) {
|
||||
{headerGroup.headers.map((header, headerIndex) => {
|
||||
const colDef = columnKindMap.get(header.id);
|
||||
|
||||
// Qualifier header — select-all checkbox only for multi-select
|
||||
// Qualifier header
|
||||
if (colDef?.kind === "qualifier") {
|
||||
if (qualifierColumn?.header === false) {
|
||||
return (
|
||||
<QualifierContainer key={header.id} type="head" />
|
||||
);
|
||||
}
|
||||
return (
|
||||
<QualifierContainer key={header.id} type="head">
|
||||
{isMultiSelect && (
|
||||
<Checkbox
|
||||
checked={isAllRowsSelected}
|
||||
indeterminate={
|
||||
!isAllRowsSelected && selectedCount > 0
|
||||
}
|
||||
onCheckedChange={(checked) => {
|
||||
// Indeterminate → clear all; otherwise toggle normally
|
||||
if (!isAllRowsSelected && selectedCount > 0) {
|
||||
toggleAllRowsSelected(false);
|
||||
} else {
|
||||
toggleAllRowsSelected(checked);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<TableQualifier
|
||||
content={
|
||||
qualifierColumn?.headerContentType ?? "simple"
|
||||
}
|
||||
selectable={isSelectable}
|
||||
selected={isSelectable && isAllPageRowsSelected}
|
||||
onSelectChange={
|
||||
isSelectable
|
||||
? (checked) =>
|
||||
toggleAllPageRowsSelected(checked)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</QualifierContainer>
|
||||
);
|
||||
}
|
||||
@@ -491,6 +437,7 @@ export function Table<TData>(props: DataTableProps<TData>) {
|
||||
return (
|
||||
<DragOverlayRow
|
||||
row={row}
|
||||
variant={rowVariant}
|
||||
columnWidths={columnWidths}
|
||||
columnKindMap={columnKindMap}
|
||||
qualifierColumn={qualifierColumn}
|
||||
@@ -514,6 +461,7 @@ export function Table<TData>(props: DataTableProps<TData>) {
|
||||
return (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
variant={rowVariant}
|
||||
sortableId={rowId}
|
||||
selected={row.getIsSelected()}
|
||||
onClick={() => {
|
||||
@@ -526,10 +474,6 @@ export function Table<TData>(props: DataTableProps<TData>) {
|
||||
if (onRowClick) {
|
||||
onRowClick(row.original);
|
||||
} else if (isSelectable) {
|
||||
if (!isMultiSelect) {
|
||||
// single-select: clear all, then select this row
|
||||
table.toggleAllRowsSelected(false);
|
||||
}
|
||||
row.toggleSelected();
|
||||
}
|
||||
}}
|
||||
@@ -540,13 +484,6 @@ export function Table<TData>(props: DataTableProps<TData>) {
|
||||
// Qualifier cell
|
||||
if (cellColDef?.kind === "qualifier") {
|
||||
const qDef = cellColDef as OnyxQualifierColumn<TData>;
|
||||
|
||||
// Resolve content based on the qualifier prop:
|
||||
// - "simple" renders nothing (checkbox only when selectable)
|
||||
// - "avatar"/"icon" render from column config
|
||||
const qualifierContent =
|
||||
qualifier === "simple" ? "simple" : qDef.content;
|
||||
|
||||
return (
|
||||
<QualifierContainer
|
||||
key={cell.id}
|
||||
@@ -554,20 +491,15 @@ export function Table<TData>(props: DataTableProps<TData>) {
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<TableQualifier
|
||||
content={qualifierContent}
|
||||
content={qDef.content}
|
||||
initials={qDef.getInitials?.(row.original)}
|
||||
icon={qDef.getIcon?.(row.original)}
|
||||
imageSrc={qDef.getImageSrc?.(row.original)}
|
||||
selectable={showQualifierCheckbox}
|
||||
selected={
|
||||
showQualifierCheckbox && row.getIsSelected()
|
||||
}
|
||||
selectable={isSelectable}
|
||||
selected={isSelectable && row.getIsSelected()}
|
||||
onSelectChange={
|
||||
showQualifierCheckbox
|
||||
isSelectable
|
||||
? (checked) => {
|
||||
if (!isMultiSelect) {
|
||||
table.toggleAllRowsSelected(false);
|
||||
}
|
||||
row.toggleSelected(checked);
|
||||
}
|
||||
: undefined
|
||||
@@ -607,7 +539,7 @@ export function Table<TData>(props: DataTableProps<TData>) {
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</TableElement>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{footer && renderFooter(footer)}
|
||||
@@ -1,17 +1,18 @@
|
||||
import { memo } from "react";
|
||||
import { type Row, flexRender } from "@tanstack/react-table";
|
||||
import TableRow from "@opal/components/table/TableRow";
|
||||
import TableCell from "@opal/components/table/TableCell";
|
||||
import QualifierContainer from "@opal/components/table/QualifierContainer";
|
||||
import TableQualifier from "@opal/components/table/TableQualifier";
|
||||
import ActionsContainer from "@opal/components/table/ActionsContainer";
|
||||
import TableRow from "@/refresh-components/table/TableRow";
|
||||
import TableCell from "@/refresh-components/table/TableCell";
|
||||
import QualifierContainer from "@/refresh-components/table/QualifierContainer";
|
||||
import TableQualifier from "@/refresh-components/table/TableQualifier";
|
||||
import ActionsContainer from "@/refresh-components/table/ActionsContainer";
|
||||
import type {
|
||||
OnyxColumnDef,
|
||||
OnyxQualifierColumn,
|
||||
} from "@opal/components/table/types";
|
||||
} from "@/refresh-components/table/types";
|
||||
|
||||
interface DragOverlayRowProps<TData> {
|
||||
row: Row<TData>;
|
||||
variant?: "table" | "list";
|
||||
columnWidths?: Record<string, number>;
|
||||
columnKindMap?: Map<string, OnyxColumnDef<TData>>;
|
||||
qualifierColumn?: OnyxQualifierColumn<TData> | null;
|
||||
@@ -20,6 +21,7 @@ interface DragOverlayRowProps<TData> {
|
||||
|
||||
function DragOverlayRowInner<TData>({
|
||||
row,
|
||||
variant,
|
||||
columnWidths,
|
||||
columnKindMap,
|
||||
qualifierColumn,
|
||||
@@ -48,7 +50,7 @@ function DragOverlayRowInner<TData>({
|
||||
</colgroup>
|
||||
)}
|
||||
<tbody>
|
||||
<TableRow selected={row.getIsSelected()}>
|
||||
<TableRow variant={variant} selected={row.getIsSelected()}>
|
||||
{row.getVisibleCells().map((cell) => {
|
||||
const colDef = columnKindMap?.get(cell.column.id);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@opal/utils";
|
||||
import { Button, Pagination, SelectButton } from "@opal/components";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button, Pagination } from "@opal/components";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import { useTableSize } from "@opal/components/table/TableSizeContext";
|
||||
import type { TableSize } from "@opal/components/table/TableSizeContext";
|
||||
import { useTableSize } from "@/refresh-components/table/TableSizeContext";
|
||||
import type { TableSize } from "@/refresh-components/table/TableSizeContext";
|
||||
import { SvgEye, SvgXCircle } from "@opal/icons";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
@@ -27,11 +27,9 @@ interface FooterSelectionModeProps {
|
||||
selectionState: SelectionState;
|
||||
/** Number of currently selected items. */
|
||||
selectedCount: number;
|
||||
/** Toggle view-filter on/off. */
|
||||
/** If provided, renders a "View" icon button when items are selected. */
|
||||
onView?: () => void;
|
||||
/** Whether the view-filter is currently active. */
|
||||
isViewingSelected?: boolean;
|
||||
/** Clears all selections. */
|
||||
/** If provided, renders a "Clear" icon button when items are selected. */
|
||||
onClear?: () => void;
|
||||
/** Number of items displayed per page. */
|
||||
pageSize: number;
|
||||
@@ -43,9 +41,7 @@ interface FooterSelectionModeProps {
|
||||
totalPages: number;
|
||||
/** Called when the user navigates to a different page. */
|
||||
onPageChange: (page: number) => void;
|
||||
/** Unit label for count pagination. @default "items" */
|
||||
units?: string;
|
||||
/** Controls overall footer sizing. `"lg"` (default) or `"md"`. */
|
||||
/** Controls overall footer sizing. `"regular"` (default) or `"small"`. */
|
||||
size?: TableSize;
|
||||
className?: string;
|
||||
}
|
||||
@@ -71,8 +67,6 @@ interface FooterSummaryModeProps {
|
||||
onPageChange: (page: number) => void;
|
||||
/** Optional extra element rendered after the summary text (e.g. a download icon). */
|
||||
leftExtra?: ReactNode;
|
||||
/** Unit label for the summary text, e.g. "users". */
|
||||
units?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -90,10 +84,9 @@ export type FooterProps = FooterSelectionModeProps | FooterSummaryModeProps;
|
||||
function getSelectionMessage(
|
||||
state: SelectionState,
|
||||
multi: boolean,
|
||||
count: number,
|
||||
isViewingSelected: boolean
|
||||
count: number
|
||||
): string {
|
||||
if (state === "none" && !isViewingSelected) {
|
||||
if (state === "none") {
|
||||
return multi ? "Select items to continue" : "Select an item to continue";
|
||||
}
|
||||
if (!multi) return "Item selected";
|
||||
@@ -107,7 +100,7 @@ function getSelectionMessage(
|
||||
*/
|
||||
export default function Footer(props: FooterProps) {
|
||||
const resolvedSize = useTableSize();
|
||||
const isSmall = resolvedSize === "md";
|
||||
const isSmall = resolvedSize === "small";
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -125,7 +118,6 @@ export default function Footer(props: FooterProps) {
|
||||
multiSelect={props.multiSelect}
|
||||
selectedCount={props.selectedCount}
|
||||
onView={props.onView}
|
||||
isViewingSelected={props.isViewingSelected}
|
||||
onClear={props.onClear}
|
||||
isSmall={isSmall}
|
||||
/>
|
||||
@@ -135,7 +127,6 @@ export default function Footer(props: FooterProps) {
|
||||
rangeStart={props.rangeStart}
|
||||
rangeEnd={props.rangeEnd}
|
||||
totalItems={props.totalItems}
|
||||
units={props.units}
|
||||
isSmall={isSmall}
|
||||
/>
|
||||
{props.leftExtra}
|
||||
@@ -153,7 +144,7 @@ export default function Footer(props: FooterProps) {
|
||||
currentPage={props.currentPage}
|
||||
totalPages={props.totalPages}
|
||||
onChange={props.onPageChange}
|
||||
units={props.units}
|
||||
units="items"
|
||||
size={isSmall ? "sm" : "md"}
|
||||
/>
|
||||
) : (
|
||||
@@ -178,7 +169,6 @@ interface SelectionLeftProps {
|
||||
multiSelect: boolean;
|
||||
selectedCount: number;
|
||||
onView?: () => void;
|
||||
isViewingSelected?: boolean;
|
||||
onClear?: () => void;
|
||||
isSmall: boolean;
|
||||
}
|
||||
@@ -188,19 +178,15 @@ function SelectionLeft({
|
||||
multiSelect,
|
||||
selectedCount,
|
||||
onView,
|
||||
isViewingSelected = false,
|
||||
onClear,
|
||||
isSmall,
|
||||
}: SelectionLeftProps) {
|
||||
const message = getSelectionMessage(
|
||||
selectionState,
|
||||
multiSelect,
|
||||
selectedCount,
|
||||
isViewingSelected
|
||||
selectedCount
|
||||
);
|
||||
const hasSelection = selectionState !== "none";
|
||||
// Show buttons when items are selected OR when the view filter is active
|
||||
const showActions = hasSelection || isViewingSelected;
|
||||
|
||||
return (
|
||||
<div className="flex flex-row gap-1 items-center justify-center w-fit flex-shrink-0 h-fit px-1">
|
||||
@@ -218,22 +204,22 @@ function SelectionLeft({
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{showActions && (
|
||||
{hasSelection && (
|
||||
<div className="flex flex-row items-center w-fit flex-shrink-0 h-fit">
|
||||
{onView && (
|
||||
<SelectButton
|
||||
<Button
|
||||
icon={SvgEye}
|
||||
state={isViewingSelected ? "selected" : "empty"}
|
||||
onClick={onView}
|
||||
tooltip="View selected"
|
||||
tooltip="View"
|
||||
size={isSmall ? "sm" : "md"}
|
||||
prominence="tertiary"
|
||||
/>
|
||||
)}
|
||||
{onClear && (
|
||||
<Button
|
||||
icon={SvgXCircle}
|
||||
onClick={onClear}
|
||||
tooltip="Deselect all"
|
||||
tooltip="Clear selection"
|
||||
size={isSmall ? "sm" : "md"}
|
||||
prominence="tertiary"
|
||||
/>
|
||||
@@ -248,7 +234,6 @@ interface SummaryLeftProps {
|
||||
rangeStart: number;
|
||||
rangeEnd: number;
|
||||
totalItems: number;
|
||||
units?: string;
|
||||
isSmall: boolean;
|
||||
}
|
||||
|
||||
@@ -256,10 +241,8 @@ function SummaryLeft({
|
||||
rangeStart,
|
||||
rangeEnd,
|
||||
totalItems,
|
||||
units,
|
||||
isSmall,
|
||||
}: SummaryLeftProps) {
|
||||
const suffix = units ? ` ${units}` : "";
|
||||
return (
|
||||
<div className="flex flex-row gap-1 items-center w-fit h-fit px-1">
|
||||
{isSmall ? (
|
||||
@@ -272,7 +255,6 @@ function SummaryLeft({
|
||||
<Text as="span" secondaryMono text03>
|
||||
{totalItems}
|
||||
</Text>
|
||||
{suffix}
|
||||
</Text>
|
||||
) : (
|
||||
<Text mainUiMuted text03>
|
||||
@@ -284,7 +266,6 @@ function SummaryLeft({
|
||||
<Text as="span" mainUiMono text03>
|
||||
{totalItems}
|
||||
</Text>
|
||||
{suffix}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useTableSize } from "@opal/components/table/TableSizeContext";
|
||||
import type { TableSize } from "@opal/components/table/TableSizeContext";
|
||||
import { useTableSize } from "@/refresh-components/table/TableSizeContext";
|
||||
import type { TableSize } from "@/refresh-components/table/TableSizeContext";
|
||||
|
||||
interface QualifierContainerProps {
|
||||
type: "head" | "cell";
|
||||
462
web/src/refresh-components/table/README.md
Normal file
462
web/src/refresh-components/table/README.md
Normal file
@@ -0,0 +1,462 @@
|
||||
# DataTable
|
||||
|
||||
Config-driven table built on [TanStack Table](https://tanstack.com/table). Handles column sizing (weight-based proportional distribution), drag-and-drop row reordering, pagination, row selection, column visibility, and sorting out of the box.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```tsx
|
||||
import DataTable from "@/refresh-components/table/DataTable";
|
||||
import { createTableColumns } from "@/refresh-components/table/columns";
|
||||
|
||||
interface Person {
|
||||
name: string;
|
||||
email: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
// Define columns at module scope (stable reference, no re-renders)
|
||||
const tc = createTableColumns<Person>();
|
||||
const columns = [
|
||||
tc.qualifier(),
|
||||
tc.column("name", { header: "Name", weight: 30, minWidth: 120 }),
|
||||
tc.column("email", { header: "Email", weight: 40, minWidth: 150 }),
|
||||
tc.column("role", { header: "Role", weight: 30, minWidth: 80 }),
|
||||
tc.actions(),
|
||||
];
|
||||
|
||||
function PeopleTable({ data }: { data: Person[] }) {
|
||||
return (
|
||||
<DataTable
|
||||
data={data}
|
||||
columns={columns}
|
||||
pageSize={10}
|
||||
footer={{ mode: "selection" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Column Builder API
|
||||
|
||||
`createTableColumns<TData>()` returns a typed builder with four methods. Each returns an `OnyxColumnDef<TData>` that you pass to the `columns` prop.
|
||||
|
||||
### `tc.qualifier(config?)`
|
||||
|
||||
Leading column for avatars, icons, images, or checkboxes.
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
| ------------------- | ----------------------------------------------------------------- | ---------- | --------------------------------------------- |
|
||||
| `content` | `"simple" \| "icon" \| "image" \| "avatar-icon" \| "avatar-user"` | `"simple"` | Body row content type |
|
||||
| `headerContentType` | same as `content` | `"simple"` | Header row content type |
|
||||
| `getInitials` | `(row: TData) => string` | - | Extract initials (for `"avatar-user"`) |
|
||||
| `getIcon` | `(row: TData) => IconFunctionComponent` | - | Extract icon (for `"icon"` / `"avatar-icon"`) |
|
||||
| `getImageSrc` | `(row: TData) => string` | - | Extract image src (for `"image"`) |
|
||||
| `selectable` | `boolean` | `true` | Show selection checkboxes |
|
||||
| `header` | `boolean` | `true` | Render qualifier content in the header |
|
||||
|
||||
Width is fixed: 56px at `"regular"` size, 40px at `"small"`.
|
||||
|
||||
```ts
|
||||
tc.qualifier({
|
||||
content: "avatar-user",
|
||||
getInitials: (row) => row.initials,
|
||||
});
|
||||
```
|
||||
|
||||
### `tc.column(accessor, config)`
|
||||
|
||||
Data column with sorting, resizing, and hiding. The `accessor` is a type-safe deep key into `TData`.
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
| ---------------- | -------------------------------------------------- | ----------------------- | -------------------------------- |
|
||||
| `header` | `string` | **required** | Column header label |
|
||||
| `cell` | `(value: TValue, row: TData) => ReactNode` | renders value as string | Custom cell renderer |
|
||||
| `enableSorting` | `boolean` | `true` | Allow sorting |
|
||||
| `enableResizing` | `boolean` | `true` | Allow column resize |
|
||||
| `enableHiding` | `boolean` | `true` | Allow hiding via actions popover |
|
||||
| `icon` | `(sorted: SortDirection) => IconFunctionComponent` | - | Override the sort indicator icon |
|
||||
| `weight` | `number` | `20` | Proportional width weight |
|
||||
| `minWidth` | `number` | `50` | Minimum width in pixels |
|
||||
|
||||
```ts
|
||||
tc.column("email", {
|
||||
header: "Email",
|
||||
weight: 28,
|
||||
minWidth: 150,
|
||||
cell: (value) => <Content sizePreset="main-ui" variant="body" title={value} prominence="muted" />,
|
||||
})
|
||||
```
|
||||
|
||||
### `tc.displayColumn(config)`
|
||||
|
||||
Non-accessor column for custom content (e.g. computed values, action buttons per row).
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
| -------------- | --------------------------- | ------------ | -------------------------------------- |
|
||||
| `id` | `string` | **required** | Unique column ID |
|
||||
| `header` | `string` | - | Optional header label |
|
||||
| `cell` | `(row: TData) => ReactNode` | **required** | Cell renderer |
|
||||
| `width` | `ColumnWidth` | **required** | `{ weight, minWidth? }` or `{ fixed }` |
|
||||
| `enableHiding` | `boolean` | `true` | Allow hiding |
|
||||
|
||||
```ts
|
||||
tc.displayColumn({
|
||||
id: "fullName",
|
||||
header: "Full Name",
|
||||
cell: (row) => `${row.firstName} ${row.lastName}`,
|
||||
width: { weight: 25, minWidth: 100 },
|
||||
});
|
||||
```
|
||||
|
||||
### `tc.actions(config?)`
|
||||
|
||||
Fixed-width column rendered at the trailing edge. Houses column visibility and sorting popovers in the header.
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
| ---------------------- | --------------------------- | ------- | ------------------------------------------ |
|
||||
| `showColumnVisibility` | `boolean` | `true` | Show the column visibility popover |
|
||||
| `showSorting` | `boolean` | `true` | Show the sorting popover |
|
||||
| `sortingFooterText` | `string` | - | Footer text inside the sorting popover |
|
||||
| `cell` | `(row: TData) => ReactNode` | - | Row-level cell renderer for action buttons |
|
||||
|
||||
Width is fixed: 88px at `"regular"`, 20px at `"small"`.
|
||||
|
||||
```ts
|
||||
tc.actions({
|
||||
sortingFooterText: "Everyone will see agents in this order.",
|
||||
});
|
||||
```
|
||||
|
||||
Row-level actions — the `cell` callback receives the row data and renders content in each body row. Clicks inside the cell automatically call `stopPropagation`, so they won't trigger row selection.
|
||||
|
||||
```tsx
|
||||
tc.actions({
|
||||
cell: (row) => (
|
||||
<div className="flex gap-x-1">
|
||||
<IconButton icon={SvgPencil} onClick={() => openEdit(row.id)} />
|
||||
<IconButton icon={SvgTrash} onClick={() => confirmDelete(row.id)} />
|
||||
</div>
|
||||
),
|
||||
});
|
||||
```
|
||||
|
||||
## DataTable Props
|
||||
|
||||
`DataTableProps<TData>`:
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ------------------------- | --------------------------------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
|
||||
| `data` | `TData[]` | **required** | Row data |
|
||||
| `columns` | `OnyxColumnDef<TData>[]` | **required** | Columns from `createTableColumns()` |
|
||||
| `pageSize` | `number` | `10` (with footer) or `data.length` (without) | Rows per page. `Infinity` disables pagination |
|
||||
| `initialSorting` | `SortingState` | `[]` | TanStack sorting state |
|
||||
| `initialColumnVisibility` | `VisibilityState` | `{}` | Map of column ID to `false` to hide initially |
|
||||
| `draggable` | `DataTableDraggableConfig<TData>` | - | Enable drag-and-drop (see below) |
|
||||
| `footer` | `DataTableFooterConfig` | - | Footer mode (see below) |
|
||||
| `size` | `"regular" \| "small"` | `"regular"` | Table density variant |
|
||||
| `onRowClick` | `(row: TData) => void` | toggles selection | Called on row click, replaces default selection toggle |
|
||||
| `height` | `number \| string` | - | Max height for scrollable body (header stays pinned). `300` or `"50vh"` |
|
||||
| `headerBackground` | `string` | - | CSS color for the sticky header (prevents content showing through) |
|
||||
| `searchTerm` | `string` | - | Search term for client-side global text filtering (case-insensitive match across all accessor columns) |
|
||||
| `serverSide` | `ServerSideConfig` | - | Enable server-side mode for manual pagination, sorting, and filtering ([see below](#server-side-mode)) |
|
||||
|
||||
## Footer Config
|
||||
|
||||
The `footer` prop accepts a discriminated union on `mode`.
|
||||
|
||||
### Selection mode
|
||||
|
||||
For tables with selectable rows. Shows a selection message + count pagination.
|
||||
|
||||
```ts
|
||||
footer={{
|
||||
mode: "selection",
|
||||
multiSelect: true, // default true
|
||||
onView: () => { ... }, // optional "View" button
|
||||
onClear: () => { ... }, // optional "Clear" button (falls back to default clearSelection)
|
||||
}}
|
||||
```
|
||||
|
||||
### Summary mode
|
||||
|
||||
For read-only tables. Shows "Showing X~Y of Z" + list pagination.
|
||||
|
||||
```ts
|
||||
footer={{ mode: "summary" }}
|
||||
```
|
||||
|
||||
## Draggable Config
|
||||
|
||||
Enable drag-and-drop row reordering. DnD is automatically disabled when column sorting is active.
|
||||
|
||||
```ts
|
||||
<DataTable
|
||||
data={items}
|
||||
columns={columns}
|
||||
draggable={{
|
||||
getRowId: (row) => row.id,
|
||||
onReorder: (ids, changedOrders) => {
|
||||
// ids: new ordered array of all row IDs
|
||||
// changedOrders: { [id]: newIndex } for rows that moved
|
||||
setItems(ids.map((id) => items.find((r) => r.id === id)!));
|
||||
},
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
| Option | Type | Description |
|
||||
| ----------- | --------------------------------------------------------------------------------- | ---------------------------------------- |
|
||||
| `getRowId` | `(row: TData) => string` | Extract a unique string ID from each row |
|
||||
| `onReorder` | `(ids: string[], changedOrders: Record<string, number>) => void \| Promise<void>` | Called after a successful reorder |
|
||||
|
||||
## Server-Side Mode
|
||||
|
||||
Pass the `serverSide` prop to switch from client-side to server-side pagination, sorting, and filtering. In this mode `data` should contain **only the current page slice** — TanStack operates with `manualPagination`, `manualSorting`, and `manualFiltering` enabled. Drag-and-drop is automatically disabled.
|
||||
|
||||
### `ServerSideConfig`
|
||||
|
||||
| Prop | Type | Description |
|
||||
| -------------------- | ----------------------------------------------- | ----------------------------------------------------------------------------------- |
|
||||
| `totalItems` | `number` | Total row count from the server, used to compute page count |
|
||||
| `isLoading` | `boolean` | Shows a loading overlay (opacity + pointer-events-none) while data is being fetched |
|
||||
| `onSortingChange` | `(sorting: SortingState) => void` | Fired when the user clicks a column header |
|
||||
| `onPaginationChange` | `(pageIndex: number, pageSize: number) => void` | Fired on page navigation and on automatic resets from sort/search changes |
|
||||
| `onSearchTermChange` | `(searchTerm: string) => void` | Fired when the `searchTerm` prop changes |
|
||||
|
||||
### Callback contract
|
||||
|
||||
The callbacks fire in a predictable order:
|
||||
|
||||
- **Sort change** — `onSortingChange` fires first, then the page resets to 0 and `onPaginationChange(0, pageSize)` fires.
|
||||
- **Page navigation** — only `onPaginationChange` fires.
|
||||
- **Search change** — `onSearchTermChange` fires, and the page resets to 0. `onPaginationChange` only fires if the page was actually on a non-zero page. When already on page 0, `searchTerm` drives the re-fetch independently (e.g. via your SWR key) — no `onPaginationChange` is needed.
|
||||
|
||||
Your data-fetching layer should include `searchTerm` in its fetch dependencies (e.g. SWR key) so that search changes trigger re-fetches regardless of pagination state.
|
||||
|
||||
### Full example
|
||||
|
||||
```tsx
|
||||
import { useState } from "react";
|
||||
import useSWR from "swr";
|
||||
import type { SortingState } from "@tanstack/react-table";
|
||||
import DataTable from "@/refresh-components/table/DataTable";
|
||||
import { createTableColumns } from "@/refresh-components/table/columns";
|
||||
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
const tc = createTableColumns<User>();
|
||||
const columns = [
|
||||
tc.qualifier(),
|
||||
tc.column("name", { header: "Name", weight: 40, minWidth: 120 }),
|
||||
tc.column("email", { header: "Email", weight: 60, minWidth: 150 }),
|
||||
tc.actions(),
|
||||
];
|
||||
|
||||
function UsersTable() {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [pageIndex, setPageIndex] = useState(0);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
|
||||
const { data: response, isLoading } = useSWR(
|
||||
["/api/users", sorting, pageIndex, pageSize, searchTerm],
|
||||
([url, sorting, pageIndex, pageSize, searchTerm]) =>
|
||||
fetch(
|
||||
`${url}?` +
|
||||
new URLSearchParams({
|
||||
page: String(pageIndex),
|
||||
size: String(pageSize),
|
||||
search: searchTerm,
|
||||
...(sorting[0] && {
|
||||
sortBy: sorting[0].id,
|
||||
sortDir: sorting[0].desc ? "desc" : "asc",
|
||||
}),
|
||||
})
|
||||
).then((r) => r.json())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<InputTypeIn
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder="Search users..."
|
||||
/>
|
||||
<DataTable
|
||||
data={response?.items ?? []}
|
||||
columns={columns}
|
||||
getRowId={(row) => row.id}
|
||||
searchTerm={searchTerm}
|
||||
pageSize={pageSize}
|
||||
footer={{ mode: "summary" }}
|
||||
serverSide={{
|
||||
totalItems: response?.total ?? 0,
|
||||
isLoading,
|
||||
onSortingChange: setSorting,
|
||||
onPaginationChange: (idx, size) => {
|
||||
setPageIndex(idx);
|
||||
setPageSize(size);
|
||||
},
|
||||
onSearchTermChange: () => {
|
||||
// search state is already managed above via searchTerm prop;
|
||||
// this callback is useful for analytics or debouncing
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Sizing
|
||||
|
||||
The `size` prop (`"regular"` or `"small"`) affects:
|
||||
|
||||
- Qualifier column width (56px vs 40px)
|
||||
- Actions column width (88px vs 20px)
|
||||
- Footer text styles and pagination size
|
||||
- All child components via `TableSizeContext`
|
||||
|
||||
Column widths can be responsive to size using a function:
|
||||
|
||||
```ts
|
||||
// In types.ts, width accepts:
|
||||
width: ColumnWidth | ((size: TableSize) => ColumnWidth);
|
||||
|
||||
// Example (this is what qualifier/actions use internally):
|
||||
width: (size) => (size === "small" ? { fixed: 40 } : { fixed: 56 });
|
||||
```
|
||||
|
||||
### Width system
|
||||
|
||||
Data columns use **weight-based proportional distribution**. A column with `weight: 40` gets twice the space of one with `weight: 20`. When the container is narrower than the sum of `minWidth` values, columns clamp to their minimums.
|
||||
|
||||
Fixed columns (`{ fixed: N }`) take exactly N pixels and don't participate in proportional distribution.
|
||||
|
||||
Resizing uses **splitter semantics**: dragging a column border grows that column and shrinks its neighbor by the same amount, keeping total width constant.
|
||||
|
||||
## Advanced Examples
|
||||
|
||||
### Scrollable table with pinned header
|
||||
|
||||
```tsx
|
||||
<DataTable
|
||||
data={allRows}
|
||||
columns={columns}
|
||||
height={300}
|
||||
headerBackground="var(--background-tint-00)"
|
||||
/>
|
||||
```
|
||||
|
||||
### Hidden columns on load
|
||||
|
||||
```tsx
|
||||
<DataTable
|
||||
data={data}
|
||||
columns={columns}
|
||||
initialColumnVisibility={{ department: false, joinDate: false }}
|
||||
footer={{ mode: "selection" }}
|
||||
/>
|
||||
```
|
||||
|
||||
### Icon-based data column
|
||||
|
||||
```tsx
|
||||
const STATUS_ICONS = {
|
||||
active: SvgCheckCircle,
|
||||
pending: SvgClock,
|
||||
inactive: SvgAlertCircle,
|
||||
} as const;
|
||||
|
||||
tc.column("status", {
|
||||
header: "Status",
|
||||
weight: 14,
|
||||
minWidth: 80,
|
||||
cell: (value) => (
|
||||
<Content
|
||||
sizePreset="main-ui"
|
||||
variant="body"
|
||||
icon={STATUS_ICONS[value]}
|
||||
title={value.charAt(0).toUpperCase() + value.slice(1)}
|
||||
/>
|
||||
),
|
||||
});
|
||||
```
|
||||
|
||||
### Non-selectable qualifier with icons
|
||||
|
||||
```ts
|
||||
tc.qualifier({
|
||||
content: "icon",
|
||||
getIcon: (row) => row.icon,
|
||||
selectable: false,
|
||||
header: false,
|
||||
});
|
||||
```
|
||||
|
||||
### Small variant in a bordered container
|
||||
|
||||
```tsx
|
||||
<div className="border border-border-01 rounded-lg overflow-hidden">
|
||||
<DataTable
|
||||
data={data}
|
||||
columns={columns}
|
||||
size="small"
|
||||
pageSize={10}
|
||||
footer={{ mode: "selection" }}
|
||||
/>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Server-side pagination
|
||||
|
||||
Minimal wiring for server-side mode — manage sorting/pagination state externally and pass the current page slice as `data`.
|
||||
|
||||
```tsx
|
||||
<DataTable
|
||||
data={currentPageRows}
|
||||
columns={columns}
|
||||
getRowId={(row) => row.id}
|
||||
searchTerm={searchTerm}
|
||||
pageSize={pageSize}
|
||||
footer={{ mode: "summary" }}
|
||||
serverSide={{
|
||||
totalItems: totalCount,
|
||||
isLoading,
|
||||
onSortingChange: setSorting,
|
||||
onPaginationChange: (idx, size) => {
|
||||
setPageIndex(idx);
|
||||
setPageSize(size);
|
||||
},
|
||||
onSearchTermChange: (term) => setSearchTerm(term),
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
### Custom row click handler
|
||||
|
||||
```tsx
|
||||
<DataTable
|
||||
data={data}
|
||||
columns={columns}
|
||||
onRowClick={(row) => router.push(`/users/${row.id}`)}
|
||||
/>
|
||||
```
|
||||
|
||||
## Source Files
|
||||
|
||||
| File | Purpose |
|
||||
| --------------------------- | -------------------------------- |
|
||||
| `DataTable.tsx` | Main component |
|
||||
| `columns.ts` | `createTableColumns` builder |
|
||||
| `types.ts` | All TypeScript interfaces |
|
||||
| `hooks/useDataTable.ts` | TanStack table wrapper hook |
|
||||
| `hooks/useColumnWidths.ts` | Weight-based width system |
|
||||
| `hooks/useDraggableRows.ts` | DnD hook (`@dnd-kit`) |
|
||||
| `Footer.tsx` | Selection / Summary footer modes |
|
||||
| `TableSizeContext.tsx` | Size context provider |
|
||||
@@ -7,10 +7,11 @@ import {
|
||||
type RowData,
|
||||
type SortingState,
|
||||
} from "@tanstack/react-table";
|
||||
import { Button, LineItemButton } from "@opal/components";
|
||||
import { Button } from "@opal/components";
|
||||
import { SvgArrowUpDown, SvgSortOrder, SvgCheck } from "@opal/icons";
|
||||
import Popover from "@/refresh-components/Popover";
|
||||
import Divider from "@/refresh-components/Divider";
|
||||
import LineItem from "@/refresh-components/buttons/LineItem";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -20,7 +21,7 @@ import Text from "@/refresh-components/texts/Text";
|
||||
interface SortingPopoverProps<TData extends RowData = RowData> {
|
||||
table: Table<TData>;
|
||||
sorting: SortingState;
|
||||
size?: "md" | "lg";
|
||||
size?: "regular" | "small";
|
||||
footerText?: string;
|
||||
ascendingLabel?: string;
|
||||
descendingLabel?: string;
|
||||
@@ -29,7 +30,7 @@ interface SortingPopoverProps<TData extends RowData = RowData> {
|
||||
function SortingPopover<TData extends RowData>({
|
||||
table,
|
||||
sorting,
|
||||
size = "lg",
|
||||
size = "regular",
|
||||
footerText,
|
||||
ascendingLabel = "Ascending",
|
||||
descendingLabel = "Descending",
|
||||
@@ -47,8 +48,8 @@ function SortingPopover<TData extends RowData>({
|
||||
<Button
|
||||
icon={currentSort === null ? SvgArrowUpDown : SvgSortOrder}
|
||||
interaction={open ? "hover" : "rest"}
|
||||
size={size === "md" ? "sm" : "md"}
|
||||
prominence="tertiary"
|
||||
size={size === "small" ? "sm" : "md"}
|
||||
prominence="internal"
|
||||
tooltip="Sort"
|
||||
/>
|
||||
</Popover.Trigger>
|
||||
@@ -67,20 +68,18 @@ function SortingPopover<TData extends RowData>({
|
||||
>
|
||||
<Divider showTitle text="Sort by" />
|
||||
|
||||
<LineItemButton
|
||||
selectVariant="select-heavy"
|
||||
state={currentSort === null ? "selected" : "empty"}
|
||||
title="Manual Ordering"
|
||||
sizePreset="main-ui"
|
||||
<LineItem
|
||||
selected={currentSort === null}
|
||||
emphasized
|
||||
rightChildren={
|
||||
currentSort === null ? (
|
||||
<SvgCheck size={16} className="text-action-link-05" />
|
||||
) : undefined
|
||||
currentSort === null ? <SvgCheck size={16} /> : undefined
|
||||
}
|
||||
onClick={() => {
|
||||
table.resetSorting();
|
||||
}}
|
||||
/>
|
||||
>
|
||||
Manual Ordering
|
||||
</LineItem>
|
||||
|
||||
{sortableColumns.map((column) => {
|
||||
const isSorted = currentSort?.id === column.id;
|
||||
@@ -90,17 +89,11 @@ function SortingPopover<TData extends RowData>({
|
||||
: column.id;
|
||||
|
||||
return (
|
||||
<LineItemButton
|
||||
<LineItem
|
||||
key={column.id}
|
||||
selectVariant="select-heavy"
|
||||
state={isSorted ? "selected" : "empty"}
|
||||
title={label}
|
||||
sizePreset="main-ui"
|
||||
rightChildren={
|
||||
isSorted ? (
|
||||
<SvgCheck size={16} className="text-action-link-05" />
|
||||
) : undefined
|
||||
}
|
||||
selected={isSorted}
|
||||
emphasized
|
||||
rightChildren={isSorted ? <SvgCheck size={16} /> : undefined}
|
||||
onClick={() => {
|
||||
if (isSorted) {
|
||||
table.resetSorting();
|
||||
@@ -108,7 +101,9 @@ function SortingPopover<TData extends RowData>({
|
||||
}
|
||||
column.toggleSorting(false);
|
||||
}}
|
||||
/>
|
||||
>
|
||||
{label}
|
||||
</LineItem>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -116,35 +111,31 @@ function SortingPopover<TData extends RowData>({
|
||||
<>
|
||||
<Divider showTitle text="Sorting Order" />
|
||||
|
||||
<LineItemButton
|
||||
selectVariant="select-heavy"
|
||||
state={!currentSort.desc ? "selected" : "empty"}
|
||||
title={ascendingLabel}
|
||||
sizePreset="main-ui"
|
||||
<LineItem
|
||||
selected={!currentSort.desc}
|
||||
emphasized
|
||||
rightChildren={
|
||||
!currentSort.desc ? (
|
||||
<SvgCheck size={16} className="text-action-link-05" />
|
||||
) : undefined
|
||||
!currentSort.desc ? <SvgCheck size={16} /> : undefined
|
||||
}
|
||||
onClick={() => {
|
||||
table.setSorting([{ id: currentSort.id, desc: false }]);
|
||||
}}
|
||||
/>
|
||||
>
|
||||
{ascendingLabel}
|
||||
</LineItem>
|
||||
|
||||
<LineItemButton
|
||||
selectVariant="select-heavy"
|
||||
state={currentSort.desc ? "selected" : "empty"}
|
||||
title={descendingLabel}
|
||||
sizePreset="main-ui"
|
||||
<LineItem
|
||||
selected={currentSort.desc}
|
||||
emphasized
|
||||
rightChildren={
|
||||
currentSort.desc ? (
|
||||
<SvgCheck size={16} className="text-action-link-05" />
|
||||
) : undefined
|
||||
currentSort.desc ? <SvgCheck size={16} /> : undefined
|
||||
}
|
||||
onClick={() => {
|
||||
table.setSorting([{ id: currentSort.id, desc: true }]);
|
||||
}}
|
||||
/>
|
||||
>
|
||||
{descendingLabel}
|
||||
</LineItem>
|
||||
</>
|
||||
)}
|
||||
</Popover.Menu>
|
||||
@@ -158,7 +149,7 @@ function SortingPopover<TData extends RowData>({
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface CreateSortingColumnOptions {
|
||||
size?: "md" | "lg";
|
||||
size?: "regular" | "small";
|
||||
footerText?: string;
|
||||
ascendingLabel?: string;
|
||||
descendingLabel?: string;
|
||||
281
web/src/refresh-components/table/Table.stories.tsx
Normal file
281
web/src/refresh-components/table/Table.stories.tsx
Normal file
@@ -0,0 +1,281 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
import Table from "./Table";
|
||||
import TableHeader from "./TableHeader";
|
||||
import TableBody from "./TableBody";
|
||||
import TableRow from "./TableRow";
|
||||
import TableHead from "./TableHead";
|
||||
import TableCell from "./TableCell";
|
||||
import { TableSizeProvider } from "./TableSizeContext";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Meta
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const meta: Meta<typeof Table> = {
|
||||
title: "refresh-components/table/Table",
|
||||
component: Table,
|
||||
tags: ["autodocs"],
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<TooltipPrimitive.Provider>
|
||||
<TableSizeProvider size="regular">
|
||||
<div style={{ maxWidth: 800, padding: 16 }}>
|
||||
<Story />
|
||||
</div>
|
||||
</TableSizeProvider>
|
||||
</TooltipPrimitive.Provider>
|
||||
),
|
||||
],
|
||||
parameters: {
|
||||
layout: "padded",
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Table>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sample data
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const connectors = [
|
||||
{
|
||||
name: "Google Drive",
|
||||
type: "Cloud Storage",
|
||||
docs: 1_240,
|
||||
status: "Active",
|
||||
},
|
||||
{ name: "Confluence", type: "Wiki", docs: 856, status: "Active" },
|
||||
{ name: "Slack", type: "Messaging", docs: 3_102, status: "Syncing" },
|
||||
{ name: "Notion", type: "Wiki", docs: 412, status: "Paused" },
|
||||
{ name: "GitHub", type: "Code", docs: 2_890, status: "Active" },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stories
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** All primitive table components composed together (Table, TableHeader, TableBody, TableRow, TableHead, TableCell). */
|
||||
export const ComposedPrimitives: Story = {
|
||||
render: () => (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead width={200}>Connector</TableHead>
|
||||
<TableHead width={150}>Type</TableHead>
|
||||
<TableHead width={120} alignment="right">
|
||||
Documents
|
||||
</TableHead>
|
||||
<TableHead width={120}>Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{connectors.map((c) => (
|
||||
<TableRow key={c.name}>
|
||||
<TableCell>
|
||||
<Text mainUiBody>{c.name}</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Text mainUiMuted text03>
|
||||
{c.type}
|
||||
</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Text mainUiMono text03>
|
||||
{c.docs.toLocaleString()}
|
||||
</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Text mainUiBody>{c.status}</Text>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
),
|
||||
};
|
||||
|
||||
/** Table rows with the "table" variant (bottom border instead of rounded corners). */
|
||||
export const TableVariantRows: Story = {
|
||||
render: () => (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead width={200}>Connector</TableHead>
|
||||
<TableHead width={150}>Type</TableHead>
|
||||
<TableHead width={120}>Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{connectors.map((c) => (
|
||||
<TableRow key={c.name} variant="table">
|
||||
<TableCell>
|
||||
<Text mainUiBody>{c.name}</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Text mainUiMuted text03>
|
||||
{c.type}
|
||||
</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Text mainUiBody>{c.status}</Text>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
),
|
||||
};
|
||||
|
||||
/** Row with selected state highlighted. */
|
||||
export const SelectedRows: Story = {
|
||||
render: () => (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead width={200}>Connector</TableHead>
|
||||
<TableHead width={150}>Type</TableHead>
|
||||
<TableHead width={120}>Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{connectors.map((c, i) => (
|
||||
<TableRow key={c.name} selected={i === 1 || i === 3}>
|
||||
<TableCell>
|
||||
<Text mainUiBody>{c.name}</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Text mainUiMuted text03>
|
||||
{c.type}
|
||||
</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Text mainUiBody>{c.status}</Text>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
),
|
||||
};
|
||||
|
||||
/** Sortable table headers with sort indicators. */
|
||||
export const SortableHeaders: Story = {
|
||||
render: () => (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead width={200} sorted="ascending" onSort={() => {}}>
|
||||
Connector
|
||||
</TableHead>
|
||||
<TableHead width={150} sorted="none" onSort={() => {}}>
|
||||
Type
|
||||
</TableHead>
|
||||
<TableHead width={120} sorted="descending" onSort={() => {}}>
|
||||
Documents
|
||||
</TableHead>
|
||||
<TableHead width={120}>Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{connectors.map((c) => (
|
||||
<TableRow key={c.name}>
|
||||
<TableCell>
|
||||
<Text mainUiBody>{c.name}</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Text mainUiMuted text03>
|
||||
{c.type}
|
||||
</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Text mainUiMono text03>
|
||||
{c.docs.toLocaleString()}
|
||||
</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Text mainUiBody>{c.status}</Text>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
),
|
||||
};
|
||||
|
||||
/** Small size variant with denser spacing. */
|
||||
export const SmallSize: Story = {
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<TooltipPrimitive.Provider>
|
||||
<TableSizeProvider size="small">
|
||||
<div style={{ maxWidth: 800, padding: 16 }}>
|
||||
<Story />
|
||||
</div>
|
||||
</TableSizeProvider>
|
||||
</TooltipPrimitive.Provider>
|
||||
),
|
||||
],
|
||||
render: () => (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead width={200}>Connector</TableHead>
|
||||
<TableHead width={150}>Type</TableHead>
|
||||
<TableHead width={120}>Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{connectors.map((c) => (
|
||||
<TableRow key={c.name}>
|
||||
<TableCell>
|
||||
<Text secondaryBody>{c.name}</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Text secondaryBody text03>
|
||||
{c.type}
|
||||
</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Text secondaryBody>{c.status}</Text>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
),
|
||||
};
|
||||
|
||||
/** Disabled rows styling. */
|
||||
export const DisabledRows: Story = {
|
||||
render: () => (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead width={200}>Connector</TableHead>
|
||||
<TableHead width={150}>Type</TableHead>
|
||||
<TableHead width={120}>Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{connectors.map((c, i) => (
|
||||
<TableRow key={c.name} disabled={i === 2 || i === 4}>
|
||||
<TableCell>
|
||||
<Text mainUiBody>{c.name}</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Text mainUiMuted text03>
|
||||
{c.type}
|
||||
</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Text mainUiBody>{c.status}</Text>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
),
|
||||
};
|
||||
26
web/src/refresh-components/table/Table.tsx
Normal file
26
web/src/refresh-components/table/Table.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { WithoutStyles } from "@/types";
|
||||
|
||||
interface TableProps
|
||||
extends WithoutStyles<React.TableHTMLAttributes<HTMLTableElement>> {
|
||||
ref?: React.Ref<HTMLTableElement>;
|
||||
/** Explicit pixel width for the table (e.g. from `table.getTotalSize()`).
|
||||
* When provided the table uses exactly this width instead of stretching
|
||||
* to fill its container, which prevents `table-layout: fixed` from
|
||||
* redistributing extra space across columns on resize. */
|
||||
width?: number;
|
||||
}
|
||||
|
||||
function Table({ ref, width, ...props }: TableProps) {
|
||||
return (
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn("border-separate border-spacing-0", "min-w-full")}
|
||||
style={{ tableLayout: "fixed", width: width ?? undefined }}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default Table;
|
||||
export type { TableProps };
|
||||
@@ -1,6 +1,6 @@
|
||||
import { cn } from "@opal/utils";
|
||||
import { useTableSize } from "@opal/components/table/TableSizeContext";
|
||||
import type { TableSize } from "@opal/components/table/TableSizeContext";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTableSize } from "@/refresh-components/table/TableSizeContext";
|
||||
import type { TableSize } from "@/refresh-components/table/TableSizeContext";
|
||||
import type { WithoutStyles } from "@/types";
|
||||
|
||||
interface TableCellProps
|
||||
@@ -1,7 +1,7 @@
|
||||
import { cn } from "@opal/utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import { useTableSize } from "@opal/components/table/TableSizeContext";
|
||||
import type { TableSize } from "@opal/components/table/TableSizeContext";
|
||||
import { useTableSize } from "@/refresh-components/table/TableSizeContext";
|
||||
import type { TableSize } from "@/refresh-components/table/TableSizeContext";
|
||||
import type { WithoutStyles } from "@/types";
|
||||
import { Button } from "@opal/components";
|
||||
import { SvgChevronDown, SvgChevronUp, SvgHandle, SvgSort } from "@opal/icons";
|
||||
@@ -30,7 +30,7 @@ interface TableHeadCustomProps {
|
||||
icon?: (sorted: SortDirection) => IconFunctionComponent;
|
||||
/** Text alignment for the column. Defaults to `"left"`. */
|
||||
alignment?: "left" | "center" | "right";
|
||||
/** Cell density. `"md"` uses tighter padding for denser layouts. */
|
||||
/** Cell density. `"small"` uses tighter padding for denser layouts. */
|
||||
size?: TableSize;
|
||||
/** Column width in pixels. Applied as an inline style on the `<th>`. */
|
||||
width?: number;
|
||||
@@ -88,7 +88,7 @@ export default function TableHead({
|
||||
}: TableHeadProps) {
|
||||
const contextSize = useTableSize();
|
||||
const resolvedSize = size ?? contextSize;
|
||||
const isSmall = resolvedSize === "md";
|
||||
const isSmall = resolvedSize === "small";
|
||||
return (
|
||||
<th
|
||||
{...thProps}
|
||||
@@ -1,12 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { cn } from "@opal/utils";
|
||||
import { useTableSize } from "@opal/components/table/TableSizeContext";
|
||||
import type { TableSize } from "@opal/components/table/TableSizeContext";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTableSize } from "@/refresh-components/table/TableSizeContext";
|
||||
import type { TableSize } from "@/refresh-components/table/TableSizeContext";
|
||||
import { SvgUser } from "@opal/icons";
|
||||
import type { IconFunctionComponent } from "@opal/types";
|
||||
import type { QualifierContentType } from "@opal/components/table/types";
|
||||
import type { QualifierContentType } from "@/refresh-components/table/types";
|
||||
import Checkbox from "@/refresh-components/inputs/Checkbox";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
|
||||
@@ -35,8 +35,8 @@ interface TableQualifierProps {
|
||||
}
|
||||
|
||||
const iconSizes = {
|
||||
lg: 16,
|
||||
md: 14,
|
||||
regular: 16,
|
||||
small: 14,
|
||||
} as const;
|
||||
|
||||
function getQualifierStyles(selected: boolean, disabled: boolean) {
|
||||
@@ -62,9 +62,9 @@ function getQualifierStyles(selected: boolean, disabled: boolean) {
|
||||
container: "bg-background-tint-01",
|
||||
icon: "stroke-text-03",
|
||||
overlay:
|
||||
"flex opacity-0 group-hover/row:opacity-100 group-focus-within/row:opacity-100 bg-background-tint-01",
|
||||
"flex opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 bg-background-tint-01",
|
||||
overlayImage:
|
||||
"flex opacity-0 group-hover/row:opacity-100 group-focus-within/row:opacity-100 bg-mask-01 group-hover/row:backdrop-blur-02 group-focus-within/row:backdrop-blur-02",
|
||||
"flex opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 bg-mask-01 group-hover:backdrop-blur-02 group-focus-within:backdrop-blur-02",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -115,7 +115,7 @@ function TableQualifier({
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded-full bg-background-neutral-inverted-00",
|
||||
resolvedSize === "lg" ? "h-7 w-7" : "h-6 w-6"
|
||||
resolvedSize === "regular" ? "h-7 w-7" : "h-6 w-6"
|
||||
)}
|
||||
>
|
||||
<Text
|
||||
@@ -138,36 +138,30 @@ function TableQualifier({
|
||||
<div
|
||||
className={cn(
|
||||
"group relative inline-flex shrink-0 items-center justify-center",
|
||||
resolvedSize === "lg" ? "h-9 w-9" : "h-7 w-7",
|
||||
resolvedSize === "regular" ? "h-9 w-9" : "h-7 w-7",
|
||||
disabled ? "cursor-not-allowed" : "cursor-default",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Inner qualifier container — no background for "simple" */}
|
||||
{content !== "simple" && (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center overflow-hidden transition-colors",
|
||||
resolvedSize === "lg" ? "h-9 w-9" : "h-7 w-7",
|
||||
isRound ? "rounded-full" : "rounded-08",
|
||||
styles.container,
|
||||
content === "image" && disabled && !selected && "opacity-50"
|
||||
)}
|
||||
>
|
||||
{renderContent()}
|
||||
</div>
|
||||
)}
|
||||
{/* Inner qualifier container */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center overflow-hidden transition-colors",
|
||||
resolvedSize === "regular" ? "h-9 w-9" : "h-7 w-7",
|
||||
isRound ? "rounded-full" : "rounded-08",
|
||||
styles.container,
|
||||
content === "image" && disabled && !selected && "opacity-50"
|
||||
)}
|
||||
>
|
||||
{renderContent()}
|
||||
</div>
|
||||
|
||||
{/* Selection overlay */}
|
||||
{selectable && (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 items-center justify-center",
|
||||
content === "simple"
|
||||
? "flex"
|
||||
: isRound
|
||||
? "rounded-full"
|
||||
: "rounded-08",
|
||||
isRound ? "rounded-full" : "rounded-08",
|
||||
content === "simple"
|
||||
? "flex"
|
||||
: content === "image"
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@opal/utils";
|
||||
import { useTableSize } from "@opal/components/table/TableSizeContext";
|
||||
import type { TableSize } from "@opal/components/table/TableSizeContext";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTableSize } from "@/refresh-components/table/TableSizeContext";
|
||||
import type { TableSize } from "@/refresh-components/table/TableSizeContext";
|
||||
import type { WithoutStyles } from "@/types";
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
@@ -18,6 +18,8 @@ interface TableRowProps
|
||||
selected?: boolean;
|
||||
/** Disables interaction and applies disabled styling */
|
||||
disabled?: boolean;
|
||||
/** Visual variant: "table" adds a bottom border, "list" adds rounded corners. Defaults to "list". */
|
||||
variant?: "table" | "list";
|
||||
/** When provided, makes this row sortable via @dnd-kit */
|
||||
sortableId?: string;
|
||||
/** Show drag handle overlay. Defaults to true when sortableId is set. */
|
||||
@@ -34,6 +36,7 @@ function SortableTableRow({
|
||||
sortableId,
|
||||
showDragHandle = true,
|
||||
size,
|
||||
variant = "list",
|
||||
selected,
|
||||
disabled,
|
||||
ref: _externalRef,
|
||||
@@ -63,6 +66,7 @@ function SortableTableRow({
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className="tbl-row group/row"
|
||||
data-variant={variant}
|
||||
data-drag-handle={showDragHandle || undefined}
|
||||
data-selected={selected || undefined}
|
||||
data-disabled={disabled || undefined}
|
||||
@@ -91,7 +95,7 @@ function SortableTableRow({
|
||||
{...listeners}
|
||||
>
|
||||
<SvgHandle
|
||||
size={resolvedSize === "md" ? 12 : 16}
|
||||
size={resolvedSize === "small" ? 12 : 16}
|
||||
className="text-border-02"
|
||||
/>
|
||||
</button>
|
||||
@@ -109,6 +113,7 @@ function TableRow({
|
||||
sortableId,
|
||||
showDragHandle,
|
||||
size,
|
||||
variant = "list",
|
||||
selected,
|
||||
disabled,
|
||||
ref,
|
||||
@@ -120,6 +125,7 @@ function TableRow({
|
||||
sortableId={sortableId}
|
||||
showDragHandle={showDragHandle}
|
||||
size={size}
|
||||
variant={variant}
|
||||
selected={selected}
|
||||
disabled={disabled}
|
||||
ref={ref}
|
||||
@@ -132,6 +138,7 @@ function TableRow({
|
||||
<tr
|
||||
ref={ref}
|
||||
className="tbl-row group/row"
|
||||
data-variant={variant}
|
||||
data-selected={selected || undefined}
|
||||
data-disabled={disabled || undefined}
|
||||
{...props}
|
||||
@@ -1,11 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext } from "react";
|
||||
import type { SizeVariants } from "@opal/types";
|
||||
|
||||
type TableSize = Extract<SizeVariants, "md" | "lg">;
|
||||
type TableSize = "regular" | "small";
|
||||
|
||||
const TableSizeContext = createContext<TableSize>("lg");
|
||||
const TableSizeContext = createContext<TableSize>("regular");
|
||||
|
||||
interface TableSizeProviderProps {
|
||||
size: TableSize;
|
||||
@@ -13,10 +13,10 @@ import type {
|
||||
OnyxDataColumn,
|
||||
OnyxDisplayColumn,
|
||||
OnyxActionsColumn,
|
||||
} from "@opal/components/table/types";
|
||||
import type { TableSize } from "@opal/components/table/TableSizeContext";
|
||||
} from "@/refresh-components/table/types";
|
||||
import type { TableSize } from "@/refresh-components/table/TableSizeContext";
|
||||
import type { IconFunctionComponent } from "@opal/types";
|
||||
import type { SortDirection } from "@opal/components/table/TableHead";
|
||||
import type { SortDirection } from "@/refresh-components/table/TableHead";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Qualifier column config
|
||||
@@ -160,7 +160,7 @@ export function createTableColumns<TData>(): TableColumnsBuilder<TData> {
|
||||
id: "qualifier",
|
||||
def,
|
||||
width: (size: TableSize) =>
|
||||
size === "md" ? { fixed: 36 } : { fixed: 44 },
|
||||
size === "small" ? { fixed: 40 } : { fixed: 56 },
|
||||
content,
|
||||
headerContentType: config?.headerContentType,
|
||||
getInitials: config?.getInitials,
|
||||
@@ -241,29 +241,14 @@ export function createTableColumns<TData>(): TableColumnsBuilder<TData> {
|
||||
: () => null,
|
||||
};
|
||||
|
||||
const showVisibility = config?.showColumnVisibility ?? true;
|
||||
const showSorting = config?.showSorting ?? true;
|
||||
const buttonCount = (showVisibility ? 1 : 0) + (showSorting ? 1 : 0);
|
||||
|
||||
// Icon button sizes: "md" button = 28px, "sm" button = 24px
|
||||
// px-1 on .tbl-actions = 4px each side = 8px total
|
||||
const BUTTON_MD = 28;
|
||||
const BUTTON_SM = 24;
|
||||
const PADDING = 8;
|
||||
|
||||
return {
|
||||
kind: "actions",
|
||||
id: "__actions",
|
||||
def,
|
||||
width: (size: TableSize) => ({
|
||||
fixed:
|
||||
Math.max(
|
||||
buttonCount * (size === "md" ? BUTTON_SM : BUTTON_MD),
|
||||
size === "md" ? BUTTON_SM : BUTTON_MD
|
||||
) + PADDING,
|
||||
}),
|
||||
showColumnVisibility: showVisibility,
|
||||
showSorting: showSorting,
|
||||
width: (size: TableSize) =>
|
||||
size === "small" ? { fixed: 20 } : { fixed: 88 },
|
||||
showColumnVisibility: config?.showColumnVisibility ?? true,
|
||||
showSorting: config?.showSorting ?? true,
|
||||
sortingFooterText: config?.sortingFooterText,
|
||||
};
|
||||
},
|
||||
@@ -153,10 +153,6 @@ interface UseDataTableReturn<TData extends RowData> {
|
||||
clearSelection: () => void;
|
||||
/** Select or deselect all rows on the current page. */
|
||||
toggleAllPageRowsSelected: (selected: boolean) => void;
|
||||
/** Select or deselect all rows across all pages. */
|
||||
toggleAllRowsSelected: (selected: boolean) => void;
|
||||
/** Whether every row across all pages is selected. */
|
||||
isAllRowsSelected: boolean;
|
||||
|
||||
// View-mode (filter to selected rows)
|
||||
/** Whether the table is currently filtered to show only selected rows. */
|
||||
@@ -411,16 +407,6 @@ export default function useDataTable<TData extends RowData>(
|
||||
table.toggleAllPageRowsSelected(selected);
|
||||
};
|
||||
|
||||
// TODO (@raunakab): In server-side mode, these only operate on the loaded
|
||||
// page data, not all rows across all pages. TanStack can't select rows it
|
||||
// doesn't have. Fixing this requires a server-side callback (e.g.
|
||||
// `onSelectAll`) and a `totalItems`-aware selection model.
|
||||
const toggleAllRowsSelected = (selected: boolean) => {
|
||||
table.toggleAllRowsSelected(selected);
|
||||
};
|
||||
|
||||
const isAllRowsSelected = table.getIsAllRowsSelected();
|
||||
|
||||
// ---- view mode (filter to selected rows) --------------------------------
|
||||
const isViewingSelected = globalFilter.selectedIds != null;
|
||||
|
||||
@@ -453,10 +439,8 @@ export default function useDataTable<TData extends RowData>(
|
||||
selectedCount,
|
||||
selectedRowIds,
|
||||
isAllPageRowsSelected,
|
||||
isAllRowsSelected,
|
||||
clearSelection,
|
||||
toggleAllPageRowsSelected,
|
||||
toggleAllRowsSelected,
|
||||
isViewingSelected,
|
||||
enterViewMode,
|
||||
exitViewMode,
|
||||
@@ -4,10 +4,9 @@ import type {
|
||||
SortingState,
|
||||
VisibilityState,
|
||||
} from "@tanstack/react-table";
|
||||
import type { TableSize } from "@opal/components/table/TableSizeContext";
|
||||
import type { TableVariant } from "@opal/components/table/TableElement";
|
||||
import type { TableSize } from "@/refresh-components/table/TableSizeContext";
|
||||
import type { IconFunctionComponent } from "@opal/types";
|
||||
import type { SortDirection } from "@opal/components/table/TableHead";
|
||||
import type { SortDirection } from "@/refresh-components/table/TableHead";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Column width (mirrors useColumnWidths types)
|
||||
@@ -130,16 +129,26 @@ export interface DataTableDraggableConfig {
|
||||
) => void | Promise<void>;
|
||||
}
|
||||
|
||||
/** Footer configuration. Mode is derived from `selectionBehavior` automatically. */
|
||||
export interface DataTableFooterConfig {
|
||||
/** Handler for the "Clear" button (multi-select only). When omitted, the default clearSelection is used. */
|
||||
export interface DataTableFooterSelection {
|
||||
mode: "selection";
|
||||
/** Whether the table supports selecting multiple rows. @default true */
|
||||
multiSelect?: boolean;
|
||||
/** When true, shows a "View" button that filters the table to only selected rows. @default false */
|
||||
showView?: boolean;
|
||||
/** Handler for the "Clear" button. When omitted, the default clearSelection is used. */
|
||||
onClear?: () => void;
|
||||
/** Unit label for count pagination, e.g. "users", "documents" (multi-select only). */
|
||||
units?: string;
|
||||
/** Optional extra element rendered after the summary text, e.g. a download icon (summary mode only). */
|
||||
}
|
||||
|
||||
export interface DataTableFooterSummary {
|
||||
mode: "summary";
|
||||
/** Optional extra element rendered after the summary text (e.g. a download icon). */
|
||||
leftExtra?: ReactNode;
|
||||
}
|
||||
|
||||
export type DataTableFooterConfig =
|
||||
| DataTableFooterSelection
|
||||
| DataTableFooterSummary;
|
||||
|
||||
export interface DataTableProps<TData> {
|
||||
/** Row data array. */
|
||||
data: TData[];
|
||||
@@ -157,10 +166,8 @@ export interface DataTableProps<TData> {
|
||||
draggable?: DataTableDraggableConfig;
|
||||
/** Footer configuration. */
|
||||
footer?: DataTableFooterConfig;
|
||||
/** Table size variant. @default "lg" */
|
||||
/** Table size variant. @default "regular" */
|
||||
size?: TableSize;
|
||||
/** Visual row variant. @default "cards" */
|
||||
variant?: TableVariant;
|
||||
/** Called whenever the set of selected row IDs changes. Receives IDs produced by `getRowId`. */
|
||||
onSelectionChange?: (selectedIds: string[]) => void;
|
||||
/** Called when a row is clicked (replaces the default selection toggle). */
|
||||
@@ -1,42 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { UserGroup } from "@/lib/types";
|
||||
import { SvgChevronRight, SvgUserManage, SvgUsers } from "@opal/icons";
|
||||
import { ContentAction } from "@opal/layouts";
|
||||
import { Section } from "@/layouts/general-layouts";
|
||||
import Card from "@/refresh-components/cards/Card";
|
||||
import IconButton from "@/refresh-components/buttons/IconButton";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import { buildGroupDescription, formatMemberCount } from "./utils";
|
||||
|
||||
interface GroupCardProps {
|
||||
group: UserGroup;
|
||||
}
|
||||
|
||||
function GroupCard({ group }: GroupCardProps) {
|
||||
const isBasic = group.name === "Basic";
|
||||
const isAdmin = group.name === "Admin";
|
||||
|
||||
return (
|
||||
<Card padding={0.5}>
|
||||
<ContentAction
|
||||
icon={isAdmin ? SvgUserManage : SvgUsers}
|
||||
title={group.name}
|
||||
description={buildGroupDescription(group)}
|
||||
sizePreset="main-content"
|
||||
variant="section"
|
||||
tag={isBasic ? { title: "Default" } : undefined}
|
||||
rightChildren={
|
||||
<Section flexDirection="row" alignItems="center">
|
||||
<Text mainUiBody text03>
|
||||
{formatMemberCount(group.users.length)}
|
||||
</Text>
|
||||
<IconButton icon={SvgChevronRight} tertiary tooltip="View group" />
|
||||
</Section>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default GroupCard;
|
||||
@@ -1,54 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import type { UserGroup } from "@/lib/types";
|
||||
import Separator from "@/refresh-components/Separator";
|
||||
import GroupCard from "./GroupCard";
|
||||
import { isBuiltInGroup } from "./utils";
|
||||
import { Section } from "@/layouts/general-layouts";
|
||||
import { IllustrationContent } from "@opal/layouts";
|
||||
import SvgNoResult from "@opal/illustrations/no-result";
|
||||
|
||||
interface GroupsListProps {
|
||||
groups: UserGroup[];
|
||||
searchQuery: string;
|
||||
}
|
||||
|
||||
function GroupsList({ groups, searchQuery }: GroupsListProps) {
|
||||
const filtered = useMemo(() => {
|
||||
if (!searchQuery.trim()) return groups;
|
||||
const q = searchQuery.toLowerCase();
|
||||
return groups.filter((g) => g.name.toLowerCase().includes(q));
|
||||
}, [groups, searchQuery]);
|
||||
|
||||
if (filtered.length === 0) {
|
||||
return (
|
||||
<IllustrationContent
|
||||
illustration={SvgNoResult}
|
||||
title="No groups found"
|
||||
description={`No groups matching "${searchQuery}"`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const builtInGroups = filtered.filter(isBuiltInGroup);
|
||||
const customGroups = filtered.filter((g) => !isBuiltInGroup(g));
|
||||
|
||||
return (
|
||||
<Section flexDirection="column" gap={0.5}>
|
||||
{builtInGroups.map((group) => (
|
||||
<GroupCard key={group.id} group={group} />
|
||||
))}
|
||||
|
||||
{builtInGroups.length > 0 && customGroups.length > 0 && (
|
||||
<Separator paddingYRem={0.5} />
|
||||
)}
|
||||
|
||||
{customGroups.map((group) => (
|
||||
<GroupCard key={group.id} group={group} />
|
||||
))}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
export default GroupsList;
|
||||
@@ -1,66 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import useSWR from "swr";
|
||||
import { SvgPlusCircle, SvgUsers } from "@opal/icons";
|
||||
import { Button } from "@opal/components";
|
||||
import * as SettingsLayouts from "@/layouts/settings-layouts";
|
||||
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
|
||||
import SimpleLoader from "@/refresh-components/loaders/SimpleLoader";
|
||||
import { errorHandlingFetcher } from "@/lib/fetcher";
|
||||
import type { UserGroup } from "@/lib/types";
|
||||
import { USER_GROUP_URL } from "./svc";
|
||||
import GroupsList from "./GroupsList";
|
||||
import { Section } from "@/layouts/general-layouts";
|
||||
import { IllustrationContent } from "@opal/layouts";
|
||||
import SvgNoResult from "@opal/illustrations/no-result";
|
||||
|
||||
function GroupsPage() {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
const {
|
||||
data: groups,
|
||||
error,
|
||||
isLoading,
|
||||
} = useSWR<UserGroup[]>(USER_GROUP_URL, errorHandlingFetcher);
|
||||
|
||||
return (
|
||||
<SettingsLayouts.Root>
|
||||
{/* This is the sticky header for the groups page. It is used to display
|
||||
* the groups page title and search input when scrolling down.
|
||||
*/}
|
||||
<div className="sticky top-0 z-settings-header bg-background-tint-01">
|
||||
<SettingsLayouts.Header icon={SvgUsers} title="Groups" separator />
|
||||
|
||||
<Section flexDirection="row" padding={1}>
|
||||
<InputTypeIn
|
||||
placeholder="Search groups..."
|
||||
variant="internal"
|
||||
value={searchQuery}
|
||||
leftSearchIcon
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
<Button icon={SvgPlusCircle}>New Group</Button>
|
||||
</Section>
|
||||
</div>
|
||||
|
||||
<SettingsLayouts.Body>
|
||||
{isLoading && <SimpleLoader />}
|
||||
|
||||
{error && (
|
||||
<IllustrationContent
|
||||
illustration={SvgNoResult}
|
||||
title="Failed to load groups."
|
||||
description="Please check the console for more details."
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && groups && (
|
||||
<GroupsList groups={groups} searchQuery={searchQuery} />
|
||||
)}
|
||||
</SettingsLayouts.Body>
|
||||
</SettingsLayouts.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export default GroupsPage;
|
||||
@@ -1,5 +0,0 @@
|
||||
/** API helpers for the Groups list page. */
|
||||
|
||||
const USER_GROUP_URL = "/api/manage/admin/user-group";
|
||||
|
||||
export { USER_GROUP_URL };
|
||||
@@ -1,57 +0,0 @@
|
||||
import type { UserGroup } from "@/lib/types";
|
||||
|
||||
/** Groups that are created by the system and cannot be deleted. */
|
||||
export const BUILT_IN_GROUP_NAMES = ["Basic", "Admin"] as const;
|
||||
|
||||
export function isBuiltInGroup(group: UserGroup): boolean {
|
||||
return (BUILT_IN_GROUP_NAMES as readonly string[]).includes(group.name);
|
||||
}
|
||||
|
||||
/** Human-readable description for built-in groups. */
|
||||
const BUILT_IN_DESCRIPTIONS: Record<string, string> = {
|
||||
Basic: "Default group for all users with basic permissions.",
|
||||
Admin: "Built-in admin group with full access to manage all permissions.",
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the description line(s) shown beneath the group name.
|
||||
*
|
||||
* Built-in groups use a fixed label.
|
||||
* Custom groups list resource counts ("3 connectors · 2 document sets · 2 agents")
|
||||
* or fall back to "No private connectors / document sets / agents".
|
||||
*/
|
||||
export function buildGroupDescription(group: UserGroup): string {
|
||||
if (isBuiltInGroup(group)) {
|
||||
return BUILT_IN_DESCRIPTIONS[group.name] ?? "";
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
if (group.cc_pairs.length > 0) {
|
||||
parts.push(
|
||||
`${group.cc_pairs.length} connector${
|
||||
group.cc_pairs.length !== 1 ? "s" : ""
|
||||
}`
|
||||
);
|
||||
}
|
||||
if (group.document_sets.length > 0) {
|
||||
parts.push(
|
||||
`${group.document_sets.length} document set${
|
||||
group.document_sets.length !== 1 ? "s" : ""
|
||||
}`
|
||||
);
|
||||
}
|
||||
if (group.personas.length > 0) {
|
||||
parts.push(
|
||||
`${group.personas.length} agent${group.personas.length !== 1 ? "s" : ""}`
|
||||
);
|
||||
}
|
||||
|
||||
return parts.length > 0
|
||||
? parts.join(" · ")
|
||||
: "No private connectors / document sets / agents";
|
||||
}
|
||||
|
||||
/** Format the member count badge, e.g. "306 Members" or "1 Member". */
|
||||
export function formatMemberCount(count: number): string {
|
||||
return `${count} ${count === 1 ? "Member" : "Members"}`;
|
||||
}
|
||||
@@ -8,11 +8,11 @@ import { useScimToken } from "@/hooks/useScimToken";
|
||||
import { usePaidEnterpriseFeaturesEnabled } from "@/components/settings/usePaidEnterpriseFeaturesEnabled";
|
||||
import useUserCounts from "@/hooks/useUserCounts";
|
||||
import { UserStatus } from "@/lib/types";
|
||||
import type { StatusFilter } from "./interfaces";
|
||||
import type { StatusFilter } from "./UsersPage/interfaces";
|
||||
|
||||
import UsersSummary from "./UsersSummary";
|
||||
import UsersTable from "./UsersTable";
|
||||
import InviteUsersModal from "./InviteUsersModal";
|
||||
import UsersSummary from "./UsersPage/UsersSummary";
|
||||
import UsersTable from "./UsersPage/UsersTable";
|
||||
import InviteUsersModal from "./UsersPage/InviteUsersModal";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Users page content
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { Table, createTableColumns } from "@opal/components";
|
||||
import DataTable from "@/refresh-components/table/DataTable";
|
||||
import { createTableColumns } from "@/refresh-components/table/columns";
|
||||
import { Content } from "@opal/layouts";
|
||||
import { Button } from "@opal/components";
|
||||
import { SvgDownload } from "@opal/icons";
|
||||
@@ -215,11 +216,10 @@ export default function UsersTable({
|
||||
roleCounts={roleCounts}
|
||||
statusCounts={statusCounts}
|
||||
/>
|
||||
<Table
|
||||
<DataTable
|
||||
data={filteredUsers}
|
||||
columns={columns}
|
||||
getRowId={(row) => row.id ?? row.email}
|
||||
qualifier="avatar"
|
||||
pageSize={PAGE_SIZE}
|
||||
searchTerm={searchTerm}
|
||||
emptyState={
|
||||
@@ -230,6 +230,7 @@ export default function UsersTable({
|
||||
/>
|
||||
}
|
||||
footer={{
|
||||
mode: "summary",
|
||||
leftExtra: (
|
||||
<Button
|
||||
icon={SvgDownload}
|
||||
|
||||
Reference in New Issue
Block a user