Compare commits

..

10 Commits

Author SHA1 Message Date
Justin Tahara
5413723ccc feat(ods): Rerun run-ci workflow (#9501) 2026-03-19 22:11:59 +00:00
Evan Lohn
9660056a51 fix: drive rate limit retry (#9498) 2026-03-19 21:32:08 +00:00
Fizza Mukhtar
3105177238 fix(llm): don't send tool_choice when no tools are provided (#9224) 2026-03-19 21:26:46 +00:00
Evan Lohn
24bb4bda8b feat: windows installer and install improvements (#9476) 2026-03-19 20:47:44 +00:00
Raunak Bhagat
9532af4ceb chore: move Hoverable story (#9495) 2026-03-19 20:40:27 +00:00
Jamison Lahman
0a913f6af5 fix(fe): fix memories immediately losing focus on click (#9493) 2026-03-19 20:15:34 +00:00
Justin Tahara
fe30c55199 fix(code interpreter): Caching files (#9484) 2026-03-19 19:32:37 +00:00
Jamison Lahman
2cf0a65dd3 chore(fe): reduce padding on elements at the bottom of modal headers (#9488) 2026-03-19 19:27:37 +00:00
Nikolas Garza
659416f363 feat(admin): groups page - list page and group cards (#9453) 2026-03-19 18:23:15 +00:00
Raunak Bhagat
40aecbc4b9 refactor(fe): move table to opal, update size API (#9438) 2026-03-19 17:23:41 +00:00
61 changed files with 2838 additions and 2137 deletions

View File

@@ -157,9 +157,7 @@ def _execute_single_retrieval(
logger.error(f"Error executing request: {e}")
raise e
elif _is_rate_limit_error(e):
results = _execute_with_retry(
lambda: retrieval_function(**request_kwargs).execute()
)
results = _execute_with_retry(retrieval_function(**request_kwargs))
elif e.resp.status == 404 or e.resp.status == 403:
if continue_on_404_or_403:
logger.debug(f"Error executing request: {e}")

View File

@@ -42,8 +42,12 @@ class HookUpdateRequest(BaseModel):
name: str | None = None
endpoint_url: str | None = None
api_key: NonEmptySecretStr | None = None
fail_strategy: HookFailStrategy | None = None
timeout_seconds: float | None = Field(default=None, gt=0)
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
@model_validator(mode="after")
def require_at_least_one_field(self) -> "HookUpdateRequest":
@@ -56,14 +60,6 @@ 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
@@ -94,28 +90,38 @@ 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):
status: HookValidateStatus
success: bool
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,
)

View File

@@ -40,14 +40,6 @@ 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):

View File

@@ -530,6 +530,11 @@ 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,
@@ -538,7 +543,6 @@ 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,

View File

@@ -77,7 +77,6 @@ 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,
)
@@ -454,7 +453,6 @@ 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)

View File

@@ -1,510 +0,0 @@
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
]

View File

@@ -1,3 +1,4 @@
import hashlib
import mimetypes
from io import BytesIO
from typing import Any
@@ -83,6 +84,14 @@ 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:
@@ -182,8 +191,13 @@ class PythonTool(Tool[PythonToolOverrideKwargs]):
for ind, chat_file in enumerate(chat_files):
file_name = chat_file.filename or f"file_{ind}"
try:
# Upload to Code Interpreter
ci_file_id = client.upload_file(chat_file.content, file_name)
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
# Stage for execution
files_to_stage.append({"path": file_name, "file_id": ci_file_id})
@@ -299,14 +313,10 @@ class PythonTool(Tool[PythonToolOverrideKwargs]):
f"Failed to delete Code Interpreter generated file {ci_file_id}: {e}"
)
# 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}"
)
# 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.
# Emit file_ids once files are processed
if generated_file_ids:

View File

@@ -1219,15 +1219,16 @@ def test_code_interpreter_receives_chat_files(
finally:
ci_mod.CodeInterpreterClient.__init__.__defaults__ = original_defaults
# Verify: file uploaded, code executed via streaming, staged file cleaned up
# Verify: file uploaded and code executed via streaming.
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
)
delete_requests = mock_ci_server.get_requests(method="DELETE")
assert len(delete_requests) == 1
assert delete_requests[0].path.startswith("/v1/files/")
# 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
execute_body = mock_ci_server.get_requests(
method="POST", path="/v1/execute/stream"

View File

@@ -256,7 +256,6 @@ 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,
@@ -412,7 +411,6 @@ 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,
@@ -1431,3 +1429,36 @@ 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"

View File

@@ -1,278 +0,0 @@
"""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

View File

@@ -0,0 +1,208 @@
"""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

View File

@@ -207,6 +207,16 @@ 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'
@@ -395,6 +405,11 @@ 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."
@@ -411,7 +426,11 @@ if command -v docker &> /dev/null \
&& ! command -v docker-compose &> /dev/null \
&& { [[ "$OSTYPE" == "linux-gnu"* ]] || [[ -n "${WSL_DISTRO_NAME:-}" ]]; }; then
print_info "Docker Compose not found — installing plugin..."
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
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"
@@ -562,10 +581,31 @@ version_compare() {
# Check Docker daemon
if ! docker info &> /dev/null; then
print_error "Docker daemon is not running. Please start Docker."
exit 1
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"
fi
print_success "Docker daemon is running"
# Check Docker resources
print_step "Verifying Docker resources"
@@ -745,6 +785,7 @@ 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
@@ -1084,6 +1125,25 @@ 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..."

View File

@@ -17,6 +17,7 @@ import (
type RunCIOptions struct {
DryRun bool
Yes bool
Rerun bool
}
// NewRunCICommand creates a new run-ci command
@@ -49,6 +50,7 @@ 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
}
@@ -107,19 +109,44 @@ func runCI(cmd *cobra.Command, args []string, opts *RunCIOptions) {
log.Fatalf("PR #%s is not from a fork - CI should already run automatically", prNumber)
}
// Confirm before proceeding
if !opts.Yes {
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)
// 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)) {
log.Info("Exiting...")
return
}
}
// Fetch the fork's branch
if forkRepo == "" {
log.Fatalf("Could not determine fork repository - headRepositoryOwner or headRepository.name is empty")
@@ -158,7 +185,11 @@ func runCI(cmd *cobra.Command, args []string, opts *RunCIOptions) {
if opts.DryRun {
log.Warnf("[DRY RUN] Would push CI branch: %s", ciBranch)
log.Warnf("[DRY RUN] Would create PR: %s", prTitle)
if existingPRURL == "" {
log.Warnf("[DRY RUN] Would create PR: %s", prTitle)
} else {
log.Warnf("[DRY RUN] Would update existing PR: %s", existingPRURL)
}
// 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)
@@ -176,6 +207,17 @@ 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)
@@ -217,6 +259,39 @@ 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",

View File

@@ -52,3 +52,8 @@ 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";

View File

@@ -1,5 +1,5 @@
import { useTableSize } from "@/refresh-components/table/TableSizeContext";
import type { TableSize } from "@/refresh-components/table/TableSizeContext";
import { useTableSize } from "@opal/components/table/TableSizeContext";
import type { TableSize } from "@opal/components/table/TableSizeContext";
interface ActionsContainerProps {
type: "head" | "cell";

View File

@@ -7,11 +7,10 @@ import {
type RowData,
type SortingState,
} from "@tanstack/react-table";
import { Button } from "@opal/components";
import { Button, LineItemButton } 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";
// ---------------------------------------------------------------------------
@@ -21,7 +20,7 @@ import Text from "@/refresh-components/texts/Text";
interface SortingPopoverProps<TData extends RowData = RowData> {
table: Table<TData>;
sorting: SortingState;
size?: "regular" | "small";
size?: "md" | "lg";
footerText?: string;
ascendingLabel?: string;
descendingLabel?: string;
@@ -30,7 +29,7 @@ interface SortingPopoverProps<TData extends RowData = RowData> {
function SortingPopover<TData extends RowData>({
table,
sorting,
size = "regular",
size = "lg",
footerText,
ascendingLabel = "Ascending",
descendingLabel = "Descending",
@@ -48,8 +47,8 @@ function SortingPopover<TData extends RowData>({
<Button
icon={currentSort === null ? SvgArrowUpDown : SvgSortOrder}
interaction={open ? "hover" : "rest"}
size={size === "small" ? "sm" : "md"}
prominence="internal"
size={size === "md" ? "sm" : "md"}
prominence="tertiary"
tooltip="Sort"
/>
</Popover.Trigger>
@@ -68,18 +67,20 @@ function SortingPopover<TData extends RowData>({
>
<Divider showTitle text="Sort by" />
<LineItem
selected={currentSort === null}
emphasized
<LineItemButton
selectVariant="select-heavy"
state={currentSort === null ? "selected" : "empty"}
title="Manual Ordering"
sizePreset="main-ui"
rightChildren={
currentSort === null ? <SvgCheck size={16} /> : undefined
currentSort === null ? (
<SvgCheck size={16} className="text-action-link-05" />
) : undefined
}
onClick={() => {
table.resetSorting();
}}
>
Manual Ordering
</LineItem>
/>
{sortableColumns.map((column) => {
const isSorted = currentSort?.id === column.id;
@@ -89,11 +90,17 @@ function SortingPopover<TData extends RowData>({
: column.id;
return (
<LineItem
<LineItemButton
key={column.id}
selected={isSorted}
emphasized
rightChildren={isSorted ? <SvgCheck size={16} /> : undefined}
selectVariant="select-heavy"
state={isSorted ? "selected" : "empty"}
title={label}
sizePreset="main-ui"
rightChildren={
isSorted ? (
<SvgCheck size={16} className="text-action-link-05" />
) : undefined
}
onClick={() => {
if (isSorted) {
table.resetSorting();
@@ -101,9 +108,7 @@ function SortingPopover<TData extends RowData>({
}
column.toggleSorting(false);
}}
>
{label}
</LineItem>
/>
);
})}
@@ -111,31 +116,35 @@ function SortingPopover<TData extends RowData>({
<>
<Divider showTitle text="Sorting Order" />
<LineItem
selected={!currentSort.desc}
emphasized
<LineItemButton
selectVariant="select-heavy"
state={!currentSort.desc ? "selected" : "empty"}
title={ascendingLabel}
sizePreset="main-ui"
rightChildren={
!currentSort.desc ? <SvgCheck size={16} /> : undefined
!currentSort.desc ? (
<SvgCheck size={16} className="text-action-link-05" />
) : undefined
}
onClick={() => {
table.setSorting([{ id: currentSort.id, desc: false }]);
}}
>
{ascendingLabel}
</LineItem>
/>
<LineItem
selected={currentSort.desc}
emphasized
<LineItemButton
selectVariant="select-heavy"
state={currentSort.desc ? "selected" : "empty"}
title={descendingLabel}
sizePreset="main-ui"
rightChildren={
currentSort.desc ? <SvgCheck size={16} /> : undefined
currentSort.desc ? (
<SvgCheck size={16} className="text-action-link-05" />
) : undefined
}
onClick={() => {
table.setSorting([{ id: currentSort.id, desc: true }]);
}}
>
{descendingLabel}
</LineItem>
/>
</>
)}
</Popover.Menu>
@@ -149,7 +158,7 @@ function SortingPopover<TData extends RowData>({
// ---------------------------------------------------------------------------
interface CreateSortingColumnOptions {
size?: "regular" | "small";
size?: "md" | "lg";
footerText?: string;
ascendingLabel?: string;
descendingLabel?: string;

View File

@@ -7,10 +7,9 @@ import {
type RowData,
type VisibilityState,
} from "@tanstack/react-table";
import { Button } from "@opal/components";
import { Button, LineItemButton, Tag } 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";
// ---------------------------------------------------------------------------
@@ -20,18 +19,20 @@ import Divider from "@/refresh-components/Divider";
interface ColumnVisibilityPopoverProps<TData extends RowData = RowData> {
table: Table<TData>;
columnVisibility: VisibilityState;
size?: "regular" | "small";
size?: "md" | "lg";
}
function ColumnVisibilityPopover<TData extends RowData>({
table,
columnVisibility,
size = "regular",
size = "lg",
}: ColumnVisibilityPopoverProps<TData>) {
const [open, setOpen] = useState(false);
const hideableColumns = table
// User-defined columns only (exclude internal qualifier/actions)
const dataColumns = table
.getAllLeafColumns()
.filter((col) => col.getCanHide());
.filter((col) => !col.id.startsWith("__") && col.id !== "qualifier");
return (
<Popover open={open} onOpenChange={setOpen}>
@@ -39,8 +40,8 @@ function ColumnVisibilityPopover<TData extends RowData>({
<Button
icon={SvgColumn}
interaction={open ? "hover" : "rest"}
size={size === "small" ? "sm" : "md"}
prominence="internal"
size={size === "md" ? "sm" : "md"}
prominence="tertiary"
tooltip="Columns"
/>
</Popover.Trigger>
@@ -48,7 +49,8 @@ function ColumnVisibilityPopover<TData extends RowData>({
<Popover.Content width="lg" align="end" side="bottom">
<Divider showTitle text="Shown Columns" />
<Popover.Menu>
{hideableColumns.map((column) => {
{dataColumns.map((column) => {
const canHide = column.getCanHide();
const isVisible = columnVisibility[column.id] !== false;
const label =
typeof column.columnDef.header === "string"
@@ -56,17 +58,23 @@ function ColumnVisibilityPopover<TData extends RowData>({
: column.id;
return (
<LineItem
<LineItemButton
key={column.id}
selected={isVisible}
emphasized
rightChildren={isVisible ? <SvgCheck size={16} /> : undefined}
onClick={() => {
column.toggleVisibility();
}}
>
{label}
</LineItem>
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}
/>
);
})}
</Popover.Menu>
@@ -80,7 +88,7 @@ function ColumnVisibilityPopover<TData extends RowData>({
// ---------------------------------------------------------------------------
interface CreateColumnVisibilityColumnOptions {
size?: "regular" | "small";
size?: "md" | "lg";
}
function createColumnVisibilityColumn<TData>(

View File

@@ -1,18 +1,17 @@
import { memo } from "react";
import { type Row, flexRender } from "@tanstack/react-table";
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 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 type {
OnyxColumnDef,
OnyxQualifierColumn,
} from "@/refresh-components/table/types";
} from "@opal/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;
@@ -21,7 +20,6 @@ interface DragOverlayRowProps<TData> {
function DragOverlayRowInner<TData>({
row,
variant,
columnWidths,
columnKindMap,
qualifierColumn,
@@ -50,7 +48,7 @@ function DragOverlayRowInner<TData>({
</colgroup>
)}
<tbody>
<TableRow variant={variant} selected={row.getIsSelected()}>
<TableRow selected={row.getIsSelected()}>
{row.getVisibleCells().map((cell) => {
const colDef = columnKindMap?.get(cell.column.id);

View File

@@ -1,10 +1,10 @@
"use client";
import { cn } from "@/lib/utils";
import { Button, Pagination } from "@opal/components";
import { cn } from "@opal/utils";
import { Button, Pagination, SelectButton } from "@opal/components";
import Text from "@/refresh-components/texts/Text";
import { useTableSize } from "@/refresh-components/table/TableSizeContext";
import type { TableSize } from "@/refresh-components/table/TableSizeContext";
import { useTableSize } from "@opal/components/table/TableSizeContext";
import type { TableSize } from "@opal/components/table/TableSizeContext";
import { SvgEye, SvgXCircle } from "@opal/icons";
import type { ReactNode } from "react";
@@ -27,9 +27,11 @@ interface FooterSelectionModeProps {
selectionState: SelectionState;
/** Number of currently selected items. */
selectedCount: number;
/** If provided, renders a "View" icon button when items are selected. */
/** Toggle view-filter on/off. */
onView?: () => void;
/** If provided, renders a "Clear" icon button when items are selected. */
/** Whether the view-filter is currently active. */
isViewingSelected?: boolean;
/** Clears all selections. */
onClear?: () => void;
/** Number of items displayed per page. */
pageSize: number;
@@ -41,7 +43,9 @@ interface FooterSelectionModeProps {
totalPages: number;
/** Called when the user navigates to a different page. */
onPageChange: (page: number) => void;
/** Controls overall footer sizing. `"regular"` (default) or `"small"`. */
/** Unit label for count pagination. @default "items" */
units?: string;
/** Controls overall footer sizing. `"lg"` (default) or `"md"`. */
size?: TableSize;
className?: string;
}
@@ -67,6 +71,8 @@ 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;
}
@@ -84,9 +90,10 @@ export type FooterProps = FooterSelectionModeProps | FooterSummaryModeProps;
function getSelectionMessage(
state: SelectionState,
multi: boolean,
count: number
count: number,
isViewingSelected: boolean
): string {
if (state === "none") {
if (state === "none" && !isViewingSelected) {
return multi ? "Select items to continue" : "Select an item to continue";
}
if (!multi) return "Item selected";
@@ -100,7 +107,7 @@ function getSelectionMessage(
*/
export default function Footer(props: FooterProps) {
const resolvedSize = useTableSize();
const isSmall = resolvedSize === "small";
const isSmall = resolvedSize === "md";
return (
<div
className={cn(
@@ -118,6 +125,7 @@ export default function Footer(props: FooterProps) {
multiSelect={props.multiSelect}
selectedCount={props.selectedCount}
onView={props.onView}
isViewingSelected={props.isViewingSelected}
onClear={props.onClear}
isSmall={isSmall}
/>
@@ -127,6 +135,7 @@ export default function Footer(props: FooterProps) {
rangeStart={props.rangeStart}
rangeEnd={props.rangeEnd}
totalItems={props.totalItems}
units={props.units}
isSmall={isSmall}
/>
{props.leftExtra}
@@ -144,7 +153,7 @@ export default function Footer(props: FooterProps) {
currentPage={props.currentPage}
totalPages={props.totalPages}
onChange={props.onPageChange}
units="items"
units={props.units}
size={isSmall ? "sm" : "md"}
/>
) : (
@@ -169,6 +178,7 @@ interface SelectionLeftProps {
multiSelect: boolean;
selectedCount: number;
onView?: () => void;
isViewingSelected?: boolean;
onClear?: () => void;
isSmall: boolean;
}
@@ -178,15 +188,19 @@ function SelectionLeft({
multiSelect,
selectedCount,
onView,
isViewingSelected = false,
onClear,
isSmall,
}: SelectionLeftProps) {
const message = getSelectionMessage(
selectionState,
multiSelect,
selectedCount
selectedCount,
isViewingSelected
);
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">
@@ -204,22 +218,22 @@ function SelectionLeft({
</Text>
)}
{hasSelection && (
{showActions && (
<div className="flex flex-row items-center w-fit flex-shrink-0 h-fit">
{onView && (
<Button
<SelectButton
icon={SvgEye}
state={isViewingSelected ? "selected" : "empty"}
onClick={onView}
tooltip="View"
tooltip="View selected"
size={isSmall ? "sm" : "md"}
prominence="tertiary"
/>
)}
{onClear && (
<Button
icon={SvgXCircle}
onClick={onClear}
tooltip="Clear selection"
tooltip="Deselect all"
size={isSmall ? "sm" : "md"}
prominence="tertiary"
/>
@@ -234,6 +248,7 @@ interface SummaryLeftProps {
rangeStart: number;
rangeEnd: number;
totalItems: number;
units?: string;
isSmall: boolean;
}
@@ -241,8 +256,10 @@ 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 ? (
@@ -255,6 +272,7 @@ function SummaryLeft({
<Text as="span" secondaryMono text03>
{totalItems}
</Text>
{suffix}
</Text>
) : (
<Text mainUiMuted text03>
@@ -266,6 +284,7 @@ function SummaryLeft({
<Text as="span" mainUiMono text03>
{totalItems}
</Text>
{suffix}
</Text>
)}
</div>

View File

@@ -1,5 +1,5 @@
import { useTableSize } from "@/refresh-components/table/TableSizeContext";
import type { TableSize } from "@/refresh-components/table/TableSizeContext";
import { useTableSize } from "@opal/components/table/TableSizeContext";
import type { TableSize } from "@opal/components/table/TableSizeContext";
interface QualifierContainerProps {
type: "head" | "cell";

View File

@@ -0,0 +1,82 @@
# 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

View File

@@ -0,0 +1,148 @@
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" }}
/>
),
};

View File

@@ -1,6 +1,6 @@
import { cn } from "@/lib/utils";
import { useTableSize } from "@/refresh-components/table/TableSizeContext";
import type { TableSize } from "@/refresh-components/table/TableSizeContext";
import { cn } from "@opal/utils";
import { useTableSize } from "@opal/components/table/TableSizeContext";
import type { TableSize } from "@opal/components/table/TableSizeContext";
import type { WithoutStyles } from "@/types";
interface TableCellProps

View File

@@ -0,0 +1,71 @@
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,
};

View File

@@ -1,7 +1,7 @@
import { cn } from "@/lib/utils";
import { cn } from "@opal/utils";
import Text from "@/refresh-components/texts/Text";
import { useTableSize } from "@/refresh-components/table/TableSizeContext";
import type { TableSize } from "@/refresh-components/table/TableSizeContext";
import { useTableSize } from "@opal/components/table/TableSizeContext";
import type { TableSize } from "@opal/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. `"small"` uses tighter padding for denser layouts. */
/** Cell density. `"md"` 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 === "small";
const isSmall = resolvedSize === "md";
return (
<th
{...thProps}

View File

@@ -1,12 +1,12 @@
"use client";
import React from "react";
import { cn } from "@/lib/utils";
import { useTableSize } from "@/refresh-components/table/TableSizeContext";
import type { TableSize } from "@/refresh-components/table/TableSizeContext";
import { cn } from "@opal/utils";
import { useTableSize } from "@opal/components/table/TableSizeContext";
import type { TableSize } from "@opal/components/table/TableSizeContext";
import { SvgUser } from "@opal/icons";
import type { IconFunctionComponent } from "@opal/types";
import type { QualifierContentType } from "@/refresh-components/table/types";
import type { QualifierContentType } from "@opal/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 = {
regular: 16,
small: 14,
lg: 16,
md: 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:opacity-100 group-focus-within:opacity-100 bg-background-tint-01",
"flex opacity-0 group-hover/row:opacity-100 group-focus-within/row:opacity-100 bg-background-tint-01",
overlayImage:
"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",
"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",
};
}
@@ -115,7 +115,7 @@ function TableQualifier({
<div
className={cn(
"flex items-center justify-center rounded-full bg-background-neutral-inverted-00",
resolvedSize === "regular" ? "h-7 w-7" : "h-6 w-6"
resolvedSize === "lg" ? "h-7 w-7" : "h-6 w-6"
)}
>
<Text
@@ -138,30 +138,36 @@ function TableQualifier({
<div
className={cn(
"group relative inline-flex shrink-0 items-center justify-center",
resolvedSize === "regular" ? "h-9 w-9" : "h-7 w-7",
resolvedSize === "lg" ? "h-9 w-9" : "h-7 w-7",
disabled ? "cursor-not-allowed" : "cursor-default",
className
)}
>
{/* 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>
{/* 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>
)}
{/* Selection overlay */}
{selectable && (
<div
className={cn(
"absolute inset-0 items-center justify-center",
isRound ? "rounded-full" : "rounded-08",
content === "simple"
? "flex"
: isRound
? "rounded-full"
: "rounded-08",
content === "simple"
? "flex"
: content === "image"

View File

@@ -1,8 +1,8 @@
"use client";
import { cn } from "@/lib/utils";
import { useTableSize } from "@/refresh-components/table/TableSizeContext";
import type { TableSize } from "@/refresh-components/table/TableSizeContext";
import { cn } from "@opal/utils";
import { useTableSize } from "@opal/components/table/TableSizeContext";
import type { TableSize } from "@opal/components/table/TableSizeContext";
import type { WithoutStyles } from "@/types";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
@@ -18,8 +18,6 @@ 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. */
@@ -36,7 +34,6 @@ function SortableTableRow({
sortableId,
showDragHandle = true,
size,
variant = "list",
selected,
disabled,
ref: _externalRef,
@@ -66,7 +63,6 @@ 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}
@@ -95,7 +91,7 @@ function SortableTableRow({
{...listeners}
>
<SvgHandle
size={resolvedSize === "small" ? 12 : 16}
size={resolvedSize === "md" ? 12 : 16}
className="text-border-02"
/>
</button>
@@ -113,7 +109,6 @@ function TableRow({
sortableId,
showDragHandle,
size,
variant = "list",
selected,
disabled,
ref,
@@ -125,7 +120,6 @@ function TableRow({
sortableId={sortableId}
showDragHandle={showDragHandle}
size={size}
variant={variant}
selected={selected}
disabled={disabled}
ref={ref}
@@ -138,7 +132,6 @@ function TableRow({
<tr
ref={ref}
className="tbl-row group/row"
data-variant={variant}
data-selected={selected || undefined}
data-disabled={disabled || undefined}
{...props}

View File

@@ -1,10 +1,11 @@
"use client";
import { createContext, useContext } from "react";
import type { SizeVariants } from "@opal/types";
type TableSize = "regular" | "small";
type TableSize = Extract<SizeVariants, "md" | "lg">;
const TableSizeContext = createContext<TableSize>("regular");
const TableSizeContext = createContext<TableSize>("lg");
interface TableSizeProviderProps {
size: TableSize;

View File

@@ -13,10 +13,10 @@ import type {
OnyxDataColumn,
OnyxDisplayColumn,
OnyxActionsColumn,
} from "@/refresh-components/table/types";
import type { TableSize } from "@/refresh-components/table/TableSizeContext";
} from "@opal/components/table/types";
import type { TableSize } from "@opal/components/table/TableSizeContext";
import type { IconFunctionComponent } from "@opal/types";
import type { SortDirection } from "@/refresh-components/table/TableHead";
import type { SortDirection } from "@opal/components/table/TableHead";
// ---------------------------------------------------------------------------
// Qualifier column config
@@ -160,7 +160,7 @@ export function createTableColumns<TData>(): TableColumnsBuilder<TData> {
id: "qualifier",
def,
width: (size: TableSize) =>
size === "small" ? { fixed: 40 } : { fixed: 56 },
size === "md" ? { fixed: 36 } : { fixed: 44 },
content,
headerContentType: config?.headerContentType,
getInitials: config?.getInitials,
@@ -241,14 +241,29 @@ 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) =>
size === "small" ? { fixed: 20 } : { fixed: 88 },
showColumnVisibility: config?.showColumnVisibility ?? true,
showSorting: config?.showSorting ?? true,
width: (size: TableSize) => ({
fixed:
Math.max(
buttonCount * (size === "md" ? BUTTON_SM : BUTTON_MD),
size === "md" ? BUTTON_SM : BUTTON_MD
) + PADDING,
}),
showColumnVisibility: showVisibility,
showSorting: showSorting,
sortingFooterText: config?.sortingFooterText,
};
},

View File

@@ -1,39 +1,56 @@
"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 "@/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";
} 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";
import type { ColumnDef } from "@tanstack/react-table";
import { cn } from "@/lib/utils";
import { cn } from "@opal/utils";
import type {
DataTableProps,
DataTableProps as BaseDataTableProps,
DataTableFooterConfig,
OnyxColumnDef,
OnyxDataColumn,
OnyxQualifierColumn,
OnyxActionsColumn,
} from "@/refresh-components/table/types";
import type { TableSize } from "@/refresh-components/table/TableSizeContext";
} 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;
};
// ---------------------------------------------------------------------------
// Internal: resolve size-dependent widths and build TanStack columns
@@ -57,6 +74,7 @@ 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 =
@@ -70,6 +88,12 @@ 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;
@@ -113,10 +137,10 @@ function processColumns<TData>(
* tc.actions(),
* ];
*
* <DataTable data={items} columns={columns} footer={{ mode: "selection" }} />
* <Table data={items} columns={columns} footer={{}} />
* ```
*/
export default function DataTable<TData>(props: DataTableProps<TData>) {
export function Table<TData>(props: DataTableProps<TData>) {
const {
data,
columns,
@@ -126,7 +150,10 @@ export default function DataTable<TData>(props: DataTableProps<TData>) {
initialColumnVisibility,
draggable,
footer,
size = "regular",
size = "lg",
variant = "cards",
qualifier = "simple",
selectionBehavior = "no-select",
onSelectionChange,
onRowClick,
searchTerm,
@@ -138,9 +165,37 @@ export default function DataTable<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(() => processColumns(columns, size), [columns, size]);
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]);
// 2. Call useDataTable
const {
@@ -155,7 +210,9 @@ export default function DataTable<TData>(props: DataTableProps<TData>) {
selectedRowIds,
clearSelection,
toggleAllPageRowsSelected,
toggleAllRowsSelected,
isAllPageRowsSelected,
isAllRowsSelected,
isViewingSelected,
enterViewMode,
exitViewMode,
@@ -193,16 +250,6 @@ export default function DataTable<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,
@@ -212,10 +259,11 @@ export default function DataTable<TData>(props: DataTableProps<TData>) {
});
const hasDraggable = !!effectiveDraggable;
const rowVariant = hasDraggable ? "table" : "list";
const isSelectable =
qualifierColumn != null && qualifierColumn.selectable !== false;
const isSelectable = selectionBehavior !== "no-select";
const isMultiSelect = selectionBehavior === "multi-select";
// Checkboxes appear for any selectable table
const showQualifierCheckbox = isSelectable;
// ---------------------------------------------------------------------------
// Render
@@ -224,11 +272,13 @@ export default function DataTable<TData>(props: DataTableProps<TData>) {
const isServerLoading = !!serverSide?.isLoading;
function renderFooter(footerConfig: DataTableFooterConfig) {
if (footerConfig.mode === "selection") {
// Mode derived from selectionBehavior — single/multi-select use selection
// footer, no-select uses summary footer.
if (isSelectable) {
return (
<Footer
mode="selection"
multiSelect={footerConfig.multiSelect !== false}
multiSelect={isMultiSelect}
selectionState={selectionState}
selectedCount={selectedCount}
onClear={
@@ -239,22 +289,24 @@ export default function DataTable<TData>(props: DataTableProps<TData>) {
})
}
onView={
footerConfig.showView
!serverSide
? isViewingSelected
? exitViewMode
: enterViewMode
: undefined
}
isViewingSelected={isViewingSelected}
pageSize={resolvedPageSize}
totalItems={totalItems}
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setPage}
units={footerConfig.units}
/>
);
}
// Summary mode
// Summary mode (no-select only)
const rangeStart =
totalItems === 0
? 0
@@ -275,6 +327,7 @@ export default function DataTable<TData>(props: DataTableProps<TData>) {
totalPages={totalPages}
onPageChange={setPage}
leftExtra={footerConfig.leftExtra}
units={footerConfig.units}
/>
);
}
@@ -303,7 +356,10 @@ export default function DataTable<TData>(props: DataTableProps<TData>) {
: undefined),
}}
>
<Table
<TableElement
size={size}
variant={variant}
selectionBehavior={selectionBehavior}
width={
Object.keys(columnWidths).length > 0
? Object.values(columnWidths).reduce((sum, w) => sum + w, 0)
@@ -311,7 +367,7 @@ export default function DataTable<TData>(props: DataTableProps<TData>) {
}
>
<colgroup>
{table.getAllLeafColumns().map((col) => (
{table.getVisibleLeafColumns().map((col) => (
<col
key={col.id}
style={
@@ -328,28 +384,26 @@ export default function DataTable<TData>(props: DataTableProps<TData>) {
{headerGroup.headers.map((header, headerIndex) => {
const colDef = columnKindMap.get(header.id);
// Qualifier header
// Qualifier header — select-all checkbox only for multi-select
if (colDef?.kind === "qualifier") {
if (qualifierColumn?.header === false) {
return (
<QualifierContainer key={header.id} type="head" />
);
}
return (
<QualifierContainer key={header.id} type="head">
<TableQualifier
content={
qualifierColumn?.headerContentType ?? "simple"
}
selectable={isSelectable}
selected={isSelectable && isAllPageRowsSelected}
onSelectChange={
isSelectable
? (checked) =>
toggleAllPageRowsSelected(checked)
: undefined
}
/>
{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);
}
}}
/>
)}
</QualifierContainer>
);
}
@@ -437,7 +491,6 @@ export default function DataTable<TData>(props: DataTableProps<TData>) {
return (
<DragOverlayRow
row={row}
variant={rowVariant}
columnWidths={columnWidths}
columnKindMap={columnKindMap}
qualifierColumn={qualifierColumn}
@@ -461,7 +514,6 @@ export default function DataTable<TData>(props: DataTableProps<TData>) {
return (
<TableRow
key={row.id}
variant={rowVariant}
sortableId={rowId}
selected={row.getIsSelected()}
onClick={() => {
@@ -474,6 +526,10 @@ export default function DataTable<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();
}
}}
@@ -484,6 +540,13 @@ export default function DataTable<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}
@@ -491,15 +554,20 @@ export default function DataTable<TData>(props: DataTableProps<TData>) {
onClick={(e) => e.stopPropagation()}
>
<TableQualifier
content={qDef.content}
content={qualifierContent}
initials={qDef.getInitials?.(row.original)}
icon={qDef.getIcon?.(row.original)}
imageSrc={qDef.getImageSrc?.(row.original)}
selectable={isSelectable}
selected={isSelectable && row.getIsSelected()}
selectable={showQualifierCheckbox}
selected={
showQualifierCheckbox && row.getIsSelected()
}
onSelectChange={
isSelectable
showQualifierCheckbox
? (checked) => {
if (!isMultiSelect) {
table.toggleAllRowsSelected(false);
}
row.toggleSelected(checked);
}
: undefined
@@ -539,7 +607,7 @@ export default function DataTable<TData>(props: DataTableProps<TData>) {
);
})}
</TableBody>
</Table>
</TableElement>
</div>
{footer && renderFooter(footer)}

View File

@@ -153,6 +153,10 @@ 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. */
@@ -407,6 +411,16 @@ 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;
@@ -439,8 +453,10 @@ export default function useDataTable<TData extends RowData>(
selectedCount,
selectedRowIds,
isAllPageRowsSelected,
isAllRowsSelected,
clearSelection,
toggleAllPageRowsSelected,
toggleAllRowsSelected,
isViewingSelected,
enterViewMode,
exitViewMode,

View File

@@ -0,0 +1,164 @@
/* 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];
}

View File

@@ -4,9 +4,10 @@ import type {
SortingState,
VisibilityState,
} from "@tanstack/react-table";
import type { TableSize } from "@/refresh-components/table/TableSizeContext";
import type { TableSize } from "@opal/components/table/TableSizeContext";
import type { TableVariant } from "@opal/components/table/TableElement";
import type { IconFunctionComponent } from "@opal/types";
import type { SortDirection } from "@/refresh-components/table/TableHead";
import type { SortDirection } from "@opal/components/table/TableHead";
// ---------------------------------------------------------------------------
// Column width (mirrors useColumnWidths types)
@@ -129,26 +130,16 @@ export interface DataTableDraggableConfig {
) => void | Promise<void>;
}
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. */
/** 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. */
onClear?: () => void;
}
export interface DataTableFooterSummary {
mode: "summary";
/** Optional extra element rendered after the summary text (e.g. a download icon). */
/** 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). */
leftExtra?: ReactNode;
}
export type DataTableFooterConfig =
| DataTableFooterSelection
| DataTableFooterSummary;
export interface DataTableProps<TData> {
/** Row data array. */
data: TData[];
@@ -166,8 +157,10 @@ export interface DataTableProps<TData> {
draggable?: DataTableDraggableConfig;
/** Footer configuration. */
footer?: DataTableFooterConfig;
/** Table size variant. @default "regular" */
/** Table size variant. @default "lg" */
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). */

View File

@@ -0,0 +1,162 @@
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>
),
};

View File

@@ -1,127 +0,0 @@
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>
),
};

View File

@@ -15,13 +15,21 @@
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 150ms ease-in-out,
--interactive-foreground 150ms ease-in-out,
--interactive-foreground-icon 150ms ease-in-out;
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);
}
.interactive[data-disabled] {
@apply cursor-not-allowed;

View File

@@ -2,7 +2,14 @@
"extends": "../../tsconfig.json",
"compilerOptions": {
"paths": {
"@opal/*": ["./src/*"]
"@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/*"]
}
},
"include": ["src/**/*"],

View File

@@ -0,0 +1 @@
export { default } from "@/refresh-pages/admin/GroupsPage";

View File

@@ -1,143 +0,0 @@
/* ---------------------------------------------------------------------------
* 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];
}

View File

@@ -0,0 +1 @@
export { default } from "@/refresh-pages/admin/GroupsPage";

View File

@@ -16,7 +16,6 @@
@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 */

View File

@@ -451,13 +451,19 @@ const ModalHeader = React.forwardRef<HTMLDivElement, ModalHeaderProps>(
);
return (
<Section ref={ref} padding={1} alignItems="start" height="fit" {...props}>
<Section
ref={ref}
padding={0.5}
alignItems="start"
height="fit"
{...props}
>
<Section
flexDirection="row"
justifyContent="between"
alignItems="start"
gap={0}
padding={0}
padding={0.5}
>
<div className="relative w-full">
{/* Close button is absolutely positioned because:
@@ -485,7 +491,6 @@ const ModalHeader = React.forwardRef<HTMLDivElement, ModalHeaderProps>(
</DialogPrimitive.Title>
</div>
</Section>
{children}
</Section>
);

View File

@@ -9,6 +9,8 @@ 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;
}
/**
@@ -37,7 +39,7 @@ const Separator = React.forwardRef(
{
noPadding,
paddingXRem,
paddingYRem,
className,
orientation = "horizontal",
decorative = true,
@@ -56,6 +58,12 @@ const Separator = React.forwardRef(
paddingRight: `${paddingXRem}rem`,
}
: {}),
...(paddingYRem != null
? {
paddingTop: `${paddingYRem}rem`,
paddingBottom: `${paddingYRem}rem`,
}
: {}),
}}
className={cn(
isHorizontal ? "w-full" : "h-full",

View File

@@ -112,9 +112,11 @@ function MemoryItem({
/>
</Disabled>
</Section>
{isFocused && (
<div
className={isFocused ? "visible" : "invisible h-0 overflow-hidden"}
>
<CharacterCount value={memory.content} limit={MAX_MEMORY_LENGTH} />
)}
</div>
</Section>
</div>
);

View File

@@ -1,462 +0,0 @@
# 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 |

View File

@@ -1,281 +0,0 @@
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>
),
};

View File

@@ -1,26 +0,0 @@
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 };

View File

@@ -0,0 +1,42 @@
"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;

View File

@@ -0,0 +1,54 @@
"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;

View File

@@ -0,0 +1,66 @@
"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;

View File

@@ -0,0 +1,5 @@
/** API helpers for the Groups list page. */
const USER_GROUP_URL = "/api/manage/admin/user-group";
export { USER_GROUP_URL };

View File

@@ -0,0 +1,57 @@
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"}`;
}

View File

@@ -1,8 +1,7 @@
"use client";
import { useMemo, useState } from "react";
import DataTable from "@/refresh-components/table/DataTable";
import { createTableColumns } from "@/refresh-components/table/columns";
import { Table, createTableColumns } from "@opal/components";
import { Content } from "@opal/layouts";
import { Button } from "@opal/components";
import { SvgDownload } from "@opal/icons";
@@ -216,10 +215,11 @@ export default function UsersTable({
roleCounts={roleCounts}
statusCounts={statusCounts}
/>
<DataTable
<Table
data={filteredUsers}
columns={columns}
getRowId={(row) => row.id ?? row.email}
qualifier="avatar"
pageSize={PAGE_SIZE}
searchTerm={searchTerm}
emptyState={
@@ -230,7 +230,6 @@ export default function UsersTable({
/>
}
footer={{
mode: "summary",
leftExtra: (
<Button
icon={SvgDownload}

View File

@@ -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 "./UsersPage/interfaces";
import type { StatusFilter } from "./interfaces";
import UsersSummary from "./UsersPage/UsersSummary";
import UsersTable from "./UsersPage/UsersTable";
import InviteUsersModal from "./UsersPage/InviteUsersModal";
import UsersSummary from "./UsersSummary";
import UsersTable from "./UsersTable";
import InviteUsersModal from "./InviteUsersModal";
// ---------------------------------------------------------------------------
// Users page content