mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-03-05 07:35:47 +00:00
Compare commits
1 Commits
main
...
nikg/std-e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42a7ec664c |
@@ -4,6 +4,7 @@ from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
from httpx_oauth.clients.google import GoogleOAuth2
|
||||
|
||||
from ee.onyx.configs.app_configs import LICENSE_ENFORCEMENT_ENABLED
|
||||
from ee.onyx.server.analytics.api import router as analytics_router
|
||||
from ee.onyx.server.auth_check import check_ee_router_auth
|
||||
from ee.onyx.server.billing.api import router as billing_router
|
||||
@@ -152,9 +153,12 @@ def get_application() -> FastAPI:
|
||||
# License management
|
||||
include_router_with_global_prefix_prepended(application, license_router)
|
||||
|
||||
# Unified billing API - always registered in EE.
|
||||
# Each endpoint is protected by the `current_admin_user` dependency (admin auth).
|
||||
include_router_with_global_prefix_prepended(application, billing_router)
|
||||
# Unified billing API - available when license system is enabled
|
||||
# Works for both self-hosted and cloud deployments
|
||||
# TODO(ENG-3533): Once frontend migrates to /admin/billing/*, this becomes the
|
||||
# primary billing API and /tenants/* billing endpoints can be removed
|
||||
if LICENSE_ENFORCEMENT_ENABLED:
|
||||
include_router_with_global_prefix_prepended(application, billing_router)
|
||||
|
||||
if MULTI_TENANT:
|
||||
# Tenant management
|
||||
|
||||
@@ -4,7 +4,6 @@ from typing import List
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi import Depends
|
||||
from fastapi import HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -22,6 +21,8 @@ from onyx.auth.users import current_user
|
||||
from onyx.configs.constants import PUBLIC_API_TAGS
|
||||
from onyx.db.engine.sql_engine import get_session
|
||||
from onyx.db.models import User
|
||||
from onyx.error_handling.error_codes import OnyxErrorCode
|
||||
from onyx.error_handling.exceptions import OnyxError
|
||||
|
||||
router = APIRouter(prefix="/analytics", tags=PUBLIC_API_TAGS)
|
||||
|
||||
@@ -231,8 +232,9 @@ def get_assistant_stats(
|
||||
end = end or datetime.datetime.utcnow()
|
||||
|
||||
if not user_can_view_assistant_stats(db_session, user, assistant_id):
|
||||
raise HTTPException(
|
||||
status_code=403, detail="Not allowed to access this assistant's stats."
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.INSUFFICIENT_PERMISSIONS,
|
||||
"Not allowed to access this assistant's stats.",
|
||||
)
|
||||
|
||||
# Pull daily usage from the DB calls
|
||||
|
||||
@@ -4,7 +4,6 @@ from typing import Any
|
||||
from typing import cast
|
||||
from typing import IO
|
||||
|
||||
from fastapi import HTTPException
|
||||
from fastapi import UploadFile
|
||||
|
||||
from ee.onyx.server.enterprise_settings.models import AnalyticsScriptUpload
|
||||
@@ -13,6 +12,8 @@ from onyx.configs.constants import FileOrigin
|
||||
from onyx.configs.constants import KV_CUSTOM_ANALYTICS_SCRIPT_KEY
|
||||
from onyx.configs.constants import KV_ENTERPRISE_SETTINGS_KEY
|
||||
from onyx.configs.constants import ONYX_DEFAULT_APPLICATION_NAME
|
||||
from onyx.error_handling.error_codes import OnyxErrorCode
|
||||
from onyx.error_handling.exceptions import OnyxError
|
||||
from onyx.file_store.file_store import get_default_file_store
|
||||
from onyx.key_value_store.factory import get_kv_store
|
||||
from onyx.key_value_store.interface import KvKeyNotFoundError
|
||||
@@ -118,9 +119,9 @@ def upload_logo(file: UploadFile | str, is_logotype: bool = False) -> bool:
|
||||
else:
|
||||
logger.notice("Uploading logo from uploaded file")
|
||||
if not file.filename or not is_valid_file_type(file.filename):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Invalid file type- only .png, .jpg, and .jpeg files are allowed",
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.VALIDATION_ERROR,
|
||||
"Invalid file type- only .png, .jpg, and .jpeg files are allowed",
|
||||
)
|
||||
content = file.file
|
||||
display_name = file.filename
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from fastapi import APIRouter
|
||||
from fastapi import Depends
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ee.onyx.db.standard_answer import fetch_standard_answer
|
||||
@@ -15,6 +14,8 @@ from ee.onyx.db.standard_answer import update_standard_answer_category
|
||||
from onyx.auth.users import current_admin_user
|
||||
from onyx.db.engine.sql_engine import get_session
|
||||
from onyx.db.models import User
|
||||
from onyx.error_handling.error_codes import OnyxErrorCode
|
||||
from onyx.error_handling.exceptions import OnyxError
|
||||
from onyx.server.manage.models import StandardAnswer
|
||||
from onyx.server.manage.models import StandardAnswerCategory
|
||||
from onyx.server.manage.models import StandardAnswerCategoryCreationRequest
|
||||
@@ -65,7 +66,7 @@ def patch_standard_answer(
|
||||
)
|
||||
|
||||
if existing_standard_answer is None:
|
||||
raise HTTPException(status_code=404, detail="Standard answer not found")
|
||||
raise OnyxError(OnyxErrorCode.NOT_FOUND, "Standard answer not found")
|
||||
|
||||
standard_answer_model = update_standard_answer(
|
||||
standard_answer_id=standard_answer_id,
|
||||
@@ -131,9 +132,7 @@ def patch_standard_answer_category(
|
||||
)
|
||||
|
||||
if existing_standard_answer_category is None:
|
||||
raise HTTPException(
|
||||
status_code=404, detail="Standard answer category not found"
|
||||
)
|
||||
raise OnyxError(OnyxErrorCode.NOT_FOUND, "Standard answer category not found")
|
||||
|
||||
standard_answer_category_model = update_standard_answer_category(
|
||||
standard_answer_category_id=standard_answer_category_id,
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi import Depends
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from onyx.auth.users import current_admin_user
|
||||
@@ -10,6 +9,8 @@ from onyx.db.engine.sql_engine import get_session
|
||||
from onyx.db.models import User
|
||||
from onyx.db.persona import get_default_assistant
|
||||
from onyx.db.persona import update_default_assistant_configuration
|
||||
from onyx.error_handling.error_codes import OnyxErrorCode
|
||||
from onyx.error_handling.exceptions import OnyxError
|
||||
from onyx.prompts.chat_prompts import DEFAULT_SYSTEM_PROMPT
|
||||
from onyx.server.features.default_assistant.models import DefaultAssistantConfiguration
|
||||
from onyx.server.features.default_assistant.models import DefaultAssistantUpdateRequest
|
||||
@@ -32,7 +33,7 @@ def get_default_assistant_configuration(
|
||||
"""
|
||||
persona = get_default_assistant(db_session)
|
||||
if not persona:
|
||||
raise HTTPException(status_code=404, detail="Default assistant not found")
|
||||
raise OnyxError(OnyxErrorCode.PERSONA_NOT_FOUND, "Default assistant not found")
|
||||
|
||||
# Extract DB tool IDs from the persona's tools
|
||||
tool_ids = [tool.id for tool in persona.tools]
|
||||
@@ -86,5 +87,5 @@ def update_default_assistant(
|
||||
|
||||
except ValueError as e:
|
||||
if "Default assistant not found" in str(e):
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
raise OnyxError(OnyxErrorCode.PERSONA_NOT_FOUND, str(e))
|
||||
raise OnyxError(OnyxErrorCode.VALIDATION_ERROR, str(e))
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from fastapi import APIRouter
|
||||
from fastapi import Depends
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from onyx.access.hierarchy_access import get_user_external_group_ids
|
||||
@@ -12,6 +11,8 @@ from onyx.db.engine.sql_engine import get_session
|
||||
from onyx.db.hierarchy import get_accessible_hierarchy_nodes_for_source
|
||||
from onyx.db.models import User
|
||||
from onyx.db.opensearch_migration import get_opensearch_retrieval_state
|
||||
from onyx.error_handling.error_codes import OnyxErrorCode
|
||||
from onyx.error_handling.exceptions import OnyxError
|
||||
from onyx.server.features.hierarchy.constants import DOCUMENT_PAGE_SIZE
|
||||
from onyx.server.features.hierarchy.constants import HIERARCHY_NODE_DOCUMENTS_PATH
|
||||
from onyx.server.features.hierarchy.constants import HIERARCHY_NODES_LIST_PATH
|
||||
@@ -43,14 +44,14 @@ router = APIRouter(prefix=HIERARCHY_NODES_PREFIX)
|
||||
|
||||
def _require_opensearch(db_session: Session) -> None:
|
||||
if not ENABLE_OPENSEARCH_INDEXING_FOR_ONYX:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=OPENSEARCH_NOT_ENABLED_MESSAGE,
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.INSUFFICIENT_PERMISSIONS,
|
||||
OPENSEARCH_NOT_ENABLED_MESSAGE,
|
||||
)
|
||||
if not get_opensearch_retrieval_state(db_session):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=MIGRATION_STATUS_MESSAGE,
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.INSUFFICIENT_PERMISSIONS,
|
||||
MIGRATION_STATUS_MESSAGE,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from fastapi import APIRouter
|
||||
from fastapi import Depends
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from onyx.auth.users import current_admin_user
|
||||
@@ -15,6 +14,8 @@ from onyx.db.input_prompt import remove_public_input_prompt
|
||||
from onyx.db.input_prompt import update_input_prompt
|
||||
from onyx.db.models import InputPrompt__User
|
||||
from onyx.db.models import User
|
||||
from onyx.error_handling.error_codes import OnyxErrorCode
|
||||
from onyx.error_handling.exceptions import OnyxError
|
||||
from onyx.server.features.input_prompt.models import CreateInputPromptRequest
|
||||
from onyx.server.features.input_prompt.models import InputPromptSnapshot
|
||||
from onyx.server.features.input_prompt.models import UpdateInputPromptRequest
|
||||
@@ -97,7 +98,7 @@ def patch_input_prompt(
|
||||
except ValueError as e:
|
||||
error_msg = "Error occurred while updated input prompt"
|
||||
logger.warn(f"{error_msg}. Stack trace: {e}")
|
||||
raise HTTPException(status_code=404, detail=error_msg)
|
||||
raise OnyxError(OnyxErrorCode.NOT_FOUND, error_msg)
|
||||
|
||||
return InputPromptSnapshot.from_model(updated_input_prompt)
|
||||
|
||||
@@ -117,7 +118,7 @@ def delete_input_prompt(
|
||||
except ValueError as e:
|
||||
error_msg = "Error occurred while deleting input prompt"
|
||||
logger.warn(f"{error_msg}. Stack trace: {e}")
|
||||
raise HTTPException(status_code=404, detail=error_msg)
|
||||
raise OnyxError(OnyxErrorCode.NOT_FOUND, error_msg)
|
||||
|
||||
|
||||
@admin_router.delete("/{input_prompt_id}")
|
||||
@@ -132,7 +133,7 @@ def delete_public_input_prompt(
|
||||
except ValueError as e:
|
||||
error_msg = "Error occurred while deleting input prompt"
|
||||
logger.warn(f"{error_msg}. Stack trace: {e}")
|
||||
raise HTTPException(status_code=404, detail=error_msg)
|
||||
raise OnyxError(OnyxErrorCode.NOT_FOUND, error_msg)
|
||||
|
||||
|
||||
@basic_router.post("/{input_prompt_id}/hide")
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from fastapi import APIRouter
|
||||
from fastapi import Depends
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from onyx.auth.users import current_user
|
||||
@@ -9,6 +8,8 @@ from onyx.db.models import User
|
||||
from onyx.db.notification import dismiss_notification
|
||||
from onyx.db.notification import get_notification_by_id
|
||||
from onyx.db.notification import get_notifications
|
||||
from onyx.error_handling.error_codes import OnyxErrorCode
|
||||
from onyx.error_handling.exceptions import OnyxError
|
||||
from onyx.server.features.build.utils import ensure_build_mode_intro_notification
|
||||
from onyx.server.features.release_notes.utils import (
|
||||
ensure_release_notes_fresh_and_notify,
|
||||
@@ -64,10 +65,11 @@ def dismiss_notification_endpoint(
|
||||
try:
|
||||
notification = get_notification_by_id(notification_id, user, db_session)
|
||||
except PermissionError:
|
||||
raise HTTPException(
|
||||
status_code=403, detail="Not authorized to dismiss this notification"
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.INSUFFICIENT_PERMISSIONS,
|
||||
"Not authorized to dismiss this notification",
|
||||
)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=404, detail="Notification not found")
|
||||
raise OnyxError(OnyxErrorCode.NOT_FOUND, "Notification not found")
|
||||
|
||||
dismiss_notification(notification, db_session)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from fastapi import APIRouter
|
||||
from fastapi import Depends
|
||||
from fastapi import HTTPException
|
||||
from fastapi_users.exceptions import InvalidPasswordException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -11,6 +10,8 @@ from onyx.auth.users import User
|
||||
from onyx.auth.users import UserManager
|
||||
from onyx.db.engine.sql_engine import get_session
|
||||
from onyx.db.users import get_user_by_email
|
||||
from onyx.error_handling.error_codes import OnyxErrorCode
|
||||
from onyx.error_handling.exceptions import OnyxError
|
||||
from onyx.server.features.password.models import ChangePasswordRequest
|
||||
from onyx.server.features.password.models import UserResetRequest
|
||||
from onyx.server.features.password.models import UserResetResponse
|
||||
@@ -34,10 +35,11 @@ async def change_my_password(
|
||||
new_password=form_data.new_password,
|
||||
)
|
||||
except InvalidPasswordException as e:
|
||||
raise HTTPException(status_code=400, detail=str(e.reason))
|
||||
raise OnyxError(OnyxErrorCode.VALIDATION_ERROR, str(e.reason))
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"An unexpected error occurred: {str(e)}"
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.INTERNAL_ERROR,
|
||||
f"An unexpected error occurred: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
@@ -53,7 +55,7 @@ async def admin_reset_user_password(
|
||||
"""
|
||||
user = get_user_by_email(user_reset_request.user_email, db_session)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
raise OnyxError(OnyxErrorCode.USER_NOT_FOUND, "User not found")
|
||||
new_password = await user_manager.reset_password_as_admin(user.id)
|
||||
return UserResetResponse(
|
||||
user_id=str(user.id),
|
||||
|
||||
@@ -3,7 +3,6 @@ import re
|
||||
|
||||
import requests
|
||||
from fastapi import APIRouter
|
||||
from fastapi import HTTPException
|
||||
|
||||
from onyx import __version__
|
||||
from onyx.auth.users import anonymous_user_enabled
|
||||
@@ -16,6 +15,8 @@ from onyx.configs.constants import DEV_VERSION_PATTERN
|
||||
from onyx.configs.constants import PUBLIC_API_TAGS
|
||||
from onyx.configs.constants import STABLE_VERSION_PATTERN
|
||||
from onyx.db.auth import get_user_count
|
||||
from onyx.error_handling.error_codes import OnyxErrorCode
|
||||
from onyx.error_handling.exceptions import OnyxError
|
||||
from onyx.server.manage.models import AllVersions
|
||||
from onyx.server.manage.models import AuthTypeResponse
|
||||
from onyx.server.manage.models import ContainerVersions
|
||||
@@ -104,14 +105,14 @@ def get_versions() -> AllVersions:
|
||||
|
||||
# Ensure we have at least one tag of each type
|
||||
if not dev_tags:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="No valid dev versions found matching pattern v(number).(number).(number)-beta.(number)",
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.INTERNAL_ERROR,
|
||||
"No valid dev versions found matching pattern v(number).(number).(number)-beta.(number)",
|
||||
)
|
||||
if not stable_tags:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="No valid stable versions found matching pattern v(number).(number).(number)",
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.INTERNAL_ERROR,
|
||||
"No valid stable versions found matching pattern v(number).(number).(number)",
|
||||
)
|
||||
|
||||
# Sort common tags and get the latest one
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi import Depends
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from onyx.auth.users import current_user
|
||||
@@ -11,6 +10,8 @@ from onyx.db.models import User
|
||||
from onyx.db.pat import create_pat
|
||||
from onyx.db.pat import list_user_pats
|
||||
from onyx.db.pat import revoke_pat
|
||||
from onyx.error_handling.error_codes import OnyxErrorCode
|
||||
from onyx.error_handling.exceptions import OnyxError
|
||||
from onyx.server.pat.models import CreatedTokenResponse
|
||||
from onyx.server.pat.models import CreateTokenRequest
|
||||
from onyx.server.pat.models import TokenResponse
|
||||
@@ -57,7 +58,7 @@ def create_token(
|
||||
expiration_days=request.expiration_days,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
raise OnyxError(OnyxErrorCode.VALIDATION_ERROR, str(e))
|
||||
|
||||
logger.info(f"User {user.email} created PAT '{request.name}'")
|
||||
|
||||
@@ -81,9 +82,7 @@ def delete_token(
|
||||
"""Delete (revoke) personal access token. Only owner can revoke their own tokens."""
|
||||
success = revoke_pat(db_session, token_id, user.id)
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=404, detail="Token not found or not owned by user"
|
||||
)
|
||||
raise OnyxError(OnyxErrorCode.NOT_FOUND, "Token not found or not owned by user")
|
||||
|
||||
logger.info(f"User {user.email} revoked token {token_id}")
|
||||
return {"message": "Token deleted successfully"}
|
||||
|
||||
@@ -60,11 +60,9 @@ class Settings(BaseModel):
|
||||
deep_research_enabled: bool | None = None
|
||||
search_ui_enabled: bool | None = None
|
||||
|
||||
# Whether EE features are unlocked for use.
|
||||
# Depends on license status: True when the user has a valid license
|
||||
# (ACTIVE, GRACE_PERIOD, PAYMENT_REMINDER), False when there's no license
|
||||
# or the license is expired (GATED_ACCESS).
|
||||
# This controls UI visibility of EE features (user groups, analytics, RBAC, etc.).
|
||||
# Enterprise features flag - set by license enforcement at runtime
|
||||
# When LICENSE_ENFORCEMENT_ENABLED=true, this reflects license status
|
||||
# When LICENSE_ENFORCEMENT_ENABLED=false, defaults to False
|
||||
ee_features_enabled: bool = False
|
||||
|
||||
temperature_override_enabled: bool | None = False
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from collections.abc import Callable
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from onyx.configs.app_configs import ANTHROPIC_DEFAULT_API_KEY
|
||||
@@ -12,6 +11,8 @@ from onyx.configs.app_configs import OPENROUTER_DEFAULT_API_KEY
|
||||
from onyx.db.usage import check_usage_limit
|
||||
from onyx.db.usage import UsageLimitExceededError
|
||||
from onyx.db.usage import UsageType
|
||||
from onyx.error_handling.error_codes import OnyxErrorCode
|
||||
from onyx.error_handling.exceptions import OnyxError
|
||||
from onyx.server.tenant_usage_limits import TenantUsageLimitKeys
|
||||
from onyx.server.tenant_usage_limits import TenantUsageLimitOverrides
|
||||
from onyx.utils.logger import setup_logger
|
||||
@@ -267,4 +268,4 @@ def check_usage_and_raise(
|
||||
"Please upgrade your plan or wait for the next billing period."
|
||||
)
|
||||
|
||||
raise HTTPException(status_code=429, detail=detail)
|
||||
raise OnyxError(OnyxErrorCode.RATE_LIMITED, detail)
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
"""Utilities for gating endpoints that require a vector database."""
|
||||
|
||||
from fastapi import HTTPException
|
||||
from starlette.status import HTTP_501_NOT_IMPLEMENTED
|
||||
|
||||
from onyx.configs.app_configs import DISABLE_VECTOR_DB
|
||||
from onyx.error_handling.error_codes import OnyxErrorCode
|
||||
from onyx.error_handling.exceptions import OnyxError
|
||||
|
||||
|
||||
def require_vector_db() -> None:
|
||||
"""FastAPI dependency — raises 501 when the vector DB is disabled."""
|
||||
if DISABLE_VECTOR_DB:
|
||||
raise HTTPException(
|
||||
status_code=HTTP_501_NOT_IMPLEMENTED,
|
||||
detail="This feature requires a vector database (DISABLE_VECTOR_DB is set).",
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.NOT_IMPLEMENTED,
|
||||
"This feature requires a vector database (DISABLE_VECTOR_DB is set).",
|
||||
)
|
||||
|
||||
@@ -281,10 +281,9 @@ class TestApplyLicenseStatusToSettings:
|
||||
}
|
||||
|
||||
|
||||
class TestSettingsDefaults:
|
||||
"""Verify Settings model defaults for CE deployments."""
|
||||
class TestSettingsDefaultEEDisabled:
|
||||
"""Verify the Settings model defaults ee_features_enabled to False."""
|
||||
|
||||
def test_default_ee_features_disabled(self) -> None:
|
||||
"""CE default: ee_features_enabled is False."""
|
||||
settings = Settings()
|
||||
assert settings.ee_features_enabled is False
|
||||
|
||||
190
web/src/app/admin/demo/table-qualifier/page.tsx
Normal file
190
web/src/app/admin/demo/table-qualifier/page.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import TableQualifier from "@/refresh-components/table/TableQualifier";
|
||||
import { TableSizeProvider } from "@/refresh-components/table/TableSizeContext";
|
||||
import type { TableSize } from "@/refresh-components/table/TableSizeContext";
|
||||
import type { QualifierContentType } from "@/refresh-components/table/types";
|
||||
import { SvgCheckCircle } from "@opal/icons";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Content type configurations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ContentConfig {
|
||||
label: string;
|
||||
content: QualifierContentType;
|
||||
extraProps: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const CONTENT_TYPES: ContentConfig[] = [
|
||||
{
|
||||
label: "Simple",
|
||||
content: "simple",
|
||||
extraProps: {},
|
||||
},
|
||||
{
|
||||
label: "Icon",
|
||||
content: "icon",
|
||||
extraProps: { icon: SvgCheckCircle },
|
||||
},
|
||||
{
|
||||
label: "Image",
|
||||
content: "image",
|
||||
extraProps: {
|
||||
imageSrc: "https://picsum.photos/36",
|
||||
imageAlt: "Placeholder",
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Avatar Icon",
|
||||
content: "avatar-icon",
|
||||
extraProps: {},
|
||||
},
|
||||
{
|
||||
label: "Avatar User",
|
||||
content: "avatar-user",
|
||||
extraProps: { initials: "AJ" },
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Row of qualifier states for a single content type
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface QualifierRowProps {
|
||||
config: ContentConfig;
|
||||
}
|
||||
|
||||
function QualifierRow({ config }: QualifierRowProps) {
|
||||
const [selectableSelected, setSelectableSelected] = useState(false);
|
||||
const [permanentSelected, setPermanentSelected] = useState(true);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Text mainUiAction text02>
|
||||
{config.label}
|
||||
</Text>
|
||||
|
||||
<div className="flex items-start gap-8">
|
||||
{/* Default */}
|
||||
<div className="flex w-20 flex-col items-center gap-2">
|
||||
<TableQualifier
|
||||
content={config.content}
|
||||
selectable={false}
|
||||
selected={false}
|
||||
disabled={false}
|
||||
{...config.extraProps}
|
||||
/>
|
||||
<Text secondaryBody text04>
|
||||
Default
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* Selectable (hover to reveal checkbox) */}
|
||||
<div className="flex w-20 flex-col items-center gap-2">
|
||||
<TableQualifier
|
||||
content={config.content}
|
||||
selectable={true}
|
||||
selected={selectableSelected}
|
||||
disabled={false}
|
||||
onSelectChange={setSelectableSelected}
|
||||
{...config.extraProps}
|
||||
/>
|
||||
<Text secondaryBody text04>
|
||||
Selectable
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* Selected */}
|
||||
<div className="flex w-20 flex-col items-center gap-2">
|
||||
<TableQualifier
|
||||
content={config.content}
|
||||
selectable={true}
|
||||
selected={permanentSelected}
|
||||
disabled={false}
|
||||
onSelectChange={setPermanentSelected}
|
||||
{...config.extraProps}
|
||||
/>
|
||||
<Text secondaryBody text04>
|
||||
Selected
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* Disabled (unselected) */}
|
||||
<div className="flex w-20 flex-col items-center gap-2">
|
||||
<TableQualifier
|
||||
content={config.content}
|
||||
selectable={true}
|
||||
selected={false}
|
||||
disabled={true}
|
||||
{...config.extraProps}
|
||||
/>
|
||||
<Text secondaryBody text04>
|
||||
Disabled
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* Disabled (selected) */}
|
||||
<div className="flex w-20 flex-col items-center gap-2">
|
||||
<TableQualifier
|
||||
content={config.content}
|
||||
selectable={true}
|
||||
selected={true}
|
||||
disabled={true}
|
||||
{...config.extraProps}
|
||||
/>
|
||||
<Text secondaryBody text04>
|
||||
Disabled+Sel
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Size section — all content types at a given size
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface SizeSectionProps {
|
||||
size: TableSize;
|
||||
title: string;
|
||||
}
|
||||
|
||||
function SizeSection({ size, title }: SizeSectionProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Text headingH3>{title}</Text>
|
||||
<TableSizeProvider size={size}>
|
||||
<div className="flex flex-col gap-8">
|
||||
{CONTENT_TYPES.map((config) => (
|
||||
<QualifierRow key={`${size}-${config.content}`} config={config} />
|
||||
))}
|
||||
</div>
|
||||
</TableSizeProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function TableQualifierDemoPage() {
|
||||
return (
|
||||
<div className="p-6 space-y-10">
|
||||
<div className="space-y-4">
|
||||
<Text headingH2>TableQualifier Demo</Text>
|
||||
<Text mainContentMuted text03>
|
||||
All content types, sizes, and interactive states. Hover selectable
|
||||
variants to reveal the checkbox; click to toggle.
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<SizeSection size="regular" title="Regular (36px)" />
|
||||
<SizeSection size="small" title="Small (28px)" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { FileDescriptor } from "@/app/app/interfaces";
|
||||
import "katex/dist/katex.min.css";
|
||||
import MessageSwitcher from "@/app/app/message/MessageSwitcher";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import { cn } from "@/lib/utils";
|
||||
import useScreenSize from "@/hooks/useScreenSize";
|
||||
import CopyIconButton from "@/refresh-components/buttons/CopyIconButton";
|
||||
import { Button } from "@opal/components";
|
||||
import { SvgEdit } from "@opal/icons";
|
||||
@@ -138,7 +137,6 @@ const HumanMessage = React.memo(function HumanMessage({
|
||||
const [content, setContent] = useState(initialContent);
|
||||
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const { isMobile } = useScreenSize();
|
||||
|
||||
// Use nodeId for switching (finding position in siblings)
|
||||
const indexInSiblings = otherMessagesCanSwitchTo?.indexOf(nodeId);
|
||||
@@ -170,104 +168,119 @@ const HumanMessage = React.memo(function HumanMessage({
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const copyEditButton = useMemo(
|
||||
() => (
|
||||
<div className="flex flex-row flex-shrink px-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<CopyIconButton
|
||||
getCopyText={() => content}
|
||||
prominence="tertiary"
|
||||
data-testid="HumanMessage/copy-button"
|
||||
/>
|
||||
<Button
|
||||
icon={SvgEdit}
|
||||
prominence="tertiary"
|
||||
tooltip="Edit"
|
||||
onClick={() => setIsEditing(true)}
|
||||
data-testid="HumanMessage/edit-button"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
[content]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
id="onyx-human-message"
|
||||
className="group flex flex-col justify-end w-full relative"
|
||||
>
|
||||
<FileDisplay alignBubble files={files || []} />
|
||||
{isEditing ? (
|
||||
<MessageEditing
|
||||
content={content}
|
||||
onSubmitEdit={(editedContent) => {
|
||||
// Don't update UI for edits that can't be persisted
|
||||
if (messageId === undefined || messageId === null) {
|
||||
setIsEditing(false);
|
||||
return;
|
||||
}
|
||||
onEdit?.(editedContent, messageId);
|
||||
setContent(editedContent);
|
||||
setIsEditing(false);
|
||||
}}
|
||||
onCancelEdit={() => setIsEditing(false)}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex justify-end">
|
||||
{onEdit && !isMobile && copyEditButton}
|
||||
<div className="md:max-w-[37.5rem]">
|
||||
<div
|
||||
className={
|
||||
"max-w-[30rem] md:max-w-[37.5rem] whitespace-break-spaces break-anywhere rounded-t-16 rounded-bl-16 bg-background-tint-02 py-2 px-3"
|
||||
<div className="md:flex md:flex-wrap relative justify-end break-words">
|
||||
{isEditing ? (
|
||||
<MessageEditing
|
||||
content={content}
|
||||
onSubmitEdit={(editedContent) => {
|
||||
// Don't update UI for edits that can't be persisted
|
||||
if (messageId === undefined || messageId === null) {
|
||||
setIsEditing(false);
|
||||
return;
|
||||
}
|
||||
onCopy={(e) => {
|
||||
const selection = window.getSelection();
|
||||
if (selection) {
|
||||
e.preventDefault();
|
||||
const text = selection
|
||||
.toString()
|
||||
.replace(/\n{2,}/g, "\n")
|
||||
.trim();
|
||||
e.clipboardData.setData("text/plain", text);
|
||||
onEdit?.(editedContent, messageId);
|
||||
setContent(editedContent);
|
||||
setIsEditing(false);
|
||||
}}
|
||||
onCancelEdit={() => setIsEditing(false)}
|
||||
/>
|
||||
) : typeof content === "string" ? (
|
||||
<>
|
||||
<div className="md:max-w-[37.5rem] flex basis-[100%] md:basis-auto justify-end md:order-1">
|
||||
<div
|
||||
className={
|
||||
"max-w-[30rem] md:max-w-[37.5rem] whitespace-break-spaces break-anywhere rounded-t-16 rounded-bl-16 bg-background-tint-02 py-2 px-3"
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
as="p"
|
||||
className="inline-block align-middle"
|
||||
mainContentBody
|
||||
onCopy={(e) => {
|
||||
const selection = window.getSelection();
|
||||
if (selection) {
|
||||
e.preventDefault();
|
||||
const text = selection
|
||||
.toString()
|
||||
.replace(/\n{2,}/g, "\n")
|
||||
.trim();
|
||||
e.clipboardData.setData("text/plain", text);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</Text>
|
||||
<Text
|
||||
as="p"
|
||||
className="inline-block align-middle"
|
||||
mainContentBody
|
||||
>
|
||||
{content}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{onEdit && !isEditing && (
|
||||
<div className="absolute md:relative right-0 z-content flex flex-row p-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<CopyIconButton
|
||||
getCopyText={() => content}
|
||||
prominence="tertiary"
|
||||
data-testid="HumanMessage/copy-button"
|
||||
/>
|
||||
<Button
|
||||
icon={SvgEdit}
|
||||
prominence="tertiary"
|
||||
tooltip="Edit"
|
||||
onClick={() => setIsEditing(true)}
|
||||
data-testid="HumanMessage/edit-button"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"my-auto",
|
||||
onEdit && !isEditing
|
||||
? "opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
: "invisible"
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
icon={SvgEdit}
|
||||
onClick={() => setIsEditing(true)}
|
||||
prominence="tertiary"
|
||||
tooltip="Edit"
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-auto rounded-lg p-1">{content}</div>
|
||||
</>
|
||||
)}
|
||||
<div className="md:min-w-[100%] flex justify-end order-1 mt-1">
|
||||
{currentMessageInd !== undefined &&
|
||||
onMessageSelection &&
|
||||
otherMessagesCanSwitchTo &&
|
||||
otherMessagesCanSwitchTo.length > 1 && (
|
||||
<MessageSwitcher
|
||||
disableForStreaming={disableSwitchingForStreaming}
|
||||
currentPage={currentMessageInd + 1}
|
||||
totalPages={otherMessagesCanSwitchTo.length}
|
||||
handlePrevious={() => {
|
||||
stopGenerating();
|
||||
const prevMessage = getPreviousMessage();
|
||||
if (prevMessage !== undefined) {
|
||||
onMessageSelection(prevMessage);
|
||||
}
|
||||
}}
|
||||
handleNext={() => {
|
||||
stopGenerating();
|
||||
const nextMessage = getNextMessage();
|
||||
if (nextMessage !== undefined) {
|
||||
onMessageSelection(nextMessage);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end pt-1">
|
||||
{!isEditing && onEdit && isMobile && copyEditButton}
|
||||
{currentMessageInd !== undefined &&
|
||||
onMessageSelection &&
|
||||
otherMessagesCanSwitchTo &&
|
||||
otherMessagesCanSwitchTo.length > 1 && (
|
||||
<MessageSwitcher
|
||||
disableForStreaming={disableSwitchingForStreaming}
|
||||
currentPage={currentMessageInd + 1}
|
||||
totalPages={otherMessagesCanSwitchTo.length}
|
||||
handlePrevious={() => {
|
||||
stopGenerating();
|
||||
const prevMessage = getPreviousMessage();
|
||||
if (prevMessage !== undefined) {
|
||||
onMessageSelection(prevMessage);
|
||||
}
|
||||
}}
|
||||
handleNext={() => {
|
||||
stopGenerating();
|
||||
const nextMessage = getNextMessage();
|
||||
if (nextMessage !== undefined) {
|
||||
onMessageSelection(nextMessage);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "@/hooks/useToast";
|
||||
|
||||
export default function EEFeatureRedirect() {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
toast.error(
|
||||
"This feature requires a license. Please upgrade your plan to access."
|
||||
);
|
||||
router.replace("/app");
|
||||
}, [router]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { SERVER_SIDE_ONLY__PAID_ENTERPRISE_FEATURES_ENABLED } from "@/lib/constants";
|
||||
import { fetchStandardSettingsSS } from "@/components/settings/lib";
|
||||
import EEFeatureRedirect from "@/app/ee/EEFeatureRedirect";
|
||||
|
||||
export default async function AdminLayout({
|
||||
children,
|
||||
@@ -9,7 +8,13 @@ export default async function AdminLayout({
|
||||
}) {
|
||||
// First check build-time constant (fast path)
|
||||
if (!SERVER_SIDE_ONLY__PAID_ENTERPRISE_FEATURES_ENABLED) {
|
||||
return <EEFeatureRedirect />;
|
||||
return (
|
||||
<div className="flex h-screen">
|
||||
<div className="mx-auto my-auto text-lg font-bold text-red-500">
|
||||
This functionality is only available in the Enterprise Edition :(
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Then check runtime license status (for license enforcement mode)
|
||||
@@ -26,7 +31,13 @@ export default async function AdminLayout({
|
||||
return children;
|
||||
}
|
||||
|
||||
return <EEFeatureRedirect />;
|
||||
return (
|
||||
<div className="flex h-screen">
|
||||
<div className="mx-auto my-auto text-lg font-bold text-red-500">
|
||||
This functionality requires an active Enterprise license.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -484,8 +484,12 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) {
|
||||
ref={chatInputBarRef}
|
||||
deepResearchEnabled={deepResearchEnabled}
|
||||
toggleDeepResearch={toggleDeepResearch}
|
||||
toggleDocumentSidebar={() => {}}
|
||||
filterManager={filterManager}
|
||||
llmManager={llmManager}
|
||||
removeDocs={() => {}}
|
||||
retrievalEnabled={retrievalEnabled}
|
||||
selectedDocuments={[]}
|
||||
initialMessage={message}
|
||||
stopGenerating={stopGenerating}
|
||||
onSubmit={handleChatInputSubmit}
|
||||
|
||||
@@ -23,7 +23,8 @@ export interface AppModeProviderProps {
|
||||
export function AppModeProvider({ children }: AppModeProviderProps) {
|
||||
const isPaidEnterpriseFeaturesEnabled = usePaidEnterpriseFeaturesEnabled();
|
||||
const { user } = useUser();
|
||||
const { isSearchModeAvailable } = useSettingsContext();
|
||||
const settings = useSettingsContext();
|
||||
const { isSearchModeAvailable } = settings;
|
||||
|
||||
const persistedMode = user?.preferences?.default_app_mode;
|
||||
const [appMode, setAppModeState] = useState<AppMode>("chat");
|
||||
|
||||
@@ -11,8 +11,21 @@ import {
|
||||
* Hook to fetch billing information from Stripe.
|
||||
*
|
||||
* Works for both cloud and self-hosted deployments:
|
||||
* - Cloud: fetches from /api/tenants/billing-information
|
||||
* - Cloud: fetches from /api/tenants/billing-information (legacy endpoint)
|
||||
* - Self-hosted: fetches from /api/admin/billing/billing-information
|
||||
*
|
||||
* Returns subscription status, seats, billing period, etc.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const { data, isLoading, error, refresh } = useBillingInformation();
|
||||
*
|
||||
* if (isLoading) return <Loading />;
|
||||
* if (error) return <Error />;
|
||||
* if (!data || !hasActiveSubscription(data)) return <NoSubscription />;
|
||||
*
|
||||
* return <BillingDetails billing={data} />;
|
||||
* ```
|
||||
*/
|
||||
export function useBillingInformation() {
|
||||
const url = NEXT_PUBLIC_CLOUD_ENABLED
|
||||
@@ -25,9 +38,16 @@ export function useBillingInformation() {
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
dedupingInterval: 30000,
|
||||
// Don't auto-retry on errors (circuit breaker will block requests anyway)
|
||||
shouldRetryOnError: false,
|
||||
// Keep previous data while revalidating to prevent UI flashing
|
||||
keepPreviousData: true,
|
||||
});
|
||||
|
||||
return { data, isLoading, error, refresh: mutate };
|
||||
return {
|
||||
data,
|
||||
isLoading,
|
||||
error,
|
||||
refresh: mutate,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,9 +7,23 @@ import { LicenseStatus } from "@/lib/billing/interfaces";
|
||||
/**
|
||||
* Hook to fetch license status for self-hosted deployments.
|
||||
*
|
||||
* Skips the fetch on cloud deployments (uses tenant auth instead).
|
||||
* Returns license information including seats, expiry, and status.
|
||||
* Only fetches for self-hosted deployments (cloud uses tenant auth instead).
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const { data, isLoading, error, refresh } = useLicense();
|
||||
*
|
||||
* if (isLoading) return <Loading />;
|
||||
* if (error) return <Error />;
|
||||
* if (!data?.has_license) return <NoLicense />;
|
||||
*
|
||||
* return <LicenseDetails license={data} />;
|
||||
* ```
|
||||
*/
|
||||
export function useLicense() {
|
||||
// Only fetch license for self-hosted deployments
|
||||
// Cloud deployments use tenant-based auth, not license files
|
||||
const url = NEXT_PUBLIC_CLOUD_ENABLED ? null : "/api/license";
|
||||
|
||||
const { data, error, mutate, isLoading } = useSWR<LicenseStatus>(
|
||||
@@ -24,14 +38,20 @@ export function useLicense() {
|
||||
}
|
||||
);
|
||||
|
||||
if (!url) {
|
||||
// Return empty state for cloud deployments
|
||||
if (NEXT_PUBLIC_CLOUD_ENABLED) {
|
||||
return {
|
||||
data: undefined,
|
||||
data: null,
|
||||
isLoading: false,
|
||||
error: undefined,
|
||||
refresh: () => Promise.resolve(undefined),
|
||||
};
|
||||
}
|
||||
|
||||
return { data, isLoading, error, refresh: mutate };
|
||||
return {
|
||||
data,
|
||||
isLoading,
|
||||
error,
|
||||
refresh: mutate,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -46,8 +46,8 @@ export interface Settings {
|
||||
// Onyx Craft (Build Mode) feature flag
|
||||
onyx_craft_enabled?: boolean;
|
||||
|
||||
// Whether EE features are unlocked (user has a valid enterprise license).
|
||||
// Controls UI visibility of EE features like user groups, analytics, RBAC.
|
||||
// Enterprise features flag - controlled by license enforcement at runtime
|
||||
// True when user has a valid license, False for community edition
|
||||
ee_features_enabled?: boolean;
|
||||
|
||||
// Seat usage - populated when seat limit is exceeded
|
||||
|
||||
@@ -190,17 +190,14 @@ function AttachmentItemLayout({
|
||||
alignItems="center"
|
||||
gap={1.5}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<Content
|
||||
title={title}
|
||||
description={description}
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
widthVariant="full"
|
||||
/>
|
||||
</div>
|
||||
<Content
|
||||
title={title}
|
||||
description={description}
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
/>
|
||||
{middleText && (
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex-1">
|
||||
<Truncated text03 secondaryBody>
|
||||
{middleText}
|
||||
</Truncated>
|
||||
|
||||
@@ -42,13 +42,8 @@ export const NEXT_PUBLIC_CUSTOM_REFRESH_URL =
|
||||
|
||||
// NOTE: this should ONLY be used on the server-side. If used client side,
|
||||
// it will not be accurate (will always be false).
|
||||
// Mirrors backend logic: EE is enabled if EITHER the legacy flag OR license
|
||||
// enforcement is active. LICENSE_ENFORCEMENT_ENABLED defaults to true on the
|
||||
// backend, so we treat undefined as enabled here to match.
|
||||
export const SERVER_SIDE_ONLY__PAID_ENTERPRISE_FEATURES_ENABLED =
|
||||
process.env.ENABLE_PAID_ENTERPRISE_EDITION_FEATURES?.toLowerCase() ===
|
||||
"true" ||
|
||||
process.env.LICENSE_ENFORCEMENT_ENABLED?.toLowerCase() !== "false";
|
||||
process.env.ENABLE_PAID_ENTERPRISE_EDITION_FEATURES?.toLowerCase() === "true";
|
||||
// NOTE: since this is a `NEXT_PUBLIC_` variable, it will be set at
|
||||
// build-time
|
||||
// TODO: consider moving this to an API call so that the api_server
|
||||
|
||||
@@ -51,6 +51,16 @@ function ToastContainer() {
|
||||
}, ANIMATION_DURATION);
|
||||
}, []);
|
||||
|
||||
// NOTE (@raunakab):
|
||||
//
|
||||
// Keep this here for debugging purposes.
|
||||
// useOnMount(() => {
|
||||
// toast.success("Test success toast", { duration: Infinity });
|
||||
// toast.error("Test error toast", { duration: Infinity });
|
||||
// toast.warning("Test warning toast", { duration: Infinity });
|
||||
// toast.info("Test info toast", { duration: Infinity });
|
||||
// });
|
||||
|
||||
if (visible.length === 0) return null;
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,455 +0,0 @@
|
||||
"use client";
|
||||
"use no memo";
|
||||
|
||||
import { 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";
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import type {
|
||||
DataTableProps,
|
||||
DataTableFooterConfig,
|
||||
OnyxColumnDef,
|
||||
OnyxDataColumn,
|
||||
OnyxQualifierColumn,
|
||||
OnyxActionsColumn,
|
||||
} from "@/refresh-components/table/types";
|
||||
import type { TableSize } from "@/refresh-components/table/TableSizeContext";
|
||||
|
||||
const noopGetRowId = () => "";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal: resolve size-dependent widths and build TanStack columns
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ProcessedColumns<TData> {
|
||||
tanstackColumns: ColumnDef<TData, any>[];
|
||||
widthConfig: WidthConfig;
|
||||
qualifierColumn: OnyxQualifierColumn<TData> | null;
|
||||
/** Map from column ID → OnyxColumnDef for dispatch in render loops. */
|
||||
columnKindMap: Map<string, OnyxColumnDef<TData>>;
|
||||
}
|
||||
|
||||
function processColumns<TData>(
|
||||
columns: OnyxColumnDef<TData>[],
|
||||
size: TableSize
|
||||
): ProcessedColumns<TData> {
|
||||
const tanstackColumns: ColumnDef<TData, any>[] = [];
|
||||
const fixedColumnIds = new Set<string>();
|
||||
const columnWeights: Record<string, number> = {};
|
||||
const columnMinWidths: Record<string, number> = {};
|
||||
const columnKindMap = new Map<string, OnyxColumnDef<TData>>();
|
||||
let qualifierColumn: OnyxQualifierColumn<TData> | null = null;
|
||||
|
||||
for (const col of columns) {
|
||||
const resolvedWidth =
|
||||
typeof col.width === "function" ? col.width(size) : col.width;
|
||||
|
||||
// Clone def to avoid mutating the caller's column definitions
|
||||
const clonedDef: ColumnDef<TData, any> = {
|
||||
...col.def,
|
||||
id: col.id,
|
||||
size:
|
||||
"fixed" in resolvedWidth ? resolvedWidth.fixed : resolvedWidth.weight,
|
||||
};
|
||||
|
||||
tanstackColumns.push(clonedDef);
|
||||
|
||||
const id = col.id;
|
||||
columnKindMap.set(id, col);
|
||||
|
||||
if ("fixed" in resolvedWidth) {
|
||||
fixedColumnIds.add(id);
|
||||
} else {
|
||||
columnWeights[id] = resolvedWidth.weight;
|
||||
columnMinWidths[id] = resolvedWidth.minWidth ?? 50;
|
||||
}
|
||||
|
||||
if (col.kind === "qualifier") qualifierColumn = col;
|
||||
}
|
||||
|
||||
return {
|
||||
tanstackColumns,
|
||||
widthConfig: { fixedColumnIds, columnWeights, columnMinWidths },
|
||||
qualifierColumn,
|
||||
columnKindMap,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DataTable component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Config-driven table component that wires together `useDataTable`,
|
||||
* `useColumnWidths`, and `useDraggableRows` automatically.
|
||||
*
|
||||
* Full flexibility via the column definitions from `createTableColumns()`.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const tc = createTableColumns<TeamMember>();
|
||||
* const columns = [
|
||||
* tc.qualifier({ content: "avatar-user", getInitials: (r) => r.initials }),
|
||||
* tc.column("name", { header: "Name", weight: 23, minWidth: 120 }),
|
||||
* tc.column("email", { header: "Email", weight: 28 }),
|
||||
* tc.actions(),
|
||||
* ];
|
||||
*
|
||||
* <DataTable data={items} columns={columns} footer={{ mode: "selection" }} />
|
||||
* ```
|
||||
*/
|
||||
export default function DataTable<TData>(props: DataTableProps<TData>) {
|
||||
const {
|
||||
data,
|
||||
columns,
|
||||
pageSize,
|
||||
initialSorting,
|
||||
initialColumnVisibility,
|
||||
draggable,
|
||||
footer,
|
||||
size = "regular",
|
||||
onRowClick,
|
||||
height,
|
||||
headerBackground,
|
||||
} = props;
|
||||
|
||||
const effectivePageSize = pageSize ?? (footer ? 10 : data.length);
|
||||
|
||||
// 1. Process columns (memoized on columns + size)
|
||||
const { tanstackColumns, widthConfig, qualifierColumn, columnKindMap } =
|
||||
useMemo(() => processColumns(columns, size), [columns, size]);
|
||||
|
||||
// 2. Call useDataTable
|
||||
const {
|
||||
table,
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalItems,
|
||||
setPage,
|
||||
pageSize: resolvedPageSize,
|
||||
selectionState,
|
||||
selectedCount,
|
||||
clearSelection,
|
||||
toggleAllPageRowsSelected,
|
||||
isAllPageRowsSelected,
|
||||
} = useDataTable({
|
||||
data,
|
||||
columns: tanstackColumns,
|
||||
pageSize: effectivePageSize,
|
||||
initialSorting,
|
||||
initialColumnVisibility,
|
||||
});
|
||||
|
||||
// 3. Call useColumnWidths
|
||||
const { containerRef, columnWidths, createResizeHandler } = useColumnWidths({
|
||||
headers: table.getHeaderGroups()[0]?.headers ?? [],
|
||||
...widthConfig,
|
||||
});
|
||||
|
||||
// 4. Call useDraggableRows (conditional)
|
||||
const draggableReturn = useDraggableRows({
|
||||
data,
|
||||
getRowId: draggable?.getRowId ?? noopGetRowId,
|
||||
enabled: !!draggable && table.getState().sorting.length === 0,
|
||||
onReorder: draggable?.onReorder,
|
||||
});
|
||||
|
||||
const hasDraggable = !!draggable;
|
||||
const rowVariant = hasDraggable ? "table" : "list";
|
||||
|
||||
const isSelectable =
|
||||
qualifierColumn != null && qualifierColumn.selectable !== false;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function renderContent() {
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className="overflow-x-auto"
|
||||
ref={containerRef}
|
||||
style={{
|
||||
...(height != null
|
||||
? {
|
||||
maxHeight:
|
||||
typeof height === "number" ? `${height}px` : height,
|
||||
overflowY: "auto" as const,
|
||||
}
|
||||
: undefined),
|
||||
...(headerBackground
|
||||
? ({
|
||||
"--table-header-bg": headerBackground,
|
||||
} as React.CSSProperties)
|
||||
: undefined),
|
||||
}}
|
||||
>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header, headerIndex) => {
|
||||
const colDef = columnKindMap.get(header.id);
|
||||
|
||||
// Qualifier header
|
||||
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
|
||||
}
|
||||
/>
|
||||
</QualifierContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// Actions header
|
||||
if (colDef?.kind === "actions") {
|
||||
const actionsDef = colDef as OnyxActionsColumn<TData>;
|
||||
return (
|
||||
<ActionsContainer key={header.id} type="head">
|
||||
{actionsDef.showColumnVisibility !== false && (
|
||||
<ColumnVisibilityPopover
|
||||
table={table}
|
||||
columnVisibility={
|
||||
table.getState().columnVisibility
|
||||
}
|
||||
size={size}
|
||||
/>
|
||||
)}
|
||||
{actionsDef.showSorting !== false && (
|
||||
<SortingPopover
|
||||
table={table}
|
||||
sorting={table.getState().sorting}
|
||||
size={size}
|
||||
footerText={actionsDef.sortingFooterText}
|
||||
/>
|
||||
)}
|
||||
</ActionsContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// Data / Display header
|
||||
const canSort = header.column.getCanSort();
|
||||
const sortDir = header.column.getIsSorted();
|
||||
const nextHeader = headerGroup.headers[headerIndex + 1];
|
||||
const canResize =
|
||||
header.column.getCanResize() &&
|
||||
!!nextHeader &&
|
||||
!widthConfig.fixedColumnIds.has(nextHeader.id);
|
||||
|
||||
const dataCol =
|
||||
colDef?.kind === "data"
|
||||
? (colDef as OnyxDataColumn<TData>)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
width={columnWidths[header.id]}
|
||||
sorted={
|
||||
canSort ? toOnyxSortDirection(sortDir) : undefined
|
||||
}
|
||||
onSort={
|
||||
canSort
|
||||
? () => header.column.toggleSorting()
|
||||
: undefined
|
||||
}
|
||||
icon={dataCol?.icon}
|
||||
resizable={canResize}
|
||||
onResizeStart={
|
||||
canResize
|
||||
? createResizeHandler(header.id, nextHeader.id)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
|
||||
<TableBody
|
||||
dndSortable={hasDraggable ? draggableReturn : undefined}
|
||||
renderDragOverlay={
|
||||
hasDraggable
|
||||
? (activeId) => {
|
||||
const row = table
|
||||
.getRowModel()
|
||||
.rows.find(
|
||||
(r) => draggable!.getRowId(r.original) === activeId
|
||||
);
|
||||
if (!row) return null;
|
||||
return <DragOverlayRow row={row} variant={rowVariant} />;
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{table.getRowModel().rows.map((row) => {
|
||||
const rowId = hasDraggable
|
||||
? draggable!.getRowId(row.original)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
variant={rowVariant}
|
||||
sortableId={rowId}
|
||||
selected={row.getIsSelected()}
|
||||
onClick={() => {
|
||||
if (onRowClick) {
|
||||
onRowClick(row.original);
|
||||
} else if (isSelectable) {
|
||||
row.toggleSelected();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => {
|
||||
const cellColDef = columnKindMap.get(cell.column.id);
|
||||
|
||||
// Qualifier cell
|
||||
if (cellColDef?.kind === "qualifier") {
|
||||
const qDef = cellColDef as OnyxQualifierColumn<TData>;
|
||||
return (
|
||||
<QualifierContainer
|
||||
key={cell.id}
|
||||
type="cell"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<TableQualifier
|
||||
content={qDef.content}
|
||||
initials={qDef.getInitials?.(row.original)}
|
||||
icon={qDef.getIcon?.(row.original)}
|
||||
imageSrc={qDef.getImageSrc?.(row.original)}
|
||||
selectable={isSelectable}
|
||||
selected={isSelectable && row.getIsSelected()}
|
||||
onSelectChange={
|
||||
isSelectable
|
||||
? (checked) => {
|
||||
row.toggleSelected(checked);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</QualifierContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// Actions cell
|
||||
if (cellColDef?.kind === "actions") {
|
||||
return (
|
||||
<ActionsContainer key={cell.id} type="cell">
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</ActionsContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// Data / Display cell
|
||||
return (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{footer && renderFooter(footer)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderFooter(footerConfig: DataTableFooterConfig) {
|
||||
if (footerConfig.mode === "selection") {
|
||||
return (
|
||||
<Footer
|
||||
mode="selection"
|
||||
multiSelect={footerConfig.multiSelect !== false}
|
||||
selectionState={selectionState}
|
||||
selectedCount={selectedCount}
|
||||
onClear={footerConfig.onClear ?? clearSelection}
|
||||
onView={footerConfig.onView}
|
||||
pageSize={resolvedPageSize}
|
||||
totalItems={totalItems}
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Summary mode
|
||||
const rangeStart =
|
||||
totalItems === 0
|
||||
? 0
|
||||
: !isFinite(resolvedPageSize)
|
||||
? 1
|
||||
: (currentPage - 1) * resolvedPageSize + 1;
|
||||
const rangeEnd = !isFinite(resolvedPageSize)
|
||||
? totalItems
|
||||
: Math.min(currentPage * resolvedPageSize, totalItems);
|
||||
|
||||
return (
|
||||
<Footer
|
||||
mode="summary"
|
||||
rangeStart={rangeStart}
|
||||
rangeEnd={rangeEnd}
|
||||
totalItems={totalItems}
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <TableSizeProvider size={size}>{renderContent()}</TableSizeProvider>;
|
||||
}
|
||||
@@ -1,317 +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 |
|
||||
|
||||
Width is fixed: 88px at `"regular"`, 20px at `"small"`.
|
||||
|
||||
```ts
|
||||
tc.actions({
|
||||
sortingFooterText: "Everyone will see agents in this order.",
|
||||
})
|
||||
```
|
||||
|
||||
## 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) |
|
||||
|
||||
## 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 |
|
||||
|
||||
## 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>
|
||||
```
|
||||
|
||||
### 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 |
|
||||
@@ -830,8 +830,12 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
|
||||
ref={chatInputBarRef}
|
||||
deepResearchEnabled={deepResearchEnabled}
|
||||
toggleDeepResearch={toggleDeepResearch}
|
||||
toggleDocumentSidebar={toggleDocumentSidebar}
|
||||
filterManager={filterManager}
|
||||
llmManager={llmManager}
|
||||
removeDocs={() => setSelectedDocuments([])}
|
||||
retrievalEnabled={retrievalEnabled}
|
||||
selectedDocuments={selectedDocuments}
|
||||
initialMessage={
|
||||
searchParams?.get(SEARCH_PARAM_NAMES.USER_PROMPT) ||
|
||||
""
|
||||
|
||||
@@ -173,21 +173,19 @@ export function FileCard({
|
||||
removeFile && doneUploading ? () => removeFile(file.id) : undefined
|
||||
}
|
||||
>
|
||||
<div className="min-w-0 max-w-[12rem]">
|
||||
<div className="max-w-[12rem]">
|
||||
<Interactive.Container border heightVariant="fit">
|
||||
<div className="[&_.opal-content-md-body]:min-w-0 [&_.opal-content-md-title]:break-all">
|
||||
<AttachmentItemLayout
|
||||
icon={isProcessing ? SimpleLoader : SvgFileText}
|
||||
title={file.name}
|
||||
description={
|
||||
isProcessing
|
||||
? file.status === UserFileStatus.UPLOADING
|
||||
? "Uploading..."
|
||||
: "Processing..."
|
||||
: typeLabel
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<AttachmentItemLayout
|
||||
icon={isProcessing ? SimpleLoader : SvgFileText}
|
||||
title={file.name}
|
||||
description={
|
||||
isProcessing
|
||||
? file.status === UserFileStatus.UPLOADING
|
||||
? "Uploading..."
|
||||
: "Processing..."
|
||||
: typeLabel
|
||||
}
|
||||
/>
|
||||
<Spacer horizontal rem={0.5} />
|
||||
</Interactive.Container>
|
||||
</div>
|
||||
|
||||
@@ -16,18 +16,16 @@ import { FilterManager, LlmManager, useFederatedConnectors } from "@/lib/hooks";
|
||||
import usePromptShortcuts from "@/hooks/usePromptShortcuts";
|
||||
import useFilter from "@/hooks/useFilter";
|
||||
import useCCPairs from "@/hooks/useCCPairs";
|
||||
import { MinimalOnyxDocument } from "@/lib/search/interfaces";
|
||||
import { OnyxDocument, MinimalOnyxDocument } from "@/lib/search/interfaces";
|
||||
import { ChatState } from "@/app/app/interfaces";
|
||||
import { useForcedTools } from "@/lib/hooks/useForcedTools";
|
||||
import { useAppMode } from "@/providers/AppModeProvider";
|
||||
import useAppFocus from "@/hooks/useAppFocus";
|
||||
import { cn, isImageFile } from "@/lib/utils";
|
||||
import { getFormattedDateRangeString } from "@/lib/dateUtils";
|
||||
import { truncateString, cn, isImageFile } from "@/lib/utils";
|
||||
import { Disabled } from "@/refresh-components/Disabled";
|
||||
import { useUser } from "@/providers/UserProvider";
|
||||
import {
|
||||
SettingsContext,
|
||||
useVectorDbEnabled,
|
||||
} from "@/providers/SettingsProvider";
|
||||
import { SettingsContext } from "@/providers/SettingsProvider";
|
||||
import { useProjectsContext } from "@/providers/ProjectsContext";
|
||||
import { FileCard } from "@/sections/cards/FileCard";
|
||||
import {
|
||||
@@ -42,6 +40,9 @@ import {
|
||||
} from "@/app/app/services/actionUtils";
|
||||
import {
|
||||
SvgArrowUp,
|
||||
SvgCalendar,
|
||||
SvgFiles,
|
||||
SvgFileText,
|
||||
SvgGlobe,
|
||||
SvgHourglass,
|
||||
SvgPlus,
|
||||
@@ -50,22 +51,64 @@ import {
|
||||
SvgStop,
|
||||
SvgX,
|
||||
} from "@opal/icons";
|
||||
import { Button } from "@opal/components";
|
||||
import { Button, OpenButton } from "@opal/components";
|
||||
import Popover from "@/refresh-components/Popover";
|
||||
import SimpleLoader from "@/refresh-components/loaders/SimpleLoader";
|
||||
import { useQueryController } from "@/providers/QueryControllerProvider";
|
||||
import { Section } from "@/layouts/general-layouts";
|
||||
import Spacer from "@/refresh-components/Spacer";
|
||||
|
||||
const LINE_HEIGHT = 24;
|
||||
const MIN_INPUT_HEIGHT = 44;
|
||||
const MAX_INPUT_HEIGHT = 200;
|
||||
|
||||
export interface SourceChipProps {
|
||||
icon?: React.ReactNode;
|
||||
title: string;
|
||||
onRemove?: () => void;
|
||||
onClick?: () => void;
|
||||
truncateTitle?: boolean;
|
||||
}
|
||||
|
||||
export function SourceChip({
|
||||
icon,
|
||||
title,
|
||||
onRemove,
|
||||
onClick,
|
||||
truncateTitle = true,
|
||||
}: SourceChipProps) {
|
||||
return (
|
||||
<div
|
||||
onClick={onClick ? onClick : undefined}
|
||||
className={cn(
|
||||
"flex-none flex items-center px-1 bg-background-neutral-01 text-xs text-text-04 border border-border-01 rounded-08 box-border gap-x-1 h-6",
|
||||
onClick && "cursor-pointer"
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
{truncateTitle ? truncateString(title, 20) : title}
|
||||
{onRemove && (
|
||||
<SvgX
|
||||
size={12}
|
||||
className="text-text-01 ml-auto cursor-pointer"
|
||||
onClick={(e: React.MouseEvent<SVGSVGElement>) => {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface AppInputBarHandle {
|
||||
reset: () => void;
|
||||
focus: () => void;
|
||||
}
|
||||
|
||||
export interface AppInputBarProps {
|
||||
removeDocs: () => void;
|
||||
selectedDocuments: OnyxDocument[];
|
||||
initialMessage?: string;
|
||||
stopGenerating: () => void;
|
||||
onSubmit: (message: string) => void;
|
||||
@@ -77,8 +120,10 @@ export interface AppInputBarProps {
|
||||
// agents
|
||||
selectedAgent: MinimalPersonaSnapshot | undefined;
|
||||
|
||||
toggleDocumentSidebar: () => void;
|
||||
handleFileUpload: (files: File[]) => void;
|
||||
filterManager: FilterManager;
|
||||
retrievalEnabled: boolean;
|
||||
deepResearchEnabled: boolean;
|
||||
setPresentingDocument?: (document: MinimalOnyxDocument) => void;
|
||||
toggleDeepResearch: () => void;
|
||||
@@ -92,13 +137,18 @@ export interface AppInputBarProps {
|
||||
|
||||
const AppInputBar = React.memo(
|
||||
({
|
||||
retrievalEnabled,
|
||||
removeDocs,
|
||||
toggleDocumentSidebar,
|
||||
filterManager,
|
||||
selectedDocuments,
|
||||
initialMessage = "",
|
||||
stopGenerating,
|
||||
onSubmit,
|
||||
chatState,
|
||||
currentSessionFileTokenCount,
|
||||
availableContextTokens,
|
||||
// agents
|
||||
selectedAgent,
|
||||
|
||||
handleFileUpload,
|
||||
@@ -115,9 +165,6 @@ const AppInputBar = React.memo(
|
||||
// Internal message state - kept local to avoid parent re-renders on every keystroke
|
||||
const [message, setMessage] = useState(initialMessage);
|
||||
const textAreaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const textAreaWrapperRef = useRef<HTMLDivElement>(null);
|
||||
const filesWrapperRef = useRef<HTMLDivElement>(null);
|
||||
const filesContentRef = useRef<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const { user } = useUser();
|
||||
const { isClassifying, classification } = useQueryController();
|
||||
@@ -131,16 +178,6 @@ const AppInputBar = React.memo(
|
||||
textAreaRef.current?.focus();
|
||||
},
|
||||
}));
|
||||
|
||||
// Sync non-empty prop changes to internal state (e.g. NRFPage reads URL params
|
||||
// after mount). Intentionally skips empty strings — clearing is handled via the
|
||||
// imperative ref.reset() method, not by passing initialMessage="".
|
||||
useEffect(() => {
|
||||
if (initialMessage) {
|
||||
setMessage(initialMessage);
|
||||
}
|
||||
}, [initialMessage]);
|
||||
|
||||
const { appMode } = useAppMode();
|
||||
const appFocus = useAppFocus();
|
||||
const isSearchMode =
|
||||
@@ -190,39 +227,46 @@ const AppInputBar = React.memo(
|
||||
|
||||
const combinedSettings = useContext(SettingsContext);
|
||||
|
||||
// TODO(@raunakab): Replace this useEffect with CSS `field-sizing: content` once
|
||||
// Firefox ships it unflagged (currently behind `layout.css.field-sizing.enabled`).
|
||||
// Auto-resize textarea based on content (chat mode only).
|
||||
// Reset to min-height first so scrollHeight reflects actual content size,
|
||||
// then clamp between min and max. This handles both growing and shrinking.
|
||||
useEffect(() => {
|
||||
const wrapper = textAreaWrapperRef.current;
|
||||
const textarea = textAreaRef.current;
|
||||
if (!wrapper || !textarea) return;
|
||||
// Track previous message to detect when lines might decrease
|
||||
const prevMessageRef = useRef("");
|
||||
|
||||
wrapper.style.height = `${MIN_INPUT_HEIGHT}px`;
|
||||
wrapper.style.height = `${Math.min(
|
||||
Math.max(textarea.scrollHeight, MIN_INPUT_HEIGHT),
|
||||
MAX_INPUT_HEIGHT
|
||||
)}px`;
|
||||
// Auto-resize textarea based on content
|
||||
useEffect(() => {
|
||||
if (isSearchMode) return;
|
||||
const textarea = textAreaRef.current;
|
||||
if (textarea) {
|
||||
const prevLineCount = (prevMessageRef.current.match(/\n/g) || [])
|
||||
.length;
|
||||
const currLineCount = (message.match(/\n/g) || []).length;
|
||||
const lineRemoved = currLineCount < prevLineCount;
|
||||
prevMessageRef.current = message;
|
||||
|
||||
if (message.length === 0) {
|
||||
textarea.style.height = `${MIN_INPUT_HEIGHT}px`;
|
||||
return;
|
||||
} else if (lineRemoved) {
|
||||
const linesRemoved = prevLineCount - currLineCount;
|
||||
textarea.style.height = `${Math.max(
|
||||
MIN_INPUT_HEIGHT,
|
||||
Math.min(
|
||||
textarea.scrollHeight - LINE_HEIGHT * linesRemoved,
|
||||
MAX_INPUT_HEIGHT
|
||||
)
|
||||
)}px`;
|
||||
} else {
|
||||
textarea.style.height = `${Math.min(
|
||||
textarea.scrollHeight,
|
||||
MAX_INPUT_HEIGHT
|
||||
)}px`;
|
||||
}
|
||||
}
|
||||
}, [message, isSearchMode]);
|
||||
|
||||
// Animate attached files wrapper to its content height so CSS transitions
|
||||
// can interpolate between concrete pixel values (0px ↔ Npx).
|
||||
const showFiles = !isSearchMode && currentMessageFiles.length > 0;
|
||||
useEffect(() => {
|
||||
const wrapper = filesWrapperRef.current;
|
||||
const content = filesContentRef.current;
|
||||
if (!wrapper || !content) return;
|
||||
|
||||
if (showFiles) {
|
||||
// Measure the inner content's actual height, then add padding (p-1 = 8px total)
|
||||
const PADDING = 8;
|
||||
wrapper.style.height = `${content.offsetHeight + PADDING}px`;
|
||||
} else {
|
||||
wrapper.style.height = "0px";
|
||||
if (initialMessage) {
|
||||
setMessage(initialMessage);
|
||||
}
|
||||
}, [showFiles, currentMessageFiles]);
|
||||
}, [initialMessage]);
|
||||
|
||||
function handlePaste(event: React.ClipboardEvent) {
|
||||
const items = event.clipboardData?.items;
|
||||
@@ -250,7 +294,8 @@ const AppInputBar = React.memo(
|
||||
);
|
||||
|
||||
const { activePromptShortcuts } = usePromptShortcuts();
|
||||
const vectorDbEnabled = useVectorDbEnabled();
|
||||
const vectorDbEnabled =
|
||||
combinedSettings?.settings.vector_db_enabled !== false;
|
||||
const { ccPairs, isLoading: ccPairsLoading } = useCCPairs(vectorDbEnabled);
|
||||
const { data: federatedConnectorsData, isLoading: federatedLoading } =
|
||||
useFederatedConnectors();
|
||||
@@ -367,9 +412,7 @@ const AppInputBar = React.memo(
|
||||
combinedSettings?.settings?.deep_research_enabled,
|
||||
]);
|
||||
|
||||
function handleKeyDownForPromptShortcuts(
|
||||
e: React.KeyboardEvent<HTMLTextAreaElement>
|
||||
) {
|
||||
function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
|
||||
if (!user?.preferences?.shortcut_enabled || !showPrompts) return;
|
||||
|
||||
if (e.key === "Enter") {
|
||||
@@ -404,171 +447,6 @@ const AppInputBar = React.memo(
|
||||
}
|
||||
}
|
||||
|
||||
const chatControls = (
|
||||
<div
|
||||
{...(isSearchMode ? { inert: true } : {})}
|
||||
className={cn(
|
||||
"flex justify-between items-center w-full",
|
||||
isSearchMode
|
||||
? "opacity-0 p-0 h-0 overflow-hidden pointer-events-none"
|
||||
: "opacity-100 p-1 h-[2.75rem] pointer-events-auto",
|
||||
"transition-all duration-150"
|
||||
)}
|
||||
>
|
||||
{/* Bottom left controls */}
|
||||
<div className="flex flex-row items-center">
|
||||
{/* (+) button - always visible */}
|
||||
<FilePickerPopover
|
||||
onFileClick={handleFileClick}
|
||||
onPickRecent={(file: ProjectFile) => {
|
||||
// Check if file with same ID already exists
|
||||
if (
|
||||
!currentMessageFiles.some(
|
||||
(existingFile) => existingFile.file_id === file.file_id
|
||||
)
|
||||
) {
|
||||
setCurrentMessageFiles((prev) => [...prev, file]);
|
||||
}
|
||||
}}
|
||||
onUnpickRecent={(file: ProjectFile) => {
|
||||
setCurrentMessageFiles((prev) =>
|
||||
prev.filter(
|
||||
(existingFile) => existingFile.file_id !== file.file_id
|
||||
)
|
||||
);
|
||||
}}
|
||||
handleUploadChange={handleUploadChange}
|
||||
trigger={(open) => (
|
||||
<Button
|
||||
icon={SvgPlusCircle}
|
||||
tooltip="Attach Files"
|
||||
transient={open}
|
||||
disabled={disabled}
|
||||
prominence="tertiary"
|
||||
/>
|
||||
)}
|
||||
selectedFileIds={currentMessageFiles.map((f) => f.id)}
|
||||
/>
|
||||
|
||||
{/* Controls that load in when data is ready */}
|
||||
<div
|
||||
data-testid="actions-container"
|
||||
className={cn(
|
||||
"flex flex-row items-center",
|
||||
controlsLoading && "invisible"
|
||||
)}
|
||||
>
|
||||
{selectedAgent && selectedAgent.tools.length > 0 && (
|
||||
<ActionsPopover
|
||||
selectedAgent={selectedAgent}
|
||||
filterManager={filterManager}
|
||||
availableSources={memoizedAvailableSources}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
{onToggleTabReading ? (
|
||||
<Button
|
||||
icon={SvgGlobe}
|
||||
onClick={onToggleTabReading}
|
||||
variant="select"
|
||||
selected={tabReadingEnabled}
|
||||
foldable={!tabReadingEnabled}
|
||||
disabled={disabled}
|
||||
>
|
||||
{tabReadingEnabled
|
||||
? currentTabUrl
|
||||
? (() => {
|
||||
try {
|
||||
return new URL(currentTabUrl).hostname;
|
||||
} catch {
|
||||
return currentTabUrl;
|
||||
}
|
||||
})()
|
||||
: "Reading tab..."
|
||||
: "Read this tab"}
|
||||
</Button>
|
||||
) : (
|
||||
showDeepResearch && (
|
||||
<Button
|
||||
icon={SvgHourglass}
|
||||
onClick={toggleDeepResearch}
|
||||
variant="select"
|
||||
selected={deepResearchEnabled}
|
||||
foldable={!deepResearchEnabled}
|
||||
disabled={disabled}
|
||||
>
|
||||
Deep Research
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
|
||||
{selectedAgent &&
|
||||
forcedToolIds.length > 0 &&
|
||||
forcedToolIds.map((toolId) => {
|
||||
const tool = selectedAgent.tools.find(
|
||||
(tool) => tool.id === toolId
|
||||
);
|
||||
if (!tool) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
key={toolId}
|
||||
icon={getIconForAction(tool)}
|
||||
onClick={() => {
|
||||
setForcedToolIds(
|
||||
forcedToolIds.filter((id) => id !== toolId)
|
||||
);
|
||||
}}
|
||||
variant="select"
|
||||
selected
|
||||
disabled={disabled}
|
||||
>
|
||||
{tool.display_name}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom right controls */}
|
||||
<div className="flex flex-row items-center gap-1">
|
||||
<div
|
||||
data-testid="AppInputBar/llm-popover-trigger"
|
||||
className={cn(controlsLoading && "invisible")}
|
||||
>
|
||||
<LLMPopover
|
||||
llmManager={llmManager}
|
||||
requiresImageInput={hasImageFiles}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
id="onyx-chat-input-send-button"
|
||||
icon={
|
||||
isClassifying
|
||||
? SimpleLoader
|
||||
: chatState === "input"
|
||||
? SvgArrowUp
|
||||
: SvgStop
|
||||
}
|
||||
disabled={
|
||||
(chatState === "input" && !message) ||
|
||||
hasUploadingFiles ||
|
||||
isClassifying
|
||||
}
|
||||
onClick={() => {
|
||||
if (chatState == "streaming") {
|
||||
stopGenerating();
|
||||
} else if (message) {
|
||||
onSubmit(message);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Disabled disabled={disabled} allowClick>
|
||||
<div
|
||||
@@ -589,17 +467,8 @@ const AppInputBar = React.memo(
|
||||
)}
|
||||
>
|
||||
{/* Attached Files */}
|
||||
<div
|
||||
ref={filesWrapperRef}
|
||||
{...(!showFiles ? { inert: true } : {})}
|
||||
className={cn(
|
||||
"transition-all duration-150",
|
||||
showFiles
|
||||
? "opacity-100 p-1"
|
||||
: "opacity-0 p-0 overflow-hidden pointer-events-none"
|
||||
)}
|
||||
>
|
||||
<div ref={filesContentRef} className="flex flex-wrap gap-1">
|
||||
{currentMessageFiles.length > 0 && (
|
||||
<div className="p-2 rounded-t-16 flex flex-wrap gap-1">
|
||||
{currentMessageFiles.map((file) => (
|
||||
<FileCard
|
||||
key={file.id}
|
||||
@@ -611,61 +480,76 @@ const AppInputBar = React.memo(
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-row items-center w-full">
|
||||
{/* Input area */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-row items-center w-full",
|
||||
isSearchMode && "p-1"
|
||||
)}
|
||||
>
|
||||
<Popover
|
||||
open={user?.preferences?.shortcut_enabled && showPrompts}
|
||||
onOpenChange={setShowPrompts}
|
||||
>
|
||||
<Popover.Anchor asChild>
|
||||
<div
|
||||
ref={textAreaWrapperRef}
|
||||
className="px-3 py-2 flex-1 flex h-[2.75rem]"
|
||||
>
|
||||
<textarea
|
||||
id="onyx-chat-input-textarea"
|
||||
role="textarea"
|
||||
ref={textAreaRef}
|
||||
onPaste={handlePaste}
|
||||
onKeyDownCapture={handleKeyDownForPromptShortcuts}
|
||||
onChange={handleInputChange}
|
||||
className={cn(
|
||||
"p-[2px] w-full h-full outline-none bg-transparent resize-none placeholder:text-text-03 whitespace-pre-wrap break-words",
|
||||
"overflow-y-auto"
|
||||
)}
|
||||
autoFocus
|
||||
rows={1}
|
||||
style={{ scrollbarWidth: "thin" }}
|
||||
aria-multiline={true}
|
||||
placeholder={
|
||||
isSearchMode
|
||||
? "Search connected sources"
|
||||
: "How can I help you today?"
|
||||
}
|
||||
value={message}
|
||||
onKeyDown={(event) => {
|
||||
<textarea
|
||||
onPaste={handlePaste}
|
||||
onKeyDownCapture={handleKeyDown}
|
||||
onChange={handleInputChange}
|
||||
ref={textAreaRef}
|
||||
id="onyx-chat-input-textarea"
|
||||
className={cn(
|
||||
"w-full",
|
||||
"outline-none",
|
||||
"bg-transparent",
|
||||
"resize-none",
|
||||
"placeholder:text-text-03",
|
||||
"whitespace-pre-wrap",
|
||||
"break-word",
|
||||
"overscroll-contain",
|
||||
"px-3",
|
||||
isSearchMode
|
||||
? "h-[40px] py-2.5 overflow-hidden"
|
||||
: [
|
||||
"h-[44px]", // Fixed initial height to prevent flash - useEffect will adjust as needed
|
||||
"overflow-y-auto",
|
||||
"pb-2",
|
||||
"pt-3",
|
||||
]
|
||||
)}
|
||||
autoFocus
|
||||
style={{ scrollbarWidth: "thin" }}
|
||||
role="textarea"
|
||||
aria-multiline
|
||||
placeholder={
|
||||
isSearchMode
|
||||
? "Search connected sources"
|
||||
: "How can I help you today"
|
||||
}
|
||||
value={message}
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
event.key === "Enter" &&
|
||||
!showPrompts &&
|
||||
!event.shiftKey &&
|
||||
!(event.nativeEvent as any).isComposing
|
||||
) {
|
||||
event.preventDefault();
|
||||
if (
|
||||
event.key === "Enter" &&
|
||||
!showPrompts &&
|
||||
!event.shiftKey &&
|
||||
!(event.nativeEvent as any).isComposing
|
||||
message &&
|
||||
!disabled &&
|
||||
!isClassifying &&
|
||||
!hasUploadingFiles
|
||||
) {
|
||||
event.preventDefault();
|
||||
if (
|
||||
message &&
|
||||
!disabled &&
|
||||
!isClassifying &&
|
||||
!hasUploadingFiles
|
||||
) {
|
||||
onSubmit(message);
|
||||
}
|
||||
onSubmit(message);
|
||||
}
|
||||
}}
|
||||
suppressContentEditableWarning={true}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
}}
|
||||
suppressContentEditableWarning={true}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Popover.Anchor>
|
||||
|
||||
<Popover.Content
|
||||
@@ -732,7 +616,214 @@ const AppInputBar = React.memo(
|
||||
)}
|
||||
</div>
|
||||
|
||||
{chatControls}
|
||||
{/* Source chips */}
|
||||
{(selectedDocuments.length > 0 ||
|
||||
filterManager.timeRange ||
|
||||
filterManager.selectedDocumentSets.length > 0) && (
|
||||
<div className="flex gap-x-.5 px-2">
|
||||
<div className="flex gap-x-1 px-2 overflow-visible overflow-x-scroll items-end miniscroll">
|
||||
{filterManager.timeRange && (
|
||||
<SourceChip
|
||||
truncateTitle={false}
|
||||
key="time-range"
|
||||
icon={<SvgCalendar size={12} />}
|
||||
title={`${getFormattedDateRangeString(
|
||||
filterManager.timeRange.from,
|
||||
filterManager.timeRange.to
|
||||
)}`}
|
||||
onRemove={() => {
|
||||
filterManager.setTimeRange(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{filterManager.selectedDocumentSets.length > 0 &&
|
||||
filterManager.selectedDocumentSets.map((docSet, index) => (
|
||||
<SourceChip
|
||||
key={`doc-set-${index}`}
|
||||
icon={<SvgFiles size={16} />}
|
||||
title={docSet}
|
||||
onRemove={() => {
|
||||
filterManager.setSelectedDocumentSets(
|
||||
filterManager.selectedDocumentSets.filter(
|
||||
(ds) => ds !== docSet
|
||||
)
|
||||
);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{selectedDocuments.length > 0 && (
|
||||
<SourceChip
|
||||
key="selected-documents"
|
||||
onClick={() => {
|
||||
toggleDocumentSidebar();
|
||||
}}
|
||||
icon={<SvgFileText size={16} />}
|
||||
title={`${selectedDocuments.length} selected`}
|
||||
onRemove={removeDocs}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isSearchMode && (
|
||||
<div className="flex justify-between items-center w-full p-1 min-h-[40px]">
|
||||
{/* Bottom left controls */}
|
||||
<div className="flex flex-row items-center">
|
||||
{/* (+) button - always visible */}
|
||||
<FilePickerPopover
|
||||
onFileClick={handleFileClick}
|
||||
onPickRecent={(file: ProjectFile) => {
|
||||
// Check if file with same ID already exists
|
||||
if (
|
||||
!currentMessageFiles.some(
|
||||
(existingFile) => existingFile.file_id === file.file_id
|
||||
)
|
||||
) {
|
||||
setCurrentMessageFiles((prev) => [...prev, file]);
|
||||
}
|
||||
}}
|
||||
onUnpickRecent={(file: ProjectFile) => {
|
||||
setCurrentMessageFiles((prev) =>
|
||||
prev.filter(
|
||||
(existingFile) => existingFile.file_id !== file.file_id
|
||||
)
|
||||
);
|
||||
}}
|
||||
handleUploadChange={handleUploadChange}
|
||||
trigger={(open) => (
|
||||
<Button
|
||||
icon={SvgPlusCircle}
|
||||
tooltip="Attach Files"
|
||||
transient={open}
|
||||
disabled={disabled}
|
||||
prominence="tertiary"
|
||||
/>
|
||||
)}
|
||||
selectedFileIds={currentMessageFiles.map((f) => f.id)}
|
||||
/>
|
||||
|
||||
{/* Controls that load in when data is ready */}
|
||||
<div
|
||||
data-testid="actions-container"
|
||||
className={cn(
|
||||
"flex flex-row items-center",
|
||||
controlsLoading && "invisible"
|
||||
)}
|
||||
>
|
||||
{selectedAgent && selectedAgent.tools.length > 0 && (
|
||||
<ActionsPopover
|
||||
selectedAgent={selectedAgent}
|
||||
filterManager={filterManager}
|
||||
availableSources={memoizedAvailableSources}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
{onToggleTabReading ? (
|
||||
<Button
|
||||
icon={SvgGlobe}
|
||||
onClick={onToggleTabReading}
|
||||
variant="select"
|
||||
selected={tabReadingEnabled}
|
||||
foldable={!tabReadingEnabled}
|
||||
disabled={disabled}
|
||||
>
|
||||
{tabReadingEnabled
|
||||
? currentTabUrl
|
||||
? (() => {
|
||||
try {
|
||||
return new URL(currentTabUrl).hostname;
|
||||
} catch {
|
||||
return currentTabUrl;
|
||||
}
|
||||
})()
|
||||
: "Reading tab..."
|
||||
: "Read this tab"}
|
||||
</Button>
|
||||
) : (
|
||||
showDeepResearch && (
|
||||
<Button
|
||||
icon={SvgHourglass}
|
||||
onClick={toggleDeepResearch}
|
||||
variant="select"
|
||||
selected={deepResearchEnabled}
|
||||
foldable={!deepResearchEnabled}
|
||||
disabled={disabled}
|
||||
>
|
||||
Deep Research
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
|
||||
{selectedAgent &&
|
||||
forcedToolIds.length > 0 &&
|
||||
forcedToolIds.map((toolId) => {
|
||||
const tool = selectedAgent.tools.find(
|
||||
(tool) => tool.id === toolId
|
||||
);
|
||||
if (!tool) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
key={toolId}
|
||||
icon={getIconForAction(tool)}
|
||||
onClick={() => {
|
||||
setForcedToolIds(
|
||||
forcedToolIds.filter((id) => id !== toolId)
|
||||
);
|
||||
}}
|
||||
variant="select"
|
||||
selected
|
||||
disabled={disabled}
|
||||
>
|
||||
{tool.display_name}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom right controls */}
|
||||
<div className="flex flex-row items-center gap-1">
|
||||
{/* LLM popover - loads when ready */}
|
||||
<div
|
||||
data-testid="AppInputBar/llm-popover-trigger"
|
||||
className={cn(controlsLoading && "invisible")}
|
||||
>
|
||||
<LLMPopover
|
||||
llmManager={llmManager}
|
||||
requiresImageInput={hasImageFiles}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Submit button */}
|
||||
<Button
|
||||
id="onyx-chat-input-send-button"
|
||||
icon={
|
||||
isClassifying
|
||||
? SimpleLoader
|
||||
: chatState === "input"
|
||||
? SvgArrowUp
|
||||
: SvgStop
|
||||
}
|
||||
disabled={
|
||||
(chatState === "input" && !message) ||
|
||||
hasUploadingFiles ||
|
||||
isClassifying
|
||||
}
|
||||
onClick={() => {
|
||||
if (chatState == "streaming") {
|
||||
stopGenerating();
|
||||
} else if (message) {
|
||||
onSubmit(message);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Disabled>
|
||||
);
|
||||
|
||||
@@ -116,6 +116,8 @@ function ViewerOpenApiToolCard({ tool }: { tool: ToolSnapshot }) {
|
||||
);
|
||||
}
|
||||
|
||||
const EMPTY_DOCS: [] = [];
|
||||
|
||||
/**
|
||||
* Floating ChatInputBar below the AgentViewerModal.
|
||||
* On submit, navigates to the agent's chat with the message pre-filled.
|
||||
@@ -135,10 +137,14 @@ function AgentChatInput({ agent, onSubmit }: AgentChatInputProps) {
|
||||
chatState="input"
|
||||
filterManager={filterManager}
|
||||
selectedAgent={agent}
|
||||
selectedDocuments={EMPTY_DOCS}
|
||||
removeDocs={() => {}}
|
||||
stopGenerating={() => {}}
|
||||
handleFileUpload={() => {}}
|
||||
toggleDocumentSidebar={() => {}}
|
||||
currentSessionFileTokenCount={0}
|
||||
availableContextTokens={Infinity}
|
||||
retrievalEnabled={false}
|
||||
deepResearchEnabled={false}
|
||||
toggleDeepResearch={() => {}}
|
||||
disabled={false}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import { test, expect } from "@tests/e2e/fixtures/eeFeatures";
|
||||
|
||||
test.describe("EE Feature Redirect", () => {
|
||||
test("redirects to /chat with toast when EE features are not licensed", async ({
|
||||
page,
|
||||
eeEnabled,
|
||||
}) => {
|
||||
test.skip(eeEnabled, "Redirect only happens without Enterprise license");
|
||||
|
||||
await page.goto("/admin/theme");
|
||||
|
||||
await expect(page).toHaveURL(/\/chat/, { timeout: 10_000 });
|
||||
|
||||
const toastContainer = page.getByTestId("toast-container");
|
||||
await expect(toastContainer).toBeVisible({ timeout: 5_000 });
|
||||
await expect(
|
||||
toastContainer.getByText(/only accessible with a paid license/i)
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { test, expect } from "@tests/e2e/fixtures/eeFeatures";
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { loginAs } from "@tests/e2e/utils/auth";
|
||||
|
||||
test.describe("Appearance Theme Settings @exclusive", () => {
|
||||
@@ -12,21 +12,24 @@ test.describe("Appearance Theme Settings @exclusive", () => {
|
||||
consentPrompt: "I agree to the terms",
|
||||
};
|
||||
|
||||
test.beforeEach(async ({ page, eeEnabled }) => {
|
||||
test.skip(
|
||||
!eeEnabled,
|
||||
"Enterprise license not active — skipping theme tests"
|
||||
);
|
||||
|
||||
// Fresh session — the eeEnabled fixture already logged in to check the
|
||||
// setting, so clear cookies and re-login for a clean test state.
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.context().clearCookies();
|
||||
await loginAs(page, "admin");
|
||||
|
||||
// Navigate first so localStorage is accessible (API-based login
|
||||
// doesn't navigate, leaving the page on about:blank).
|
||||
await page.goto("/admin/theme");
|
||||
await expect(
|
||||
page.locator('[data-label="application-name-input"]')
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Skip the entire test when Enterprise features are not licensed.
|
||||
// The /admin/theme page is gated behind ee_features_enabled and
|
||||
// renders a license-required message instead of the settings form.
|
||||
const eeLocked = page.getByText(
|
||||
"This functionality requires an active Enterprise license."
|
||||
);
|
||||
if (await eeLocked.isVisible({ timeout: 1000 }).catch(() => false)) {
|
||||
test.skip(true, "Enterprise license not active — skipping theme tests");
|
||||
}
|
||||
|
||||
// Clear localStorage to ensure consent modal shows
|
||||
await page.evaluate(() => {
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
/**
|
||||
* Playwright fixture that detects EE (Enterprise Edition) license state.
|
||||
*
|
||||
* Usage:
|
||||
* ```ts
|
||||
* import { test, expect } from "@tests/e2e/fixtures/eeFeatures";
|
||||
*
|
||||
* test("my EE-gated test", async ({ page, eeEnabled }) => {
|
||||
* test.skip(!eeEnabled, "Requires active Enterprise license");
|
||||
* // ... rest of test
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* The fixture:
|
||||
* - Authenticates as admin
|
||||
* - Fetches /api/settings to check ee_features_enabled
|
||||
* - Provides a boolean to the test BEFORE any navigation happens
|
||||
*
|
||||
* This lets tests call test.skip() synchronously at the top, which is the
|
||||
* correct Playwright pattern — never navigate then decide to skip.
|
||||
*/
|
||||
|
||||
import { test as base, expect } from "@playwright/test";
|
||||
import { loginAs } from "@tests/e2e/utils/auth";
|
||||
|
||||
export const test = base.extend<{
|
||||
/** Whether EE features are enabled (valid enterprise license). */
|
||||
eeEnabled: boolean;
|
||||
}>({
|
||||
eeEnabled: async ({ page }, use) => {
|
||||
await loginAs(page, "admin");
|
||||
const res = await page.request.get("/api/settings");
|
||||
if (!res.ok()) {
|
||||
// Fail open — if we can't determine, assume EE is not enabled
|
||||
await use(false);
|
||||
return;
|
||||
}
|
||||
const settings = await res.json();
|
||||
await use(settings.ee_features_enabled === true);
|
||||
},
|
||||
});
|
||||
|
||||
export { expect };
|
||||
Reference in New Issue
Block a user