Compare commits

..

13 Commits

Author SHA1 Message Date
Raunak Bhagat
50dde0be1a chore: edit AGENTS.md and CLAUDE.md files (#9486) 2026-03-20 00:59:30 +00:00
acaprau
199e1df453 feat(opensearch): Add functions for keyword and semantic retrieval (#9479)
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2026-03-20 00:48:01 +00:00
Justin Tahara
996b674840 feat(backend): Adding procps (#9509) 2026-03-19 23:26:36 +00:00
Justin Tahara
5413723ccc feat(ods): Rerun run-ci workflow (#9501) 2026-03-19 22:11:59 +00:00
Evan Lohn
9660056a51 fix: drive rate limit retry (#9498) 2026-03-19 21:32:08 +00:00
Fizza Mukhtar
3105177238 fix(llm): don't send tool_choice when no tools are provided (#9224) 2026-03-19 21:26:46 +00:00
Evan Lohn
24bb4bda8b feat: windows installer and install improvements (#9476) 2026-03-19 20:47:44 +00:00
Raunak Bhagat
9532af4ceb chore: move Hoverable story (#9495) 2026-03-19 20:40:27 +00:00
Jamison Lahman
0a913f6af5 fix(fe): fix memories immediately losing focus on click (#9493) 2026-03-19 20:15:34 +00:00
Justin Tahara
fe30c55199 fix(code interpreter): Caching files (#9484) 2026-03-19 19:32:37 +00:00
Jamison Lahman
2cf0a65dd3 chore(fe): reduce padding on elements at the bottom of modal headers (#9488) 2026-03-19 19:27:37 +00:00
Nikolas Garza
659416f363 feat(admin): groups page - list page and group cards (#9453) 2026-03-19 18:23:15 +00:00
Raunak Bhagat
40aecbc4b9 refactor(fe): move table to opal, update size API (#9438) 2026-03-19 17:23:41 +00:00
74 changed files with 4013 additions and 2704 deletions

279
AGENTS.md
View File

@@ -167,284 +167,7 @@ web/
## Frontend Standards
### 1. Import Standards
**Always use absolute imports with the `@` prefix.**
**Reason:** Moving files around becomes easier since you don't also have to update those import statements. This makes modifications to the codebase much nicer.
```typescript
// ✅ Good
import { Button } from "@/components/ui/button";
import { useAuth } from "@/hooks/useAuth";
import { Text } from "@/refresh-components/texts/Text";
// ❌ Bad
import { Button } from "../../../components/ui/button";
import { useAuth } from "./hooks/useAuth";
```
### 2. React Component Functions
**Prefer regular functions over arrow functions for React components.**
**Reason:** Functions just become easier to read.
```typescript
// ✅ Good
function UserProfile({ userId }: UserProfileProps) {
return <div>User Profile</div>
}
// ❌ Bad
const UserProfile = ({ userId }: UserProfileProps) => {
return <div>User Profile</div>
}
```
### 3. Props Interface Extraction
**Extract prop types into their own interface definitions.**
**Reason:** Functions just become easier to read.
```typescript
// ✅ Good
interface UserCardProps {
user: User
showActions?: boolean
onEdit?: (userId: string) => void
}
function UserCard({ user, showActions = false, onEdit }: UserCardProps) {
return <div>User Card</div>
}
// ❌ Bad
function UserCard({
user,
showActions = false,
onEdit
}: {
user: User
showActions?: boolean
onEdit?: (userId: string) => void
}) {
return <div>User Card</div>
}
```
### 4. Spacing Guidelines
**Prefer padding over margins for spacing.**
**Reason:** We want to consolidate usage to paddings instead of margins.
```typescript
// ✅ Good
<div className="p-4 space-y-2">
<div className="p-2">Content</div>
</div>
// ❌ Bad
<div className="m-4 space-y-2">
<div className="m-2">Content</div>
</div>
```
### 5. Tailwind Dark Mode
**Strictly forbid using the `dark:` modifier in Tailwind classes, except for logo icon handling.**
**Reason:** The `colors.css` file already, VERY CAREFULLY, defines what the exact opposite colour of each light-mode colour is. Overriding this behaviour is VERY bad and will lead to horrible UI breakages.
**Exception:** The `createLogoIcon` helper in `web/src/components/icons/icons.tsx` uses `dark:` modifiers (`dark:invert`, `dark:hidden`, `dark:block`) to handle third-party logo icons that cannot automatically adapt through `colors.css`. This is the ONLY acceptable use of dark mode modifiers.
```typescript
// ✅ Good - Standard components use `tailwind-themes/tailwind.config.js` / `src/app/css/colors.css`
<div className="bg-background-neutral-03 text-text-02">
Content
</div>
// ✅ Good - Logo icons with dark mode handling via createLogoIcon
export const GithubIcon = createLogoIcon(githubLightIcon, {
monochromatic: true, // Will apply dark:invert internally
});
export const GitbookIcon = createLogoIcon(gitbookLightIcon, {
darkSrc: gitbookDarkIcon, // Will use dark:hidden/dark:block internally
});
// ❌ Bad - Manual dark mode overrides
<div className="bg-white dark:bg-black text-black dark:text-white">
Content
</div>
```
### 6. Class Name Utilities
**Use the `cn` utility instead of raw string formatting for classNames.**
**Reason:** `cn`s are easier to read. They also allow for more complex types (i.e., string-arrays) to get formatted properly (it flattens each element in that string array down). As a result, it can allow things such as conditionals (i.e., `myCondition && "some-tailwind-class"`, which evaluates to `false` when `myCondition` is `false`) to get filtered out.
```typescript
import { cn } from '@/lib/utils'
// ✅ Good
<div className={cn(
'base-class',
isActive && 'active-class',
className
)}>
Content
</div>
// ❌ Bad
<div className={`base-class ${isActive ? 'active-class' : ''} ${className}`}>
Content
</div>
```
### 7. Custom Hooks Organization
**Follow a "hook-per-file" layout. Each hook should live in its own file within `web/src/hooks`.**
**Reason:** This is just a layout preference. Keeps code clean.
```typescript
// web/src/hooks/useUserData.ts
export function useUserData(userId: string) {
// hook implementation
}
// web/src/hooks/useLocalStorage.ts
export function useLocalStorage<T>(key: string, initialValue: T) {
// hook implementation
}
```
### 8. Icon Usage
**ONLY use icons from the `web/src/icons` directory. Do NOT use icons from `react-icons`, `lucide`, or other external libraries.**
**Reason:** We have a very carefully curated selection of icons that match our Onyx guidelines. We do NOT want to muddy those up with different aesthetic stylings.
```typescript
// ✅ Good
import SvgX from "@/icons/x";
import SvgMoreHorizontal from "@/icons/more-horizontal";
// ❌ Bad
import { User } from "lucide-react";
import { FiSearch } from "react-icons/fi";
```
**Missing Icons**: If an icon is needed but doesn't exist in the `web/src/icons` directory, import it from Figma using the Figma MCP tool and add it to the icons directory.
If you need help with this step, reach out to `raunak@onyx.app`.
### 9. Text Rendering
**Prefer using the `refresh-components/texts/Text` component for all text rendering. Avoid "naked" text nodes.**
**Reason:** The `Text` component is fully compliant with the stylings provided in Figma. It provides easy utilities to specify the text-colour and font-size in the form of flags. Super duper easy.
```typescript
// ✅ Good
import { Text } from '@/refresh-components/texts/Text'
function UserCard({ name }: { name: string }) {
return (
<Text
{/* The `text03` flag makes the text it renders to be coloured the 3rd-scale grey */}
text03
{/* The `mainAction` flag makes the text it renders to be "main-action" font + line-height + weightage, as described in the Figma */}
mainAction
>
{name}
</Text>
)
}
// ❌ Bad
function UserCard({ name }: { name: string }) {
return (
<div>
<h2>{name}</h2>
<p>User details</p>
</div>
)
}
```
### 10. Component Usage
**Heavily avoid raw HTML input components. Always use components from the `web/src/refresh-components` or `web/lib/opal/src` directory.**
**Reason:** We've put in a lot of effort to unify the components that are rendered in the Onyx app. Using raw components breaks the entire UI of the application, and leaves it in a muddier state than before.
```typescript
// ✅ Good
import Button from '@/refresh-components/buttons/Button'
import InputTypeIn from '@/refresh-components/inputs/InputTypeIn'
import SvgPlusCircle from '@/icons/plus-circle'
function ContactForm() {
return (
<form>
<InputTypeIn placeholder="Search..." />
<Button type="submit" leftIcon={SvgPlusCircle}>Submit</Button>
</form>
)
}
// ❌ Bad
function ContactForm() {
return (
<form>
<input placeholder="Name" />
<textarea placeholder="Message" />
<button type="submit">Submit</button>
</form>
)
}
```
### 11. Colors
**Always use custom overrides for colors and borders rather than built in Tailwind CSS colors. These overrides live in `web/tailwind-themes/tailwind.config.js`.**
**Reason:** Our custom color system uses CSS variables that automatically handle dark mode and maintain design consistency across the app. Standard Tailwind colors bypass this system.
**Available color categories:**
- **Text:** `text-01` through `text-05`, `text-inverted-XX`
- **Backgrounds:** `background-neutral-XX`, `background-tint-XX` (and inverted variants)
- **Borders:** `border-01` through `border-05`, `border-inverted-XX`
- **Actions:** `action-link-XX`, `action-danger-XX`
- **Status:** `status-info-XX`, `status-success-XX`, `status-warning-XX`, `status-error-XX`
- **Theme:** `theme-primary-XX`, `theme-red-XX`, `theme-blue-XX`, etc.
```typescript
// ✅ Good - Use custom Onyx color classes
<div className="bg-background-neutral-01 border border-border-02" />
<div className="bg-background-tint-02 border border-border-01" />
<div className="bg-status-success-01" />
<div className="bg-action-link-01" />
<div className="bg-theme-primary-05" />
// ❌ Bad - Do NOT use standard Tailwind colors
<div className="bg-gray-100 border border-gray-300 text-gray-600" />
<div className="bg-white border border-slate-200" />
<div className="bg-green-100 text-green-700" />
<div className="bg-blue-100 text-blue-600" />
<div className="bg-indigo-500" />
```
### 12. Data Fetching
**Prefer using `useSWR` for data fetching. Data should generally be fetched on the client side. Components that need data should display a loader / placeholder while waiting for that data. Prefer loading data within the component that needs it rather than at the top level and passing it down.**
**Reason:** Client side fetching allows us to load the skeleton of the page without waiting for data to load, leading to a snappier UX. Loading data where needed reduces dependencies between a component and its parent component(s).
Frontend standards for the `web/` and `desktop/` projects live in `web/AGENTS.md`.
## Database & Migrations

View File

@@ -47,6 +47,8 @@ RUN apt-get update && \
gcc \
nano \
vim \
# Install procps so kubernetes exec sessions can use ps aux for debugging
procps \
libjemalloc2 \
&& \
rm -rf /var/lib/apt/lists/* && \

View File

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

View File

@@ -1,3 +1,4 @@
import json
import logging
import time
from contextlib import AbstractContextManager
@@ -1062,7 +1063,7 @@ class OpenSearchIndexClient(OpenSearchClient):
f"Body: {get_new_body_without_vectors(body)}\n"
f"Search pipeline ID: {search_pipeline_id}\n"
f"Phase took: {phase_took}\n"
f"Profile: {profile}\n"
f"Profile: {json.dumps(profile, indent=2)}\n"
)
if timed_out:
error_str = f"OpenSearch client error: Search timed out for index {self._index_name}."

View File

@@ -950,7 +950,86 @@ class OpenSearchDocumentIndex(DocumentIndex):
search_pipeline_id=normalization_pipeline_name,
)
# Good place for a breakpoint to inspect the search hits if you have "explain" enabled.
# Good place for a breakpoint to inspect the search hits if you have
# "explain" enabled.
inference_chunks_uncleaned: list[InferenceChunkUncleaned] = [
_convert_retrieved_opensearch_chunk_to_inference_chunk_uncleaned(
search_hit.document_chunk, search_hit.score, search_hit.match_highlights
)
for search_hit in search_hits
]
inference_chunks: list[InferenceChunk] = cleanup_content_for_chunks(
inference_chunks_uncleaned
)
return inference_chunks
def keyword_retrieval(
self,
query: str,
filters: IndexFilters,
num_to_retrieve: int,
) -> list[InferenceChunk]:
logger.debug(
f"[OpenSearchDocumentIndex] Keyword retrieving {num_to_retrieve} chunks for index {self._index_name}."
)
query_body = DocumentQuery.get_keyword_search_query(
query_text=query,
num_hits=num_to_retrieve,
tenant_state=self._tenant_state,
# NOTE: Index filters includes metadata tags which were filtered
# for invalid unicode at indexing time. In theory it would be
# ideal to do filtering here as well, in practice we never did
# that in the Vespa codepath and have not seen issues in
# production, so we deliberately conform to the existing logic
# in order to not unknowningly introduce a possible bug.
index_filters=filters,
include_hidden=False,
)
search_hits: list[SearchHit[DocumentChunkWithoutVectors]] = self._client.search(
body=query_body,
search_pipeline_id=None,
)
inference_chunks_uncleaned: list[InferenceChunkUncleaned] = [
_convert_retrieved_opensearch_chunk_to_inference_chunk_uncleaned(
search_hit.document_chunk, search_hit.score, search_hit.match_highlights
)
for search_hit in search_hits
]
inference_chunks: list[InferenceChunk] = cleanup_content_for_chunks(
inference_chunks_uncleaned
)
return inference_chunks
def semantic_retrieval(
self,
query_embedding: Embedding,
filters: IndexFilters,
num_to_retrieve: int,
) -> list[InferenceChunk]:
logger.debug(
f"[OpenSearchDocumentIndex] Semantic retrieving {num_to_retrieve} chunks for index {self._index_name}."
)
query_body = DocumentQuery.get_semantic_search_query(
query_embedding=query_embedding,
num_hits=num_to_retrieve,
tenant_state=self._tenant_state,
# NOTE: Index filters includes metadata tags which were filtered
# for invalid unicode at indexing time. In theory it would be
# ideal to do filtering here as well, in practice we never did
# that in the Vespa codepath and have not seen issues in
# production, so we deliberately conform to the existing logic
# in order to not unknowningly introduce a possible bug.
index_filters=filters,
include_hidden=False,
)
search_hits: list[SearchHit[DocumentChunkWithoutVectors]] = self._client.search(
body=query_body,
search_pipeline_id=None,
)
inference_chunks_uncleaned: list[InferenceChunkUncleaned] = [
_convert_retrieved_opensearch_chunk_to_inference_chunk_uncleaned(
search_hit.document_chunk, search_hit.score, search_hit.match_highlights

View File

@@ -404,12 +404,170 @@ class DocumentQuery:
DocumentQuery._get_match_highlights_configuration()
)
# Explain is for scoring breakdowns.
# Explain is for scoring breakdowns. Setting this significantly
# increases query latency.
if OPENSEARCH_EXPLAIN_ENABLED:
final_hybrid_search_body["explain"] = True
return final_hybrid_search_body
@staticmethod
def get_keyword_search_query(
query_text: str,
num_hits: int,
tenant_state: TenantState,
index_filters: IndexFilters,
include_hidden: bool,
) -> dict[str, Any]:
"""Returns a final keyword search query.
This query can be directly supplied to the OpenSearch client.
Args:
query_text: The text to query for.
num_hits: The final number of hits to return.
tenant_state: Tenant state containing the tenant ID.
index_filters: Filters for the keyword search query.
include_hidden: Whether to include hidden documents.
Returns:
A dictionary representing the final keyword search query.
"""
if num_hits > DEFAULT_OPENSEARCH_MAX_RESULT_WINDOW:
raise ValueError(
f"Bug: num_hits ({num_hits}) is greater than the current maximum allowed "
f"result window ({DEFAULT_OPENSEARCH_MAX_RESULT_WINDOW})."
)
keyword_search_filters = DocumentQuery._get_search_filters(
tenant_state=tenant_state,
include_hidden=include_hidden,
# TODO(andrei): We've done no filtering for PUBLIC_DOC_PAT up to
# now. This should not cause any issues but it can introduce
# redundant filters in queries that may affect performance.
access_control_list=index_filters.access_control_list,
source_types=index_filters.source_type or [],
tags=index_filters.tags or [],
document_sets=index_filters.document_set or [],
user_file_ids=index_filters.user_file_ids or [],
project_id=index_filters.project_id,
persona_id=index_filters.persona_id,
time_cutoff=index_filters.time_cutoff,
min_chunk_index=None,
max_chunk_index=None,
attached_document_ids=index_filters.attached_document_ids,
hierarchy_node_ids=index_filters.hierarchy_node_ids,
)
keyword_search_query = (
DocumentQuery._get_title_content_combined_keyword_search_query(
query_text, search_filters=keyword_search_filters
)
)
final_keyword_search_query: dict[str, Any] = {
"query": keyword_search_query,
"size": num_hits,
"timeout": f"{DEFAULT_OPENSEARCH_QUERY_TIMEOUT_S}s",
# Exclude retrieving the vector fields in order to save on
# retrieval cost as we don't need them upstream.
"_source": {
"excludes": [TITLE_VECTOR_FIELD_NAME, CONTENT_VECTOR_FIELD_NAME]
},
}
if not OPENSEARCH_MATCH_HIGHLIGHTS_DISABLED:
final_keyword_search_query["highlight"] = (
DocumentQuery._get_match_highlights_configuration()
)
if not OPENSEARCH_PROFILING_DISABLED:
final_keyword_search_query["profile"] = True
# Explain is for scoring breakdowns. Setting this significantly
# increases query latency.
if OPENSEARCH_EXPLAIN_ENABLED:
final_keyword_search_query["explain"] = True
return final_keyword_search_query
@staticmethod
def get_semantic_search_query(
query_embedding: list[float],
num_hits: int,
tenant_state: TenantState,
index_filters: IndexFilters,
include_hidden: bool,
) -> dict[str, Any]:
"""Returns a final semantic search query.
This query can be directly supplied to the OpenSearch client.
Args:
query_embedding: The vector embedding of the text to query for.
num_hits: The final number of hits to return.
tenant_state: Tenant state containing the tenant ID.
index_filters: Filters for the semantic search query.
include_hidden: Whether to include hidden documents.
Returns:
A dictionary representing the final semantic search query.
"""
if num_hits > DEFAULT_OPENSEARCH_MAX_RESULT_WINDOW:
raise ValueError(
f"Bug: num_hits ({num_hits}) is greater than the current maximum allowed "
f"result window ({DEFAULT_OPENSEARCH_MAX_RESULT_WINDOW})."
)
semantic_search_filters = DocumentQuery._get_search_filters(
tenant_state=tenant_state,
include_hidden=include_hidden,
# TODO(andrei): We've done no filtering for PUBLIC_DOC_PAT up to
# now. This should not cause any issues but it can introduce
# redundant filters in queries that may affect performance.
access_control_list=index_filters.access_control_list,
source_types=index_filters.source_type or [],
tags=index_filters.tags or [],
document_sets=index_filters.document_set or [],
user_file_ids=index_filters.user_file_ids or [],
project_id=index_filters.project_id,
persona_id=index_filters.persona_id,
time_cutoff=index_filters.time_cutoff,
min_chunk_index=None,
max_chunk_index=None,
attached_document_ids=index_filters.attached_document_ids,
hierarchy_node_ids=index_filters.hierarchy_node_ids,
)
semantic_search_query = (
DocumentQuery._get_content_vector_similarity_search_query(
query_embedding,
vector_candidates=num_hits,
search_filters=semantic_search_filters,
)
)
final_semantic_search_query: dict[str, Any] = {
"query": semantic_search_query,
"size": num_hits,
"timeout": f"{DEFAULT_OPENSEARCH_QUERY_TIMEOUT_S}s",
# Exclude retrieving the vector fields in order to save on
# retrieval cost as we don't need them upstream.
"_source": {
"excludes": [TITLE_VECTOR_FIELD_NAME, CONTENT_VECTOR_FIELD_NAME]
},
}
if not OPENSEARCH_PROFILING_DISABLED:
final_semantic_search_query["profile"] = True
# Explain is for scoring breakdowns. Setting this significantly
# increases query latency.
if OPENSEARCH_EXPLAIN_ENABLED:
final_semantic_search_query["explain"] = True
return final_semantic_search_query
@staticmethod
def get_random_search_query(
tenant_state: TenantState,
@@ -581,8 +739,9 @@ class DocumentQuery:
def _get_content_vector_similarity_search_query(
query_vector: list[float],
vector_candidates: int = DEFAULT_NUM_HYBRID_SUBQUERY_CANDIDATES,
search_filters: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
return {
query = {
"knn": {
CONTENT_VECTOR_FIELD_NAME: {
"vector": query_vector,
@@ -591,11 +750,19 @@ class DocumentQuery:
}
}
if search_filters is not None:
query["knn"][CONTENT_VECTOR_FIELD_NAME]["filter"] = {
"bool": {"filter": search_filters}
}
return query
@staticmethod
def _get_title_content_combined_keyword_search_query(
query_text: str,
search_filters: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
return {
query = {
"bool": {
"should": [
{
@@ -636,10 +803,19 @@ class DocumentQuery:
}
}
},
]
],
# Ensure at least one term from the query is present in the
# document. This defaults to 1, unless a filter or must clause
# is supplied, in which case it defaults to 0.
"minimum_should_match": 1,
}
}
if search_filters is not None:
query["bool"]["filter"] = search_filters
return query
@staticmethod
def _get_search_filters(
tenant_state: TenantState,

View File

@@ -42,8 +42,12 @@ class HookUpdateRequest(BaseModel):
name: str | None = None
endpoint_url: str | None = None
api_key: NonEmptySecretStr | None = None
fail_strategy: HookFailStrategy | None = None
timeout_seconds: float | None = Field(default=None, gt=0)
fail_strategy: HookFailStrategy | None = (
None # if None in model_fields_set, reset to spec default
)
timeout_seconds: float | None = Field(
default=None, gt=0
) # if None in model_fields_set, reset to spec default
@model_validator(mode="after")
def require_at_least_one_field(self) -> "HookUpdateRequest":
@@ -56,14 +60,6 @@ class HookUpdateRequest(BaseModel):
and not (self.endpoint_url or "").strip()
):
raise ValueError("endpoint_url cannot be cleared.")
if "fail_strategy" in self.model_fields_set and self.fail_strategy is None:
raise ValueError(
"fail_strategy cannot be null; omit the field to leave it unchanged."
)
if "timeout_seconds" in self.model_fields_set and self.timeout_seconds is None:
raise ValueError(
"timeout_seconds cannot be null; omit the field to leave it unchanged."
)
return self
@@ -94,28 +90,38 @@ class HookResponse(BaseModel):
fail_strategy: HookFailStrategy
timeout_seconds: float # always resolved — None from request is replaced with spec default before DB write
is_active: bool
is_reachable: bool | None
creator_email: str | None
created_at: datetime
updated_at: datetime
class HookValidateStatus(str, Enum):
passed = "passed" # server responded (any status except 401/403)
auth_failed = "auth_failed" # server responded with 401 or 403
timeout = (
"timeout" # TCP connected, but read/write timed out (server exists but slow)
)
cannot_connect = "cannot_connect" # could not connect to the server
class HookValidateResponse(BaseModel):
status: HookValidateStatus
success: bool
error_message: str | None = None
# ---------------------------------------------------------------------------
# Health models
# ---------------------------------------------------------------------------
class HookHealthStatus(str, Enum):
healthy = "healthy" # green — reachable, no failures in last 1h
degraded = "degraded" # yellow — reachable, failures in last 1h
unreachable = "unreachable" # red — is_reachable=false or null
class HookFailureRecord(BaseModel):
error_message: str | None = None
status_code: int | None = None
duration_ms: int | None = None
created_at: datetime
class HookHealthResponse(BaseModel):
status: HookHealthStatus
recent_failures: list[HookFailureRecord] = Field(
default_factory=list,
description="Last 10 failures, newest first",
max_length=10,
)

View File

@@ -40,14 +40,6 @@ class HookPointSpec(ABC):
docs_url: str | None = None
def __init_subclass__(cls, **kwargs: object) -> None:
"""Enforce that every concrete subclass declares all required class attributes.
Called automatically by Python whenever a class inherits from HookPointSpec.
Abstract subclasses (those still carrying unimplemented abstract methods) are
skipped — they are intermediate base classes and may not yet define everything.
Only fully concrete subclasses are validated, ensuring a clear TypeError at
import time rather than a confusing AttributeError at runtime.
"""
super().__init_subclass__(**kwargs)
# Skip intermediate abstract subclasses — they may still be partially defined.
if getattr(cls, "__abstractmethods__", None):

View File

@@ -530,6 +530,11 @@ class LitellmLLM(LLM):
):
messages = _strip_tool_content_from_messages(messages)
# Only pass tool_choice when tools are present — some providers (e.g. Fireworks)
# reject requests where tool_choice is explicitly null.
if tools and tool_choice is not None:
optional_kwargs["tool_choice"] = tool_choice
response = litellm.completion(
mock_response=get_llm_mock_response() or MOCK_LLM_RESPONSE,
model=model,
@@ -538,7 +543,6 @@ class LitellmLLM(LLM):
custom_llm_provider=self._custom_llm_provider or None,
messages=messages,
tools=tools,
tool_choice=tool_choice,
stream=stream,
temperature=temperature,
timeout=timeout_override or self._timeout,

View File

@@ -77,7 +77,6 @@ from onyx.server.features.default_assistant.api import (
)
from onyx.server.features.document_set.api import router as document_set_router
from onyx.server.features.hierarchy.api import router as hierarchy_router
from onyx.server.features.hooks.api import router as hook_router
from onyx.server.features.input_prompt.api import (
admin_router as admin_input_prompt_router,
)
@@ -454,7 +453,6 @@ def get_application(lifespan_override: Lifespan | None = None) -> FastAPI:
register_onyx_exception_handlers(application)
include_router_with_global_prefix_prepended(application, hook_router)
include_router_with_global_prefix_prepended(application, password_router)
include_router_with_global_prefix_prepended(application, chat_router)
include_router_with_global_prefix_prepended(application, query_router)

View File

@@ -1,510 +0,0 @@
import ipaddress
import socket
from urllib.parse import urlparse
import httpx
from fastapi import APIRouter
from fastapi import Depends
from fastapi import Query
from sqlalchemy.orm import Session
from onyx.auth.users import current_admin_user
from onyx.auth.users import User
from onyx.db.constants import UNSET
from onyx.db.constants import UnsetType
from onyx.db.engine.sql_engine import get_session
from onyx.db.engine.sql_engine import get_session_with_current_tenant
from onyx.db.hook import create_hook__no_commit
from onyx.db.hook import delete_hook__no_commit
from onyx.db.hook import get_hook_by_id
from onyx.db.hook import get_hook_execution_logs
from onyx.db.hook import get_hooks
from onyx.db.hook import update_hook__no_commit
from onyx.db.models import Hook
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from onyx.hooks.api_dependencies import require_hook_enabled
from onyx.hooks.models import HookCreateRequest
from onyx.hooks.models import HookFailureRecord
from onyx.hooks.models import HookPointMetaResponse
from onyx.hooks.models import HookResponse
from onyx.hooks.models import HookUpdateRequest
from onyx.hooks.models import HookValidateResponse
from onyx.hooks.models import HookValidateStatus
from onyx.hooks.registry import get_all_specs
from onyx.hooks.registry import get_hook_point_spec
from onyx.utils.logger import setup_logger
logger = setup_logger()
# ---------------------------------------------------------------------------
# SSRF protection
# ---------------------------------------------------------------------------
# RFC 1918 private ranges, loopback, link-local (IMDS at 169.254.169.254),
# shared address space, and IPv6 equivalents.
_BLOCKED_NETWORKS: list[ipaddress.IPv4Network | ipaddress.IPv6Network] = [
ipaddress.ip_network("127.0.0.0/8"), # loopback
ipaddress.ip_network("10.0.0.0/8"), # RFC 1918
ipaddress.ip_network("172.16.0.0/12"), # RFC 1918
ipaddress.ip_network("192.168.0.0/16"), # RFC 1918
ipaddress.ip_network("169.254.0.0/16"), # link-local / cloud IMDS
ipaddress.ip_network("100.64.0.0/10"), # shared address space (RFC 6598)
ipaddress.ip_network("0.0.0.0/8"), # "this" network
ipaddress.ip_network("::1/128"), # IPv6 loopback
ipaddress.ip_network("fc00::/7"), # IPv6 ULA
ipaddress.ip_network("fe80::/10"), # IPv6 link-local
]
def _check_ssrf_safety(endpoint_url: str) -> None:
"""Raise OnyxError if endpoint_url could be used for SSRF.
Checks:
1. Scheme must be https.
2. All IPs the hostname resolves to must be public — private/reserved
ranges (RFC 1918, link-local, loopback, cloud IMDS) are blocked.
Note: this provides a good-faith pre-flight check. DNS rebinding attacks
(where the hostname re-resolves to a different IP after this check) are
outside the threat model for a single-tenant, admin-only API.
"""
parsed = urlparse(endpoint_url)
scheme = (parsed.scheme or "").lower()
if scheme != "https":
raise OnyxError(
OnyxErrorCode.INVALID_INPUT,
f"Hook endpoint URL must use https, got: {scheme!r}",
)
hostname = parsed.hostname
if not hostname:
raise OnyxError(
OnyxErrorCode.INVALID_INPUT,
f"Could not parse hostname from URL: {endpoint_url}",
)
try:
resolved = socket.getaddrinfo(hostname, None)
except socket.gaierror as e:
raise OnyxError(
OnyxErrorCode.INVALID_INPUT,
f"Could not resolve hostname {hostname!r}: {e}",
)
for addr_info in resolved:
ip_str = addr_info[4][0]
try:
ip = ipaddress.ip_address(ip_str)
except ValueError:
continue
for blocked in _BLOCKED_NETWORKS:
if ip in blocked:
raise OnyxError(
OnyxErrorCode.INVALID_INPUT,
f"Hook endpoint URL resolves to a private or reserved IP address "
f"({ip}), which is not permitted.",
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _hook_to_response(hook: Hook, creator_email: str | None = None) -> HookResponse:
return HookResponse(
id=hook.id,
name=hook.name,
hook_point=hook.hook_point,
endpoint_url=hook.endpoint_url,
fail_strategy=hook.fail_strategy,
timeout_seconds=hook.timeout_seconds,
is_active=hook.is_active,
is_reachable=hook.is_reachable,
creator_email=(
creator_email
if creator_email is not None
else (hook.creator.email if hook.creator else None)
),
created_at=hook.created_at,
updated_at=hook.updated_at,
)
def _get_hook_or_404(
db_session: Session,
hook_id: int,
include_creator: bool = False,
) -> Hook:
hook = get_hook_by_id(
db_session=db_session,
hook_id=hook_id,
include_creator=include_creator,
)
if hook is None:
raise OnyxError(OnyxErrorCode.NOT_FOUND, f"Hook {hook_id} not found.")
return hook
def _raise_for_validation_failure(validation: HookValidateResponse) -> None:
"""Raise an appropriate OnyxError for a non-passed validation result."""
if validation.status == HookValidateStatus.auth_failed:
raise OnyxError(OnyxErrorCode.CREDENTIAL_INVALID, validation.error_message)
if validation.status == HookValidateStatus.timeout:
raise OnyxError(
OnyxErrorCode.GATEWAY_TIMEOUT,
f"Endpoint validation failed: {validation.error_message}",
)
raise OnyxError(
OnyxErrorCode.BAD_GATEWAY,
f"Endpoint validation failed: {validation.error_message}",
)
def _validate_endpoint(
endpoint_url: str,
api_key: str | None,
timeout_seconds: float,
) -> HookValidateResponse:
"""Check whether endpoint_url is reachable by sending an empty POST request.
We use POST since hook endpoints expect POST requests. The server will typically
respond with 4xx (missing/invalid body) — that is fine. Any HTTP response means
the server is up and routable. A 401/403 response returns auth_failed
(not reachable — indicates the api_key is invalid).
Timeout handling:
- ConnectTimeout: TCP handshake never completed → cannot_connect.
- ReadTimeout / WriteTimeout: TCP was established, server responded slowly → timeout
(operator should consider increasing timeout_seconds).
- All other exceptions → cannot_connect.
"""
_check_ssrf_safety(endpoint_url)
headers: dict[str, str] = {}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
try:
with httpx.Client(timeout=timeout_seconds) as client:
response = client.post(endpoint_url, headers=headers)
if response.status_code in (401, 403):
return HookValidateResponse(
status=HookValidateStatus.auth_failed,
error_message=f"Authentication failed (HTTP {response.status_code})",
)
return HookValidateResponse(status=HookValidateStatus.passed)
except httpx.TimeoutException as exc:
# ConnectTimeout: TCP handshake never completed → cannot_connect.
# ReadTimeout / WriteTimeout: TCP was established, server just responded slowly → timeout.
if isinstance(exc, httpx.ConnectTimeout):
logger.warning(
"Hook endpoint validation: connect timeout for %s",
endpoint_url,
exc_info=exc,
)
return HookValidateResponse(
status=HookValidateStatus.cannot_connect, error_message=str(exc)
)
logger.warning(
"Hook endpoint validation: read/write timeout for %s",
endpoint_url,
exc_info=exc,
)
return HookValidateResponse(
status=HookValidateStatus.timeout,
error_message="Endpoint timed out — consider increasing timeout_seconds.",
)
except Exception as exc:
logger.warning(
"Hook endpoint validation: connection error for %s",
endpoint_url,
exc_info=exc,
)
return HookValidateResponse(
status=HookValidateStatus.cannot_connect, error_message=str(exc)
)
# ---------------------------------------------------------------------------
# Routers
# ---------------------------------------------------------------------------
router = APIRouter(prefix="/admin/hooks")
# ---------------------------------------------------------------------------
# Hook endpoints
# ---------------------------------------------------------------------------
@router.get("/specs")
def get_hook_point_specs(
_: User = Depends(current_admin_user),
_hook_enabled: None = Depends(require_hook_enabled),
) -> list[HookPointMetaResponse]:
return [
HookPointMetaResponse(
hook_point=spec.hook_point,
display_name=spec.display_name,
description=spec.description,
docs_url=spec.docs_url,
input_schema=spec.input_schema,
output_schema=spec.output_schema,
default_timeout_seconds=spec.default_timeout_seconds,
default_fail_strategy=spec.default_fail_strategy,
fail_hard_description=spec.fail_hard_description,
)
for spec in get_all_specs()
]
@router.get("")
def list_hooks(
_: User = Depends(current_admin_user),
_hook_enabled: None = Depends(require_hook_enabled),
db_session: Session = Depends(get_session),
) -> list[HookResponse]:
hooks = get_hooks(db_session=db_session, include_creator=True)
return [_hook_to_response(h) for h in hooks]
@router.post("")
def create_hook(
req: HookCreateRequest,
user: User = Depends(current_admin_user),
_hook_enabled: None = Depends(require_hook_enabled),
db_session: Session = Depends(get_session),
) -> HookResponse:
"""Create a new hook. The endpoint is validated before persisting — creation fails if
the endpoint cannot be reached or the api_key is invalid. Hooks are created inactive;
use POST /{hook_id}/activate once ready to receive traffic."""
spec = get_hook_point_spec(req.hook_point)
api_key = req.api_key.get_secret_value() if req.api_key else None
validation = _validate_endpoint(
endpoint_url=req.endpoint_url,
api_key=api_key,
timeout_seconds=req.timeout_seconds or spec.default_timeout_seconds,
)
if validation.status != HookValidateStatus.passed:
_raise_for_validation_failure(validation)
hook = create_hook__no_commit(
db_session=db_session,
name=req.name,
hook_point=req.hook_point,
endpoint_url=req.endpoint_url,
api_key=api_key,
fail_strategy=req.fail_strategy or spec.default_fail_strategy,
timeout_seconds=req.timeout_seconds or spec.default_timeout_seconds,
creator_id=user.id,
)
hook.is_reachable = True
db_session.commit()
return _hook_to_response(hook, creator_email=user.email)
@router.get("/{hook_id}")
def get_hook(
hook_id: int,
_: User = Depends(current_admin_user),
_hook_enabled: None = Depends(require_hook_enabled),
db_session: Session = Depends(get_session),
) -> HookResponse:
hook = _get_hook_or_404(db_session, hook_id, include_creator=True)
return _hook_to_response(hook)
@router.patch("/{hook_id}")
def update_hook(
hook_id: int,
req: HookUpdateRequest,
_: User = Depends(current_admin_user),
_hook_enabled: None = Depends(require_hook_enabled),
db_session: Session = Depends(get_session),
) -> HookResponse:
"""Update hook fields. If endpoint_url, api_key, or timeout_seconds changes, the
endpoint is re-validated using the effective values. For active hooks the update is
rejected on validation failure, keeping live traffic unaffected. For inactive hooks
the update goes through regardless and is_reachable is updated to reflect the result.
Note: if an active hook's endpoint is currently down, even a timeout_seconds-only
increase will be rejected. The recovery flow is: deactivate → update → reactivate.
"""
# api_key: UNSET = no change, None = clear, value = update
api_key: str | None | UnsetType
if "api_key" not in req.model_fields_set:
api_key = UNSET
elif req.api_key is None:
api_key = None
else:
api_key = req.api_key.get_secret_value()
endpoint_url_changing = "endpoint_url" in req.model_fields_set
api_key_changing = not isinstance(api_key, UnsetType)
timeout_changing = "timeout_seconds" in req.model_fields_set
validated_is_reachable: bool | None = None
if endpoint_url_changing or api_key_changing or timeout_changing:
existing = _get_hook_or_404(db_session, hook_id)
effective_url: str = (
req.endpoint_url if endpoint_url_changing else existing.endpoint_url # type: ignore[assignment] # endpoint_url is required on create and cannot be cleared on update
)
effective_api_key: str | None = (
(api_key if not isinstance(api_key, UnsetType) else None)
if api_key_changing
else (
existing.api_key.get_value(apply_mask=False)
if existing.api_key
else None
)
)
effective_timeout: float = (
req.timeout_seconds if timeout_changing else existing.timeout_seconds # type: ignore[assignment] # req.timeout_seconds is non-None when timeout_changing (validated by HookUpdateRequest)
)
validation = _validate_endpoint(
endpoint_url=effective_url,
api_key=effective_api_key,
timeout_seconds=effective_timeout,
)
if existing.is_active and validation.status != HookValidateStatus.passed:
_raise_for_validation_failure(validation)
validated_is_reachable = validation.status == HookValidateStatus.passed
hook = update_hook__no_commit(
db_session=db_session,
hook_id=hook_id,
name=req.name,
endpoint_url=(req.endpoint_url if endpoint_url_changing else UNSET),
api_key=api_key,
fail_strategy=req.fail_strategy,
timeout_seconds=req.timeout_seconds,
is_reachable=validated_is_reachable,
include_creator=True,
)
db_session.commit()
return _hook_to_response(hook)
@router.delete("/{hook_id}")
def delete_hook(
hook_id: int,
_: User = Depends(current_admin_user),
_hook_enabled: None = Depends(require_hook_enabled),
db_session: Session = Depends(get_session),
) -> None:
delete_hook__no_commit(db_session=db_session, hook_id=hook_id)
db_session.commit()
@router.post("/{hook_id}/activate")
def activate_hook(
hook_id: int,
_: User = Depends(current_admin_user),
_hook_enabled: None = Depends(require_hook_enabled),
db_session: Session = Depends(get_session),
) -> HookResponse:
hook = _get_hook_or_404(db_session, hook_id)
if not hook.endpoint_url:
raise OnyxError(
OnyxErrorCode.INVALID_INPUT, "Hook has no endpoint URL configured."
)
api_key = hook.api_key.get_value(apply_mask=False) if hook.api_key else None
validation = _validate_endpoint(
endpoint_url=hook.endpoint_url,
api_key=api_key,
timeout_seconds=hook.timeout_seconds,
)
if validation.status != HookValidateStatus.passed:
# Persist is_reachable=False in a separate session so the request
# session has no commits on the failure path and the transaction
# boundary stays clean.
if hook.is_reachable is not False:
with get_session_with_current_tenant() as side_session:
update_hook__no_commit(
db_session=side_session, hook_id=hook_id, is_reachable=False
)
side_session.commit()
_raise_for_validation_failure(validation)
hook = update_hook__no_commit(
db_session=db_session,
hook_id=hook_id,
is_active=True,
is_reachable=True,
include_creator=True,
)
db_session.commit()
return _hook_to_response(hook)
@router.post("/{hook_id}/validate")
def validate_hook(
hook_id: int,
_: User = Depends(current_admin_user),
_hook_enabled: None = Depends(require_hook_enabled),
db_session: Session = Depends(get_session),
) -> HookValidateResponse:
hook = _get_hook_or_404(db_session, hook_id)
if not hook.endpoint_url:
raise OnyxError(
OnyxErrorCode.INVALID_INPUT, "Hook has no endpoint URL configured."
)
api_key = hook.api_key.get_value(apply_mask=False) if hook.api_key else None
validation = _validate_endpoint(
endpoint_url=hook.endpoint_url,
api_key=api_key,
timeout_seconds=hook.timeout_seconds,
)
validation_passed = validation.status == HookValidateStatus.passed
if hook.is_reachable != validation_passed:
update_hook__no_commit(
db_session=db_session, hook_id=hook_id, is_reachable=validation_passed
)
db_session.commit()
return validation
@router.post("/{hook_id}/deactivate")
def deactivate_hook(
hook_id: int,
_: User = Depends(current_admin_user),
_hook_enabled: None = Depends(require_hook_enabled),
db_session: Session = Depends(get_session),
) -> HookResponse:
hook = update_hook__no_commit(
db_session=db_session,
hook_id=hook_id,
is_active=False,
include_creator=True,
)
db_session.commit()
return _hook_to_response(hook)
# ---------------------------------------------------------------------------
# Execution log endpoints
# ---------------------------------------------------------------------------
@router.get("/{hook_id}/execution-logs")
def list_hook_execution_logs(
hook_id: int,
limit: int = Query(default=50, ge=1, le=100),
_: User = Depends(current_admin_user),
_hook_enabled: None = Depends(require_hook_enabled),
db_session: Session = Depends(get_session),
) -> list[HookFailureRecord]:
_get_hook_or_404(db_session, hook_id)
logs = get_hook_execution_logs(db_session=db_session, hook_id=hook_id, limit=limit)
return [
HookFailureRecord(
error_message=log.error_message,
status_code=log.status_code,
duration_ms=log.duration_ms,
created_at=log.created_at,
)
for log in logs
]

View File

@@ -1,3 +1,4 @@
import hashlib
import mimetypes
from io import BytesIO
from typing import Any
@@ -83,6 +84,14 @@ class PythonTool(Tool[PythonToolOverrideKwargs]):
def __init__(self, tool_id: int, emitter: Emitter) -> None:
super().__init__(emitter=emitter)
self._id = tool_id
# Cache of (filename, content_hash) -> ci_file_id to avoid re-uploading
# the same file on every tool call iteration within the same agent session.
# Filename is included in the key so two files with identical bytes but
# different names each get their own upload slot.
# TTL assumption: code-interpreter file TTLs (typically hours) greatly
# exceed the lifetime of a single agent session (at most MAX_LLM_CYCLES
# iterations, typically a few minutes), so stale-ID eviction is not needed.
self._uploaded_file_cache: dict[tuple[str, str], str] = {}
@property
def id(self) -> int:
@@ -182,8 +191,13 @@ class PythonTool(Tool[PythonToolOverrideKwargs]):
for ind, chat_file in enumerate(chat_files):
file_name = chat_file.filename or f"file_{ind}"
try:
# Upload to Code Interpreter
ci_file_id = client.upload_file(chat_file.content, file_name)
content_hash = hashlib.sha256(chat_file.content).hexdigest()
cache_key = (file_name, content_hash)
ci_file_id = self._uploaded_file_cache.get(cache_key)
if ci_file_id is None:
# Upload to Code Interpreter
ci_file_id = client.upload_file(chat_file.content, file_name)
self._uploaded_file_cache[cache_key] = ci_file_id
# Stage for execution
files_to_stage.append({"path": file_name, "file_id": ci_file_id})
@@ -299,14 +313,10 @@ class PythonTool(Tool[PythonToolOverrideKwargs]):
f"Failed to delete Code Interpreter generated file {ci_file_id}: {e}"
)
# Cleanup staged input files
for file_mapping in files_to_stage:
try:
client.delete_file(file_mapping["file_id"])
except Exception as e:
logger.error(
f"Failed to delete Code Interpreter staged file {file_mapping['file_id']}: {e}"
)
# Note: staged input files are intentionally not deleted here because
# _uploaded_file_cache reuses their file_ids across iterations. They are
# orphaned when the session ends, but the code interpreter cleans up
# stale files on its own TTL.
# Emit file_ids once files are processed
if generated_file_ids:

View File

@@ -1640,3 +1640,275 @@ class TestOpenSearchClient:
for k in DocumentChunkWithoutVectors.model_fields
}
)
def test_keyword_search(
self,
test_client: OpenSearchIndexClient,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
Tests keyword search with filters for ACL, hidden documents, and tenant
isolation.
"""
# Precondition.
_patch_global_tenant_state(monkeypatch, True)
_patch_opensearch_match_highlights_disabled(monkeypatch, False)
tenant_x = TenantState(tenant_id="tenant-x", multitenant=True)
tenant_y = TenantState(tenant_id="tenant-y", multitenant=True)
mappings = DocumentSchema.get_document_schema(
vector_dimension=128, multitenant=tenant_x.multitenant
)
settings = DocumentSchema.get_index_settings_based_on_environment()
test_client.create_index(mappings=mappings, settings=settings)
# Index documents with different public/hidden and tenant states.
docs = {
"public-doc": _create_test_document_chunk(
document_id="public-doc",
chunk_index=0,
content="Public document content",
hidden=False,
tenant_state=tenant_x,
),
"hidden-doc": _create_test_document_chunk(
document_id="hidden-doc",
chunk_index=0,
content="Hidden document content, spooky",
hidden=True,
tenant_state=tenant_x,
),
"private-doc-user-a": _create_test_document_chunk(
document_id="private-doc-user-a",
chunk_index=0,
content="Private document content, btw my SSN is 123-45-6789",
hidden=False,
tenant_state=tenant_x,
document_access=DocumentAccess.build(
user_emails=["user-a@example.com"],
user_groups=[],
external_user_emails=[],
external_user_group_ids=[],
is_public=False,
),
),
# Tests that we don't return documents that don't match keywords at
# all, even if they match filters.
"private-but-not-relevant-doc-user-a": _create_test_document_chunk(
document_id="private-but-not-relevant-doc-user-a",
chunk_index=0,
content="This text should not match the query at all",
hidden=False,
tenant_state=tenant_x,
document_access=DocumentAccess.build(
user_emails=["user-a@example.com"],
user_groups=[],
external_user_emails=[],
external_user_group_ids=[],
is_public=False,
),
),
"private-doc-user-b": _create_test_document_chunk(
document_id="private-doc-user-b",
chunk_index=0,
content="Private document content, btw my SSN is 987-65-4321",
hidden=False,
tenant_state=tenant_x,
document_access=DocumentAccess.build(
user_emails=["user-b@example.com"],
user_groups=[],
external_user_emails=[],
external_user_group_ids=[],
is_public=False,
),
),
"should-not-exist-from-tenant-x-pov": _create_test_document_chunk(
document_id="should-not-exist-from-tenant-x-pov",
chunk_index=0,
content="This is an entirely different tenant, x should never see this",
# Make this as permissive as possible to exercise tenant
# isolation.
hidden=False,
tenant_state=tenant_y,
),
}
for doc in docs.values():
test_client.index_document(document=doc, tenant_state=doc.tenant_id)
# Refresh index to make documents searchable.
test_client.refresh_index()
# Should not match private-but-not-relevant-doc-user-a.
query_text = "document content"
search_body = DocumentQuery.get_keyword_search_query(
query_text=query_text,
num_hits=5,
tenant_state=tenant_x,
# The user should only be able to see their private docs. tenant_id
# in this object is not relevant.
index_filters=IndexFilters(
access_control_list=[prefix_user_email("user-a@example.com")],
tenant_id=None,
),
include_hidden=False,
)
# Under test.
results = test_client.search(body=search_body, search_pipeline_id=None)
# Postcondition.
# Should only get the public, non-hidden document, and the private
# document for which the user has access.
assert len(results) == 2
# This should be the highest-ranked result, as a higher percentage of
# the content matches the query.
assert results[0].document_chunk.document_id == "public-doc"
# Make sure the chunk contents are preserved.
assert results[0].document_chunk == DocumentChunkWithoutVectors(
**{
k: getattr(docs["public-doc"], k)
for k in DocumentChunkWithoutVectors.model_fields
}
)
# Make sure score reporting seems reasonable (it should not be None
# or 0).
assert results[0].score
# Make sure there is some kind of match highlight.
assert results[0].match_highlights.get(CONTENT_FIELD_NAME, [])
# Same for the second result.
assert results[1].document_chunk.document_id == "private-doc-user-a"
assert results[1].document_chunk == DocumentChunkWithoutVectors(
**{
k: getattr(docs["private-doc-user-a"], k)
for k in DocumentChunkWithoutVectors.model_fields
}
)
assert results[1].score
assert results[1].match_highlights.get(CONTENT_FIELD_NAME, [])
assert results[1].score < results[0].score
def test_semantic_search(
self,
test_client: OpenSearchIndexClient,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
Tests semantic search with filters for ACL, hidden documents, and tenant
isolation.
"""
# Precondition.
_patch_global_tenant_state(monkeypatch, True)
tenant_x = TenantState(tenant_id="tenant-x", multitenant=True)
tenant_y = TenantState(tenant_id="tenant-y", multitenant=True)
mappings = DocumentSchema.get_document_schema(
vector_dimension=128, multitenant=tenant_x.multitenant
)
settings = DocumentSchema.get_index_settings_based_on_environment()
test_client.create_index(mappings=mappings, settings=settings)
# Index documents with different public/hidden and tenant states.
docs = {
"public-doc": _create_test_document_chunk(
document_id="public-doc",
chunk_index=0,
content="Public document content",
hidden=False,
tenant_state=tenant_x,
# Make this identical to the query vector to test that this
# result is returned first.
content_vector=_generate_test_vector(0.6),
),
"hidden-doc": _create_test_document_chunk(
document_id="hidden-doc",
chunk_index=0,
content="Hidden document content, spooky",
hidden=True,
tenant_state=tenant_x,
),
"private-doc-user-a": _create_test_document_chunk(
document_id="private-doc-user-a",
chunk_index=0,
content="Private document content, btw my SSN is 123-45-6789",
hidden=False,
tenant_state=tenant_x,
document_access=DocumentAccess.build(
user_emails=["user-a@example.com"],
user_groups=[],
external_user_emails=[],
external_user_group_ids=[],
is_public=False,
),
# Make this different from the query vector to test that this
# result is returned second.
content_vector=_generate_test_vector(0.5),
),
"private-doc-user-b": _create_test_document_chunk(
document_id="private-doc-user-b",
chunk_index=0,
content="Private document content, btw my SSN is 987-65-4321",
hidden=False,
tenant_state=tenant_x,
document_access=DocumentAccess.build(
user_emails=["user-b@example.com"],
user_groups=[],
external_user_emails=[],
external_user_group_ids=[],
is_public=False,
),
),
"should-not-exist-from-tenant-x-pov": _create_test_document_chunk(
document_id="should-not-exist-from-tenant-x-pov",
chunk_index=0,
content="This is an entirely different tenant, x should never see this",
# Make this as permissive as possible to exercise tenant
# isolation.
hidden=False,
tenant_state=tenant_y,
),
}
for doc in docs.values():
test_client.index_document(document=doc, tenant_state=doc.tenant_id)
# Refresh index to make documents searchable.
test_client.refresh_index()
query_vector = _generate_test_vector(0.6)
search_body = DocumentQuery.get_semantic_search_query(
query_embedding=query_vector,
num_hits=5,
tenant_state=tenant_x,
# The user should only be able to see their private docs. tenant_id
# in this object is not relevant.
index_filters=IndexFilters(
access_control_list=[prefix_user_email("user-a@example.com")],
tenant_id=None,
),
include_hidden=False,
)
# Under test.
results = test_client.search(body=search_body, search_pipeline_id=None)
# Postcondition.
# Should only get the public, non-hidden document, and the private
# document for which the user has access.
assert len(results) == 2
# We explicitly expect this to be the highest-ranked result.
assert results[0].document_chunk.document_id == "public-doc"
# Make sure the chunk contents are preserved.
assert results[0].document_chunk == DocumentChunkWithoutVectors(
**{
k: getattr(docs["public-doc"], k)
for k in DocumentChunkWithoutVectors.model_fields
}
)
assert results[0].score == 1.0
# Same for the second result.
assert results[1].document_chunk.document_id == "private-doc-user-a"
assert results[1].document_chunk == DocumentChunkWithoutVectors(
**{
k: getattr(docs["private-doc-user-a"], k)
for k in DocumentChunkWithoutVectors.model_fields
}
)
assert results[1].score
assert 0.0 < results[1].score < 1.0

View File

@@ -1219,15 +1219,16 @@ def test_code_interpreter_receives_chat_files(
finally:
ci_mod.CodeInterpreterClient.__init__.__defaults__ = original_defaults
# Verify: file uploaded, code executed via streaming, staged file cleaned up
# Verify: file uploaded and code executed via streaming.
assert len(mock_ci_server.get_requests(method="POST", path="/v1/files")) == 1
assert (
len(mock_ci_server.get_requests(method="POST", path="/v1/execute/stream")) == 1
)
delete_requests = mock_ci_server.get_requests(method="DELETE")
assert len(delete_requests) == 1
assert delete_requests[0].path.startswith("/v1/files/")
# Staged input files are intentionally NOT deleted — PythonTool caches their
# file IDs across agent-loop iterations to avoid re-uploading on every call.
# The code interpreter cleans them up via its own TTL.
assert len(mock_ci_server.get_requests(method="DELETE")) == 0
execute_body = mock_ci_server.get_requests(
method="POST", path="/v1/execute/stream"

View File

@@ -256,7 +256,6 @@ def test_multiple_tool_calls(default_multi_llm: LitellmLLM) -> None:
{"role": "user", "content": "What's the weather and time in New York?"}
],
tools=tools,
tool_choice=None,
stream=True,
temperature=0.0, # Default value from GEN_AI_TEMPERATURE
timeout=30,
@@ -412,7 +411,6 @@ def test_multiple_tool_calls_streaming(default_multi_llm: LitellmLLM) -> None:
{"role": "user", "content": "What's the weather and time in New York?"}
],
tools=tools,
tool_choice=None,
stream=True,
temperature=0.0, # Default value from GEN_AI_TEMPERATURE
timeout=30,
@@ -1431,3 +1429,36 @@ def test_strip_tool_content_merges_consecutive_tool_results() -> None:
assert "sunny 72F" in merged
assert "tc_2" in merged
assert "headline news" in merged
def test_no_tool_choice_sent_when_no_tools(default_multi_llm: LitellmLLM) -> None:
"""Regression test for providers (e.g. Fireworks) that reject tool_choice=null.
When no tools are provided, tool_choice must not be forwarded to
litellm.completion() at all — not even as None.
"""
messages: LanguageModelInput = [UserMessage(content="Hello!")]
mock_stream_chunks = [
litellm.ModelResponse(
id="chatcmpl-123",
choices=[
litellm.Choices(
delta=_create_delta(role="assistant", content="Hello!"),
finish_reason="stop",
index=0,
)
],
model="gpt-3.5-turbo",
),
]
with patch("litellm.completion") as mock_completion:
mock_completion.return_value = mock_stream_chunks
default_multi_llm.invoke(messages, tools=None)
_, kwargs = mock_completion.call_args
assert (
"tool_choice" not in kwargs
), "tool_choice must not be sent to providers when no tools are provided"

View File

@@ -1,278 +0,0 @@
"""Unit tests for onyx.server.features.hooks.api helpers.
Covers:
- _check_ssrf_safety: scheme enforcement and private-IP blocklist
- _validate_endpoint: httpx exception → HookValidateStatus mapping
ConnectTimeout → cannot_connect (TCP handshake never completed)
ConnectError → cannot_connect (DNS / TLS failure)
ReadTimeout et al. → timeout (TCP connected, server slow)
Any other exc → cannot_connect
- _raise_for_validation_failure: HookValidateStatus → OnyxError mapping
"""
from unittest.mock import MagicMock
from unittest.mock import patch
import httpx
import pytest
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from onyx.hooks.models import HookValidateResponse
from onyx.hooks.models import HookValidateStatus
from onyx.server.features.hooks.api import _check_ssrf_safety
from onyx.server.features.hooks.api import _raise_for_validation_failure
from onyx.server.features.hooks.api import _validate_endpoint
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
_URL = "https://example.com/hook"
_API_KEY = "secret"
_TIMEOUT = 5.0
def _mock_response(status_code: int) -> MagicMock:
response = MagicMock()
response.status_code = status_code
return response
# ---------------------------------------------------------------------------
# _check_ssrf_safety
# ---------------------------------------------------------------------------
class TestCheckSsrfSafety:
def _call(self, url: str) -> None:
_check_ssrf_safety(url)
# --- scheme checks ---
def test_https_is_allowed(self) -> None:
with patch("onyx.server.features.hooks.api.socket.getaddrinfo") as mock_dns:
mock_dns.return_value = [(None, None, None, None, ("93.184.216.34", 0))]
self._call("https://example.com/hook") # must not raise
@pytest.mark.parametrize(
"url", ["http://example.com/hook", "ftp://example.com/hook"]
)
def test_non_https_scheme_rejected(self, url: str) -> None:
with pytest.raises(OnyxError) as exc_info:
self._call(url)
assert exc_info.value.error_code == OnyxErrorCode.INVALID_INPUT
assert "https" in (exc_info.value.detail or "").lower()
# --- private IP blocklist ---
@pytest.mark.parametrize(
"ip",
[
pytest.param("127.0.0.1", id="loopback"),
pytest.param("10.0.0.1", id="RFC1918-A"),
pytest.param("172.16.0.1", id="RFC1918-B"),
pytest.param("192.168.1.1", id="RFC1918-C"),
pytest.param("169.254.169.254", id="link-local-IMDS"),
pytest.param("100.64.0.1", id="shared-address-space"),
pytest.param("::1", id="IPv6-loopback"),
pytest.param("fc00::1", id="IPv6-ULA"),
pytest.param("fe80::1", id="IPv6-link-local"),
],
)
def test_private_ip_is_blocked(self, ip: str) -> None:
with (
patch("onyx.server.features.hooks.api.socket.getaddrinfo") as mock_dns,
pytest.raises(OnyxError) as exc_info,
):
mock_dns.return_value = [(None, None, None, None, (ip, 0))]
self._call("https://internal.example.com/hook")
assert exc_info.value.error_code == OnyxErrorCode.INVALID_INPUT
assert ip in (exc_info.value.detail or "")
def test_public_ip_is_allowed(self) -> None:
with patch("onyx.server.features.hooks.api.socket.getaddrinfo") as mock_dns:
mock_dns.return_value = [(None, None, None, None, ("93.184.216.34", 0))]
self._call("https://example.com/hook") # must not raise
def test_dns_resolution_failure_raises(self) -> None:
import socket
with (
patch(
"onyx.server.features.hooks.api.socket.getaddrinfo",
side_effect=socket.gaierror("name not found"),
),
pytest.raises(OnyxError) as exc_info,
):
self._call("https://no-such-host.example.com/hook")
assert exc_info.value.error_code == OnyxErrorCode.INVALID_INPUT
# ---------------------------------------------------------------------------
# _validate_endpoint
# ---------------------------------------------------------------------------
class TestValidateEndpoint:
def _call(self, *, api_key: str | None = _API_KEY) -> HookValidateResponse:
# Bypass SSRF check — tested separately in TestCheckSsrfSafety.
with patch("onyx.server.features.hooks.api._check_ssrf_safety"):
return _validate_endpoint(
endpoint_url=_URL,
api_key=api_key,
timeout_seconds=_TIMEOUT,
)
@patch("onyx.server.features.hooks.api.httpx.Client")
def test_2xx_returns_passed(self, mock_client_cls: MagicMock) -> None:
mock_client_cls.return_value.__enter__.return_value.post.return_value = (
_mock_response(200)
)
assert self._call().status == HookValidateStatus.passed
@patch("onyx.server.features.hooks.api.httpx.Client")
def test_5xx_returns_passed(self, mock_client_cls: MagicMock) -> None:
mock_client_cls.return_value.__enter__.return_value.post.return_value = (
_mock_response(500)
)
assert self._call().status == HookValidateStatus.passed
@patch("onyx.server.features.hooks.api.httpx.Client")
@pytest.mark.parametrize("status_code", [401, 403])
def test_401_403_returns_auth_failed(
self, mock_client_cls: MagicMock, status_code: int
) -> None:
mock_client_cls.return_value.__enter__.return_value.post.return_value = (
_mock_response(status_code)
)
result = self._call()
assert result.status == HookValidateStatus.auth_failed
assert str(status_code) in (result.error_message or "")
@patch("onyx.server.features.hooks.api.httpx.Client")
def test_4xx_non_auth_returns_passed(self, mock_client_cls: MagicMock) -> None:
mock_client_cls.return_value.__enter__.return_value.post.return_value = (
_mock_response(422)
)
assert self._call().status == HookValidateStatus.passed
@patch("onyx.server.features.hooks.api.httpx.Client")
def test_connect_timeout_returns_cannot_connect(
self, mock_client_cls: MagicMock
) -> None:
mock_client_cls.return_value.__enter__.return_value.post.side_effect = (
httpx.ConnectTimeout("timed out")
)
assert self._call().status == HookValidateStatus.cannot_connect
@patch("onyx.server.features.hooks.api.httpx.Client")
@pytest.mark.parametrize(
"exc",
[
httpx.ReadTimeout("read timeout"),
httpx.WriteTimeout("write timeout"),
httpx.PoolTimeout("pool timeout"),
],
)
def test_read_write_pool_timeout_returns_timeout(
self, mock_client_cls: MagicMock, exc: httpx.TimeoutException
) -> None:
mock_client_cls.return_value.__enter__.return_value.post.side_effect = exc
assert self._call().status == HookValidateStatus.timeout
@patch("onyx.server.features.hooks.api.httpx.Client")
def test_connect_error_returns_cannot_connect(
self, mock_client_cls: MagicMock
) -> None:
# Covers DNS failures, TLS errors, and other connection-level errors.
mock_client_cls.return_value.__enter__.return_value.post.side_effect = (
httpx.ConnectError("name resolution failed")
)
assert self._call().status == HookValidateStatus.cannot_connect
@patch("onyx.server.features.hooks.api.httpx.Client")
def test_arbitrary_exception_returns_cannot_connect(
self, mock_client_cls: MagicMock
) -> None:
mock_client_cls.return_value.__enter__.return_value.post.side_effect = (
ConnectionRefusedError("refused")
)
assert self._call().status == HookValidateStatus.cannot_connect
@patch("onyx.server.features.hooks.api.httpx.Client")
def test_api_key_sent_as_bearer(self, mock_client_cls: MagicMock) -> None:
mock_post = mock_client_cls.return_value.__enter__.return_value.post
mock_post.return_value = _mock_response(200)
self._call(api_key="mykey")
_, kwargs = mock_post.call_args
assert kwargs["headers"]["Authorization"] == "Bearer mykey"
@patch("onyx.server.features.hooks.api.httpx.Client")
def test_no_api_key_omits_auth_header(self, mock_client_cls: MagicMock) -> None:
mock_post = mock_client_cls.return_value.__enter__.return_value.post
mock_post.return_value = _mock_response(200)
self._call(api_key=None)
_, kwargs = mock_post.call_args
assert "Authorization" not in kwargs["headers"]
# ---------------------------------------------------------------------------
# _raise_for_validation_failure
# ---------------------------------------------------------------------------
class TestRaiseForValidationFailure:
@pytest.mark.parametrize(
"status, expected_code",
[
(HookValidateStatus.auth_failed, OnyxErrorCode.CREDENTIAL_INVALID),
(HookValidateStatus.timeout, OnyxErrorCode.GATEWAY_TIMEOUT),
(HookValidateStatus.cannot_connect, OnyxErrorCode.BAD_GATEWAY),
],
)
def test_raises_correct_error_code(
self, status: HookValidateStatus, expected_code: OnyxErrorCode
) -> None:
validation = HookValidateResponse(status=status, error_message="some error")
with pytest.raises(OnyxError) as exc_info:
_raise_for_validation_failure(validation)
assert exc_info.value.error_code == expected_code
def test_auth_failed_passes_error_message_directly(self) -> None:
validation = HookValidateResponse(
status=HookValidateStatus.auth_failed, error_message="bad credentials"
)
with pytest.raises(OnyxError) as exc_info:
_raise_for_validation_failure(validation)
assert exc_info.value.detail == "bad credentials"
@pytest.mark.parametrize(
"status", [HookValidateStatus.timeout, HookValidateStatus.cannot_connect]
)
def test_timeout_and_cannot_connect_wrap_error_message(
self, status: HookValidateStatus
) -> None:
validation = HookValidateResponse(status=status, error_message="raw error")
with pytest.raises(OnyxError) as exc_info:
_raise_for_validation_failure(validation)
assert exc_info.value.detail == "Endpoint validation failed: raw error"
# ---------------------------------------------------------------------------
# HookValidateStatus enum string values (API contract)
# ---------------------------------------------------------------------------
class TestHookValidateStatusValues:
@pytest.mark.parametrize(
"status, expected",
[
(HookValidateStatus.passed, "passed"),
(HookValidateStatus.auth_failed, "auth_failed"),
(HookValidateStatus.timeout, "timeout"),
(HookValidateStatus.cannot_connect, "cannot_connect"),
],
)
def test_string_values(self, status: HookValidateStatus, expected: str) -> None:
assert status == expected

View File

@@ -0,0 +1,208 @@
"""Unit tests for PythonTool file-upload caching.
Verifies that PythonTool reuses code-interpreter file IDs across multiple
run() calls within the same session instead of re-uploading identical content
on every agent loop iteration.
"""
from unittest.mock import MagicMock
from unittest.mock import patch
from onyx.tools.models import ChatFile
from onyx.tools.models import PythonToolOverrideKwargs
from onyx.tools.tool_implementations.python.code_interpreter_client import (
StreamResultEvent,
)
from onyx.tools.tool_implementations.python.python_tool import PythonTool
TOOL_MODULE = "onyx.tools.tool_implementations.python.python_tool"
def _make_stream_result() -> StreamResultEvent:
return StreamResultEvent(
exit_code=0,
timed_out=False,
duration_ms=10,
files=[],
)
def _make_tool() -> PythonTool:
emitter = MagicMock()
return PythonTool(tool_id=1, emitter=emitter)
def _make_override(files: list[ChatFile]) -> PythonToolOverrideKwargs:
return PythonToolOverrideKwargs(chat_files=files)
def _run_tool(tool: PythonTool, mock_client: MagicMock, files: list[ChatFile]) -> None:
"""Call tool.run() with a mocked CodeInterpreterClient context manager."""
from onyx.server.query_and_chat.placement import Placement
mock_client.execute_streaming.return_value = iter([_make_stream_result()])
ctx = MagicMock()
ctx.__enter__ = MagicMock(return_value=mock_client)
ctx.__exit__ = MagicMock(return_value=False)
placement = Placement(turn_index=0, tab_index=0)
override = _make_override(files)
with patch(f"{TOOL_MODULE}.CodeInterpreterClient", return_value=ctx):
tool.run(placement=placement, override_kwargs=override, code="print('hi')")
# ---------------------------------------------------------------------------
# Cache hit: same content uploaded in a second call reuses the file_id
# ---------------------------------------------------------------------------
@patch(f"{TOOL_MODULE}.CODE_INTERPRETER_BASE_URL", "http://fake:8000")
def test_same_file_uploaded_only_once_across_two_runs() -> None:
tool = _make_tool()
client = MagicMock()
client.upload_file.return_value = "file-id-abc"
pptx_content = b"fake pptx bytes"
files = [ChatFile(filename="report.pptx", content=pptx_content)]
_run_tool(tool, client, files)
_run_tool(tool, client, files)
# upload_file should only have been called once across both runs
client.upload_file.assert_called_once_with(pptx_content, "report.pptx")
@patch(f"{TOOL_MODULE}.CODE_INTERPRETER_BASE_URL", "http://fake:8000")
def test_cached_file_id_is_staged_on_second_run() -> None:
tool = _make_tool()
client = MagicMock()
client.upload_file.return_value = "file-id-abc"
files = [ChatFile(filename="data.pptx", content=b"content")]
_run_tool(tool, client, files)
# On the second run, execute_streaming should still receive the file
client.execute_streaming.return_value = iter([_make_stream_result()])
ctx = MagicMock()
ctx.__enter__ = MagicMock(return_value=client)
ctx.__exit__ = MagicMock(return_value=False)
from onyx.server.query_and_chat.placement import Placement
placement = Placement(turn_index=1, tab_index=0)
with patch(f"{TOOL_MODULE}.CodeInterpreterClient", return_value=ctx):
tool.run(
placement=placement,
override_kwargs=_make_override(files),
code="print('hi')",
)
# The second execute_streaming call should include the file
_, kwargs = client.execute_streaming.call_args
staged_files = kwargs.get("files") or []
assert any(f["file_id"] == "file-id-abc" for f in staged_files)
# ---------------------------------------------------------------------------
# Cache miss: different content triggers a new upload
# ---------------------------------------------------------------------------
@patch(f"{TOOL_MODULE}.CODE_INTERPRETER_BASE_URL", "http://fake:8000")
def test_different_file_content_uploaded_separately() -> None:
tool = _make_tool()
client = MagicMock()
client.upload_file.side_effect = ["file-id-v1", "file-id-v2"]
file_v1 = ChatFile(filename="report.pptx", content=b"version 1")
file_v2 = ChatFile(filename="report.pptx", content=b"version 2")
_run_tool(tool, client, [file_v1])
_run_tool(tool, client, [file_v2])
assert client.upload_file.call_count == 2
@patch(f"{TOOL_MODULE}.CODE_INTERPRETER_BASE_URL", "http://fake:8000")
def test_multiple_distinct_files_each_uploaded_once() -> None:
tool = _make_tool()
client = MagicMock()
client.upload_file.side_effect = ["id-a", "id-b"]
files = [
ChatFile(filename="a.pptx", content=b"aaa"),
ChatFile(filename="b.xlsx", content=b"bbb"),
]
_run_tool(tool, client, files)
_run_tool(tool, client, files)
# Two distinct files — each uploaded exactly once
assert client.upload_file.call_count == 2
@patch(f"{TOOL_MODULE}.CODE_INTERPRETER_BASE_URL", "http://fake:8000")
def test_same_content_different_filename_uploaded_separately() -> None:
# Identical bytes but different names must each get their own upload slot
# so both files appear under their respective paths in the workspace.
tool = _make_tool()
client = MagicMock()
client.upload_file.side_effect = ["id-v1", "id-v2"]
same_bytes = b"shared content"
files = [
ChatFile(filename="report_v1.csv", content=same_bytes),
ChatFile(filename="report_v2.csv", content=same_bytes),
]
_run_tool(tool, client, files)
assert client.upload_file.call_count == 2
# ---------------------------------------------------------------------------
# No cross-instance sharing: a fresh PythonTool re-uploads everything
# ---------------------------------------------------------------------------
@patch(f"{TOOL_MODULE}.CODE_INTERPRETER_BASE_URL", "http://fake:8000")
def test_new_tool_instance_re_uploads_file() -> None:
client = MagicMock()
client.upload_file.side_effect = ["id-session-1", "id-session-2"]
files = [ChatFile(filename="deck.pptx", content=b"slide data")]
tool_session_1 = _make_tool()
_run_tool(tool_session_1, client, files)
tool_session_2 = _make_tool()
_run_tool(tool_session_2, client, files)
# Different instances — each uploads independently
assert client.upload_file.call_count == 2
# ---------------------------------------------------------------------------
# Upload failure: failed upload is not cached, retried next run
# ---------------------------------------------------------------------------
@patch(f"{TOOL_MODULE}.CODE_INTERPRETER_BASE_URL", "http://fake:8000")
def test_upload_failure_not_cached() -> None:
tool = _make_tool()
client = MagicMock()
# First call raises, second succeeds
client.upload_file.side_effect = [Exception("network error"), "file-id-ok"]
files = [ChatFile(filename="slides.pptx", content=b"data")]
# First run — upload fails, file is skipped but not cached
_run_tool(tool, client, files)
# Second run — should attempt upload again
_run_tool(tool, client, files)
assert client.upload_file.call_count == 2

93
cubic.yaml Normal file
View File

@@ -0,0 +1,93 @@
# yaml-language-server: $schema=https://cubic.dev/schema/cubic-repository-config.schema.json
version: 1
reviews:
enabled: true
sensitivity: medium
incremental_commits: true
check_drafts: false
custom_instructions: |
Use explicit type annotations for variables to enhance code clarity,
especially when moving type hints around in the code.
Use `contributing_guides/best_practices.md` as core review context.
Prefer consistency with existing patterns, fix issues in code you touch,
avoid tacking new features onto muddy interfaces, fail loudly instead of
silently swallowing errors, keep code strictly typed, preserve clear state
boundaries, remove duplicate or dead logic, break up overly long functions,
avoid hidden import-time side effects, respect module boundaries, and favor
correctness-by-construction over relying on callers to use an API correctly.
Reference these files for additional context:
- `contributing_guides/best_practices.md` — Best practices for contributing to the codebase
- `CLAUDE.md` — Project instructions and coding standards
- `backend/alembic/README.md` — Migration guidance, including multi-tenant migration behavior
- `deployment/helm/charts/onyx/values-lite.yaml` — Lite deployment Helm values and service assumptions
- `deployment/docker_compose/docker-compose.onyx-lite.yml` — Lite deployment Docker Compose overlay and disabled service behavior
ignore:
files:
- greptile.json
- cubic.yaml
custom_rules:
- name: TODO format
description: >
Whenever a TODO is added, there must always be an associated name or
ticket in the style of TODO(name): ... or TODO(1234): ...
- name: Frontend standards
description: >
For frontend changes, enforce all standards described in the
web/AGENTS.md file.
include:
- web/**
- desktop/**
- name: No debugging code
description: >
Remove temporary debugging code before merging to production,
especially tenant-specific debugging logs.
- name: No hardcoded booleans
description: >
When hardcoding a boolean variable to a constant value, remove the
variable entirely and clean up all places where it's used rather than
just setting it to a constant.
- name: Multi-tenant awareness
description: >
Code changes must consider both multi-tenant and single-tenant
deployments. In multi-tenant mode, preserve tenant isolation, ensure
tenant context is propagated correctly, and avoid assumptions that only
hold for a single shared schema or globally shared state. In
single-tenant mode, avoid introducing unnecessary tenant-specific
requirements or cloud-only control-plane dependencies.
- name: Onyx lite compatibility
description: >
Code changes must consider both regular Onyx deployments and Onyx lite
deployments. Lite deployments disable the vector DB, Redis, model
servers, and background workers by default, use PostgreSQL-backed
cache/auth/file storage, and rely on the API server to handle
background work. Do not assume those services are available unless the
code path is explicitly limited to full deployments.
- name: OnyxError over HTTPException
description: >
Never raise HTTPException directly in business code. Use
`raise OnyxError(OnyxErrorCode.XXX, "message")` from
`onyx.error_handling.exceptions`. A global FastAPI exception handler
converts OnyxError into structured JSON responses with
{"error_code": "...", "detail": "..."}. Error codes are defined in
`onyx.error_handling.error_codes.OnyxErrorCode`. For upstream errors
with dynamic HTTP status codes, use `status_code_override`:
`raise OnyxError(OnyxErrorCode.BAD_GATEWAY, detail, status_code_override=upstream_status)`.
include:
- backend/**/*.py
issues:
fix_with_cubic_buttons: true
pr_comment_fixes: true
fix_commits_to_pr: true

File diff suppressed because it is too large Load Diff

View File

@@ -207,6 +207,16 @@ prompt_yn_or_default() {
fi
}
confirm_action() {
local description="$1"
prompt_yn_or_default "Install ${description}? (Y/n) [default: Y] " "Y"
if [[ "$REPLY" =~ ^[Nn] ]]; then
print_warning "Skipping: ${description}"
return 1
fi
return 0
}
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
@@ -395,6 +405,11 @@ fi
if ! command -v docker &> /dev/null; then
if [[ "$OSTYPE" == "linux-gnu"* ]] || [[ -n "${WSL_DISTRO_NAME:-}" ]]; then
print_info "Docker is required but not installed."
if ! confirm_action "Docker Engine"; then
print_error "Docker is required to run Onyx."
exit 1
fi
install_docker_linux
if ! command -v docker &> /dev/null; then
print_error "Docker installation failed."
@@ -411,7 +426,11 @@ if command -v docker &> /dev/null \
&& ! command -v docker-compose &> /dev/null \
&& { [[ "$OSTYPE" == "linux-gnu"* ]] || [[ -n "${WSL_DISTRO_NAME:-}" ]]; }; then
print_info "Docker Compose not found — installing plugin..."
print_info "Docker Compose is required but not installed."
if ! confirm_action "Docker Compose plugin"; then
print_error "Docker Compose is required to run Onyx."
exit 1
fi
COMPOSE_ARCH="$(uname -m)"
COMPOSE_URL="https://github.com/docker/compose/releases/latest/download/docker-compose-linux-${COMPOSE_ARCH}"
COMPOSE_DIR="/usr/local/lib/docker/cli-plugins"
@@ -562,10 +581,31 @@ version_compare() {
# Check Docker daemon
if ! docker info &> /dev/null; then
print_error "Docker daemon is not running. Please start Docker."
exit 1
if [[ "$OSTYPE" == "darwin"* ]]; then
print_info "Docker daemon is not running. Starting Docker Desktop..."
open -a Docker
# Wait up to 120 seconds for Docker to be ready
DOCKER_WAIT=0
DOCKER_MAX_WAIT=120
while ! docker info &> /dev/null; do
if [ $DOCKER_WAIT -ge $DOCKER_MAX_WAIT ]; then
print_error "Docker Desktop did not start within ${DOCKER_MAX_WAIT} seconds."
print_info "Please start Docker Desktop manually and re-run this script."
exit 1
fi
printf "\r\033[KWaiting for Docker Desktop to start... (%ds)" "$DOCKER_WAIT"
sleep 2
DOCKER_WAIT=$((DOCKER_WAIT + 2))
done
echo ""
print_success "Docker Desktop is now running"
else
print_error "Docker daemon is not running. Please start Docker."
exit 1
fi
else
print_success "Docker daemon is running"
fi
print_success "Docker daemon is running"
# Check Docker resources
print_step "Verifying Docker resources"
@@ -745,6 +785,7 @@ print_success "All configuration files ready"
# Set up deployment configuration
print_step "Setting up deployment configs"
ENV_FILE="${INSTALL_ROOT}/deployment/.env"
ENV_TEMPLATE="${INSTALL_ROOT}/deployment/env.template"
# Check if services are already running
if [ -d "${INSTALL_ROOT}/deployment" ] && [ -f "${INSTALL_ROOT}/deployment/docker-compose.yml" ]; then
# Determine compose command
@@ -1084,6 +1125,25 @@ else
USE_LATEST=false
fi
# For pinned version tags, re-download config files from that tag so the
# compose file matches the images being pulled (the initial download used main).
if [[ "$USE_LATEST" = false ]] && [[ "$USE_LOCAL_FILES" = false ]]; then
PINNED_BASE="https://raw.githubusercontent.com/onyx-dot-app/onyx/${CURRENT_IMAGE_TAG}/deployment"
print_info "Fetching config files matching tag ${CURRENT_IMAGE_TAG}..."
if download_file "${PINNED_BASE}/docker_compose/docker-compose.yml" "${INSTALL_ROOT}/deployment/docker-compose.yml" 2>/dev/null; then
download_file "${PINNED_BASE}/data/nginx/app.conf.template" "${INSTALL_ROOT}/data/nginx/app.conf.template" 2>/dev/null || true
download_file "${PINNED_BASE}/data/nginx/run-nginx.sh" "${INSTALL_ROOT}/data/nginx/run-nginx.sh" 2>/dev/null || true
chmod +x "${INSTALL_ROOT}/data/nginx/run-nginx.sh"
if [[ "$LITE_MODE" = true ]]; then
download_file "${PINNED_BASE}/docker_compose/${LITE_COMPOSE_FILE}" \
"${INSTALL_ROOT}/deployment/${LITE_COMPOSE_FILE}" 2>/dev/null || true
fi
print_success "Config files updated to match ${CURRENT_IMAGE_TAG}"
else
print_warning "Tag ${CURRENT_IMAGE_TAG} not found on GitHub — using main branch configs"
fi
fi
# Pull Docker images with reduced output
print_step "Pulling Docker images"
print_info "This may take several minutes depending on your internet connection..."

1
desktop/AGENTS.md Symbolic link
View File

@@ -0,0 +1 @@
../web/AGENTS.md

1
desktop/CLAUDE.md Symbolic link
View File

@@ -0,0 +1 @@
AGENTS.md

View File

@@ -65,7 +65,7 @@
},
{
"scope": ["web/**"],
"rule": "For frontend changes (changes that touch the /web directory), make sure to enforce all standards described in the web/STANDARDS.md file."
"rule": "For frontend changes (changes that touch the /web directory), make sure to enforce all standards described in the web/AGENTS.md file."
},
{
"scope": [],
@@ -85,7 +85,7 @@
},
{
"scope": ["backend/**/*.py"],
"rule": "Never raise HTTPException directly in business code. Use `raise OnyxError(OnyxErrorCode.XXX, \"message\")` from `onyx.error_handling.exceptions`. A global FastAPI exception handler converts OnyxError into structured JSON responses with {\"error_code\": \"...\", \"message\": \"...\"}. Error codes are defined in `onyx.error_handling.error_codes.OnyxErrorCode`. For upstream errors with dynamic HTTP status codes, use `status_code_override`: `raise OnyxError(OnyxErrorCode.BAD_GATEWAY, detail, status_code_override=upstream_status)`."
"rule": "Never raise HTTPException directly in business code. Use `raise OnyxError(OnyxErrorCode.XXX, \"message\")` from `onyx.error_handling.exceptions`. A global FastAPI exception handler converts OnyxError into structured JSON responses with {\"error_code\": \"...\", \"detail\": \"...\"}. Error codes are defined in `onyx.error_handling.error_codes.OnyxErrorCode`. For upstream errors with dynamic HTTP status codes, use `status_code_override`: `raise OnyxError(OnyxErrorCode.BAD_GATEWAY, detail, status_code_override=upstream_status)`."
}
],
"files": [

View File

@@ -17,6 +17,7 @@ import (
type RunCIOptions struct {
DryRun bool
Yes bool
Rerun bool
}
// NewRunCICommand creates a new run-ci command
@@ -49,6 +50,7 @@ Example usage:
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "Perform all local operations but skip pushing to remote and creating PRs")
cmd.Flags().BoolVar(&opts.Yes, "yes", false, "Skip confirmation prompts and automatically proceed")
cmd.Flags().BoolVar(&opts.Rerun, "rerun", false, "Update an existing CI PR with the latest fork changes to re-trigger CI")
return cmd
}
@@ -107,19 +109,44 @@ func runCI(cmd *cobra.Command, args []string, opts *RunCIOptions) {
log.Fatalf("PR #%s is not from a fork - CI should already run automatically", prNumber)
}
// Confirm before proceeding
if !opts.Yes {
if !prompt.Confirm(fmt.Sprintf("Create CI branch for PR #%s? (yes/no): ", prNumber)) {
log.Info("Exiting...")
return
}
}
// Create the CI branch
ciBranch := fmt.Sprintf("run-ci/%s", prNumber)
prTitle := fmt.Sprintf("chore: [Running GitHub actions for #%s]", prNumber)
prBody := fmt.Sprintf("This PR runs GitHub Actions CI for #%s.\n\n- [x] Override Linear Check\n\n**This PR should be closed (not merged) after CI completes.**", prNumber)
// Check if a CI PR already exists for this branch
existingPRURL, err := findExistingCIPR(ciBranch)
if err != nil {
log.Fatalf("Failed to check for existing CI PR: %v", err)
}
if existingPRURL != "" && !opts.Rerun {
log.Infof("A CI PR already exists for #%s: %s", prNumber, existingPRURL)
log.Info("Run with --rerun to update it with the latest fork changes and re-trigger CI.")
return
}
if opts.Rerun && existingPRURL == "" {
log.Warn("--rerun was specified but no existing open CI PR was found. A new PR will be created.")
}
if existingPRURL != "" && opts.Rerun {
log.Infof("Existing CI PR found: %s", existingPRURL)
log.Info("Will update the CI branch with the latest fork changes to re-trigger CI.")
}
// Confirm before proceeding
if !opts.Yes {
action := "Create CI branch"
if existingPRURL != "" {
action = "Update existing CI branch"
}
if !prompt.Confirm(fmt.Sprintf("%s for PR #%s? (yes/no): ", action, prNumber)) {
log.Info("Exiting...")
return
}
}
// Fetch the fork's branch
if forkRepo == "" {
log.Fatalf("Could not determine fork repository - headRepositoryOwner or headRepository.name is empty")
@@ -158,7 +185,11 @@ func runCI(cmd *cobra.Command, args []string, opts *RunCIOptions) {
if opts.DryRun {
log.Warnf("[DRY RUN] Would push CI branch: %s", ciBranch)
log.Warnf("[DRY RUN] Would create PR: %s", prTitle)
if existingPRURL == "" {
log.Warnf("[DRY RUN] Would create PR: %s", prTitle)
} else {
log.Warnf("[DRY RUN] Would update existing PR: %s", existingPRURL)
}
// Switch back to original branch
if err := git.RunCommand("switch", "--quiet", originalBranch); err != nil {
log.Warnf("Failed to switch back to original branch: %v", err)
@@ -176,6 +207,17 @@ func runCI(cmd *cobra.Command, args []string, opts *RunCIOptions) {
log.Fatalf("Failed to push CI branch: %v", err)
}
if existingPRURL != "" {
// PR already exists - force push is enough to re-trigger CI
log.Infof("Switching back to original branch: %s", originalBranch)
if err := git.RunCommand("switch", "--quiet", originalBranch); err != nil {
log.Warnf("Failed to switch back to original branch: %v", err)
}
log.Infof("CI PR updated successfully: %s", existingPRURL)
log.Info("The force push will re-trigger CI. Remember to close (not merge) this PR after CI completes!")
return
}
// Create PR using GitHub CLI
log.Info("Creating PR...")
prURL, err := createCIPR(ciBranch, prInfo.BaseRefName, prTitle, prBody)
@@ -217,6 +259,39 @@ func getPRInfo(prNumber string) (*PRInfo, error) {
return &prInfo, nil
}
// findExistingCIPR checks if an open PR already exists for the given CI branch.
// Returns the PR URL if found, or empty string if not.
func findExistingCIPR(headBranch string) (string, error) {
cmd := exec.Command("gh", "pr", "list",
"--head", headBranch,
"--state", "open",
"--json", "url",
)
output, err := cmd.Output()
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
return "", fmt.Errorf("%w: %s", err, string(exitErr.Stderr))
}
return "", err
}
var prs []struct {
URL string `json:"url"`
}
if err := json.Unmarshal(output, &prs); err != nil {
log.Debugf("Failed to parse PR list JSON: %v (raw: %s)", err, string(output))
return "", fmt.Errorf("failed to parse PR list: %w", err)
}
if len(prs) == 0 {
log.Debugf("No existing open PRs found for branch %s", headBranch)
return "", nil
}
log.Debugf("Found existing PR for branch %s: %s", headBranch, prs[0].URL)
return prs[0].URL, nil
}
// createCIPR creates a pull request for CI using the GitHub CLI
func createCIPR(headBranch, baseBranch, title, body string) (string, error) {
cmd := exec.Command("gh", "pr", "create",

540
web/AGENTS.md Normal file
View File

@@ -0,0 +1,540 @@
# Frontend Standards
This file is the single source of truth for frontend coding standards across all Onyx frontend
projects (including, but not limited to, `/web`, `/desktop`).
# Components
UI components are spread across several directories while the codebase migrates to Opal:
- **`web/lib/opal/src/`** — The Opal design system. Preferred for all new components.
- **`web/src/refresh-components/`** — Production components not yet migrated to Opal.
- **`web/src/sections/`** — Feature-specific composite components (cards, modals, etc.).
- **`web/src/layouts/`** — Page-level layout components (settings pages, etc.).
**Do NOT use anything from `web/src/components/`** — this directory contains legacy components
that are being phased out. Always prefer Opal first; fall back to `refresh-components` only for
components not yet available in Opal.
## Opal Layouts (`lib/opal/src/layouts/`)
All layout primitives are imported from `@opal/layouts`. They handle sizing, font selection, icon
alignment, and optional inline editing.
```typescript
import { Content, ContentAction, IllustrationContent } from "@opal/layouts";
```
### Content
**Use this for any combination of icon + title + description.**
A two-axis layout component that automatically routes to the correct internal layout
(`ContentXl`, `ContentLg`, `ContentMd`, `ContentSm`) based on `sizePreset` and `variant`:
| sizePreset | variant | Routes to | Layout |
|---|---|---|---|
| `headline` / `section` | `heading` | `ContentXl` | Icon on top (flex-col) |
| `headline` / `section` | `section` | `ContentLg` | Icon inline (flex-row) |
| `main-content` / `main-ui` / `secondary` | `section` / `heading` | `ContentMd` | Compact inline |
| `main-content` / `main-ui` / `secondary` | `body` | `ContentSm` | Body text layout |
```typescript
<Content
sizePreset="main-ui"
variant="section"
icon={SvgSettings}
title="Settings"
description="Manage your preferences"
/>
```
### ContentAction
**Use this when a Content block needs right-side actions** (buttons, badges, icons, etc.).
Wraps `Content` and adds a `rightChildren` slot. Accepts all `Content` props plus:
- `rightChildren`: `ReactNode` — actions rendered on the right
- `paddingVariant`: `SizeVariant` — controls outer padding
```typescript
<ContentAction
sizePreset="main-ui"
variant="section"
icon={SvgUser}
title="John Doe"
description="Admin"
rightChildren={<Button icon={SvgEdit}>Edit</Button>}
/>
```
### IllustrationContent
**Use this for empty states, error pages, and informational placeholders.**
A vertically-stacked, center-aligned layout that pairs a large illustration (7.5rem x 7.5rem)
with a title and optional description.
```typescript
import SvgNoResult from "@opal/illustrations/no-result";
<IllustrationContent
illustration={SvgNoResult}
title="No results found"
description="Try adjusting your search or filters."
/>
```
Props:
- `illustration`: `IconFunctionComponent` — optional, from `@opal/illustrations`
- `title`: `string` — required
- `description`: `string` — optional
## Settings Page Layout (`src/layouts/settings-layouts.tsx`)
**Use this for all admin/settings pages.** Provides a standardized layout with scroll-aware
sticky headers, centered content containers, and responsive behavior.
```typescript
import SettingsLayouts from "@/layouts/settings-layouts";
function MySettingsPage() {
return (
<SettingsLayouts.Root>
<SettingsLayouts.Header
icon={SvgSettings}
title="Account Settings"
description="Manage your account preferences"
rightChildren={<Button>Save</Button>}
>
<InputTypeIn placeholder="Search settings..." />
</SettingsLayouts.Header>
<SettingsLayouts.Body>
<Card>Settings content here</Card>
</SettingsLayouts.Body>
</SettingsLayouts.Root>
);
}
```
Sub-components:
- **`SettingsLayouts.Root`** — Wrapper with centered, scrollable container. Width options:
`"sm"` (672px), `"sm-md"` (752px), `"md"` (872px, default), `"lg"` (992px), `"full"` (100%).
- **`SettingsLayouts.Header`** — Sticky header with icon, title, description, optional
`rightChildren` actions, optional `children` below (e.g., search/filter), optional `backButton`,
and optional `separator`. Automatically shows a scroll shadow when scrolled.
- **`SettingsLayouts.Body`** — Content container with consistent padding and vertical spacing.
## Cards (`src/sections/cards/`)
**When building a card that displays information about a specific entity (agent, document set,
file, connector, etc.), add it to `web/src/sections/cards/`.**
Each card is a self-contained component focused on a single entity type. Cards typically include
entity identification (name, avatar, icon), summary information, and quick actions.
```typescript
import AgentCard from "@/sections/cards/AgentCard";
import DocumentSetCard from "@/sections/cards/DocumentSetCard";
import FileCard from "@/sections/cards/FileCard";
```
Guidelines:
- One card per entity type — keep card-specific logic within the card component.
- Cards should be reusable across different pages and contexts.
- Use shared components from `@opal/components`, `@opal/layouts`, and `@/refresh-components`
inside cards — do not duplicate layout or styling logic.
## Button (`components/buttons/button/`)
**Always use the Opal `Button`.** Do not use raw `<button>` elements.
Built on `Interactive.Stateless` > `Interactive.Container`, so it inherits the full color/state
system automatically.
```typescript
import { Button } from "@opal/components/buttons/button/components";
// Labeled button
<Button variant="default" prominence="primary" icon={SvgPlus}>
Create
</Button>
// Icon-only button (omit children)
<Button variant="default" prominence="tertiary" icon={SvgTrash} size="sm" />
```
Key props:
- `variant`: `"default"` | `"action"` | `"danger"` | `"none"`
- `prominence`: `"primary"` | `"secondary"` | `"tertiary"` | `"internal"`
- `size`: `"lg"` | `"md"` | `"sm"` | `"xs"` | `"2xs"` | `"fit"`
- `icon`, `rightIcon`, `children`, `disabled`, `href`, `tooltip`
## Core Primitives (`core/`)
The `core/` directory contains the lowest-level building blocks that power all Opal components.
**Most code should not interface with these directly** — use higher-level components like `Button`,
`Content`, and `ContentAction` instead. These are documented here for understanding, not everyday use.
### Interactive (`core/interactive/`)
The foundational layer for all clickable/interactive surfaces. Defines the color matrix for
hover, active, and disabled states.
- **`Interactive.Stateless`** — Color system for stateless elements (buttons, links). Applies
variant/prominence/state combinations via CSS custom properties.
- **`Interactive.Stateful`** — Color system for stateful elements (toggles, sidebar items, selects).
Uses `state` (`"empty"` | `"filled"` | `"selected"`) instead of prominence.
- **`Interactive.Container`** — Structural box providing height, rounding, padding, and border.
Shared by both Stateless and Stateful. Renders as `<div>`, `<button>`, or `<Link>` depending
on context.
- **`Interactive.Foldable`** — Zero-width collapsible wrapper with CSS grid animation.
### Disabled (`core/disabled/`)
Propagates disabled state via React context. `Interactive.Stateless` and `Interactive.Stateful`
consume this automatically, so wrapping a subtree in `<Disabled disabled={true}>` disables all
interactive descendants.
### Hoverable (`core/animations/`)
A standardized way to provide "opacity-100 on hover" behavior. Instead of manually wiring
`opacity-0 group-hover:opacity-100` with Tailwind, use `Hoverable` for consistent, coordinated
hover-to-reveal patterns.
- **`Hoverable.Root`** — Wraps a hover group. Tracks mouse enter/leave and broadcasts hover
state to descendants via a per-group React context.
- **`Hoverable.Item`** — Marks an element that should appear on hover. Supports two modes:
- **Group mode** (`group` prop provided): visibility driven by a matching `Hoverable.Root`
ancestor. Throws if no matching Root is found.
- **Local mode** (`group` omitted): uses CSS `:hover` on the item itself.
```typescript
import { Hoverable } from "@opal/core";
// Group mode — hovering anywhere on the row reveals the trash icon
<Hoverable.Root group="row">
<div className="flex items-center gap-2">
<span>Row content</span>
<Hoverable.Item group="row" variant="opacity-on-hover">
<SvgTrash />
</Hoverable.Item>
</div>
</Hoverable.Root>
// Local mode — hovering the item itself reveals it
<Hoverable.Item variant="opacity-on-hover">
<SvgTrash />
</Hoverable.Item>
```
# Best Practices
## 1. Tailwind Dark Mode
**Strictly forbid using the `dark:` modifier in Tailwind classes, except for logo icon handling.**
**Reason:** The `colors.css` file already, VERY CAREFULLY, defines what the exact opposite colour of each light-mode colour is. Overriding this behaviour is VERY bad and will lead to horrible UI breakages.
**Exception:** The `createLogoIcon` helper in `web/src/components/icons/icons.tsx` uses `dark:` modifiers (`dark:invert`, `dark:hidden`, `dark:block`) to handle third-party logo icons that cannot automatically adapt through `colors.css`. This is the ONLY acceptable use of dark mode modifiers.
```typescript
// ✅ Good - Standard components use `tailwind-themes/tailwind.config.js` / `src/app/css/colors.css`
<div className="bg-background-neutral-03 text-text-02">
Content
</div>
// ✅ Good - Logo icons with dark mode handling via createLogoIcon
export const GithubIcon = createLogoIcon(githubLightIcon, {
monochromatic: true, // Will apply dark:invert internally
});
export const GitbookIcon = createLogoIcon(gitbookLightIcon, {
darkSrc: gitbookDarkIcon, // Will use dark:hidden/dark:block internally
});
// ❌ Bad - Manual dark mode overrides
<div className="bg-white dark:bg-black text-black dark:text-white">
Content
</div>
```
## 2. Icon Usage
**ONLY use icons from the `web/src/icons` directory. Do NOT use icons from `react-icons`, `lucide`, or other external libraries.**
**Reason:** We have a very carefully curated selection of icons that match our Onyx guidelines. We do NOT want to muddy those up with different aesthetic stylings.
```typescript
// ✅ Good
import SvgX from "@/icons/x";
import SvgMoreHorizontal from "@/icons/more-horizontal";
// ❌ Bad
import { User } from "lucide-react";
import { FiSearch } from "react-icons/fi";
```
**Missing Icons**: If an icon is needed but doesn't exist in the `web/src/icons` directory, import it from Figma using the Figma MCP tool and add it to the icons directory.
If you need help with this step, reach out to `raunak@onyx.app`.
## 3. Text Rendering
**Prefer using the `refresh-components/texts/Text` component for all text rendering. Avoid "naked" text nodes.**
**Reason:** The `Text` component is fully compliant with the stylings provided in Figma. It provides easy utilities to specify the text-colour and font-size in the form of flags. Super duper easy.
```typescript
// ✅ Good
import { Text } from '@/refresh-components/texts/Text'
function UserCard({ name }: { name: string }) {
return (
<Text
{/* The `text03` flag makes the text it renders to be coloured the 3rd-scale grey */}
text03
{/* The `mainAction` flag makes the text it renders to be "main-action" font + line-height + weightage, as described in the Figma */}
mainAction
>
{name}
</Text>
)
}
// ❌ Bad
function UserCard({ name }: { name: string }) {
return (
<div>
<h2>{name}</h2>
<p>User details</p>
</div>
)
}
```
## 4. Component Usage
**Heavily avoid raw HTML input components. Always use components from the `web/src/refresh-components` or `web/lib/opal/src` directory.**
**Reason:** We've put in a lot of effort to unify the components that are rendered in the Onyx app. Using raw components breaks the entire UI of the application, and leaves it in a muddier state than before.
```typescript
// ✅ Good
import Button from '@/refresh-components/buttons/Button'
import InputTypeIn from '@/refresh-components/inputs/InputTypeIn'
import SvgPlusCircle from '@/icons/plus-circle'
function ContactForm() {
return (
<form>
<InputTypeIn placeholder="Search..." />
<Button type="submit" leftIcon={SvgPlusCircle}>Submit</Button>
</form>
)
}
// ❌ Bad
function ContactForm() {
return (
<form>
<input placeholder="Name" />
<textarea placeholder="Message" />
<button type="submit">Submit</button>
</form>
)
}
```
## 5. Colors
**Always use custom overrides for colors and borders rather than built in Tailwind CSS colors. These overrides live in `web/tailwind-themes/tailwind.config.js`.**
**Reason:** Our custom color system uses CSS variables that automatically handle dark mode and maintain design consistency across the app. Standard Tailwind colors bypass this system.
**Available color categories:**
- **Text:** `text-01` through `text-05`, `text-inverted-XX`
- **Backgrounds:** `background-neutral-XX`, `background-tint-XX` (and inverted variants)
- **Borders:** `border-01` through `border-05`, `border-inverted-XX`
- **Actions:** `action-link-XX`, `action-danger-XX`
- **Status:** `status-info-XX`, `status-success-XX`, `status-warning-XX`, `status-error-XX`
- **Theme:** `theme-primary-XX`, `theme-red-XX`, `theme-blue-XX`, etc.
```typescript
// ✅ Good - Use custom Onyx color classes
<div className="bg-background-neutral-01 border border-border-02" />
<div className="bg-background-tint-02 border border-border-01" />
<div className="bg-status-success-01" />
<div className="bg-action-link-01" />
<div className="bg-theme-primary-05" />
// ❌ Bad - Do NOT use standard Tailwind colors
<div className="bg-gray-100 border border-gray-300 text-gray-600" />
<div className="bg-white border border-slate-200" />
<div className="bg-green-100 text-green-700" />
<div className="bg-blue-100 text-blue-600" />
<div className="bg-indigo-500" />
```
## 6. Data Fetching
**Prefer using `useSWR` for data fetching. Data should generally be fetched on the client side. Components that need data should display a loader / placeholder while waiting for that data. Prefer loading data within the component that needs it rather than at the top level and passing it down.**
**Reason:** Client side fetching allows us to load the skeleton of the page without waiting for data to load, leading to a snappier UX. Loading data where needed reduces dependencies between a component and its parent component(s).
# Stylistic Preferences
## 1. Import Standards
**Always use absolute imports with the `@` prefix.**
**Reason:** Moving files around becomes easier since you don't also have to update those import statements. This makes modifications to the codebase much nicer.
```typescript
// ✅ Good
import { Button } from "@/components/ui/button";
import { useAuth } from "@/hooks/useAuth";
import { Text } from "@/refresh-components/texts/Text";
// ❌ Bad
import { Button } from "../../../components/ui/button";
import { useAuth } from "./hooks/useAuth";
```
## 2. React Component Functions
**Prefer regular functions over arrow functions for React components.**
**Reason:** Functions just become easier to read.
```typescript
// ✅ Good
function UserProfile({ userId }: UserProfileProps) {
return <div>User Profile</div>
}
// ❌ Bad
const UserProfile = ({ userId }: UserProfileProps) => {
return <div>User Profile</div>
}
```
## 3. Props Interface Extraction
**Extract prop types into their own interface definitions. Keep prop interfaces in the same file
as the component they belong to. Non-prop types (shared models, API response shapes, enums, etc.)
should be placed in a co-located `interfaces.ts` file.**
**Reason:** Prop interfaces are tightly coupled to their component and rarely imported elsewhere,
so co-location keeps things simple. Shared types belong in `interfaces.ts` so they can be
imported without pulling in component code.
```typescript
// ✅ Good — props interface in the same file as the component
// UserCard.tsx
interface UserCardProps {
user: User
showActions?: boolean
onEdit?: (userId: string) => void
}
function UserCard({ user, showActions = false, onEdit }: UserCardProps) {
return <div>User Card</div>
}
// ✅ Good — shared types in interfaces.ts
// interfaces.ts
export interface User {
id: string
name: string
role: UserRole
}
export type UserRole = "admin" | "member" | "viewer"
// ❌ Bad — inline prop types
function UserCard({
user,
showActions = false,
onEdit
}: {
user: User
showActions?: boolean
onEdit?: (userId: string) => void
}) {
return <div>User Card</div>
}
```
## 4. Spacing Guidelines
**Prefer padding over margins for spacing. When a library component exposes a padding prop
(e.g., `paddingVariant`), use that prop instead of wrapping it in a `<div>` with padding classes.
If a library component does not expose a padding override and you find yourself adding a wrapper
div for spacing, consider updating the library component to accept one.**
**Reason:** We want to consolidate usage to paddings instead of margins, and minimize wrapper
divs that exist solely for spacing.
```typescript
// ✅ Good — use the component's padding prop
<ContentAction paddingVariant="md" ... />
// ✅ Good — padding utilities when no component prop exists
<div className="p-4 space-y-2">
<div className="p-2">Content</div>
</div>
// ❌ Bad — wrapper div just for spacing
<div className="p-4">
<ContentAction ... />
</div>
// ❌ Bad — margins
<div className="m-4 space-y-2">
<div className="m-2">Content</div>
</div>
```
## 5. Class Name Utilities
**Use the `cn` utility instead of raw string formatting for classNames.**
**Reason:** `cn`s are easier to read. They also allow for more complex types (i.e., string-arrays) to get formatted properly (it flattens each element in that string array down). As a result, it can allow things such as conditionals (i.e., `myCondition && "some-tailwind-class"`, which evaluates to `false` when `myCondition` is `false`) to get filtered out.
```typescript
import { cn } from '@/lib/utils'
// ✅ Good
<div className={cn(
'base-class',
isActive && 'active-class',
className
)}>
Content
</div>
// ❌ Bad
<div className={`base-class ${isActive ? 'active-class' : ''} ${className}`}>
Content
</div>
```
## 6. Custom Hooks Organization
**Follow a "hook-per-file" layout. Each hook should live in its own file within `web/src/hooks`.**
**Reason:** This is just a layout preference. Keeps code clean.
```typescript
// web/src/hooks/useUserData.ts
export function useUserData(userId: string) {
// hook implementation
}
// web/src/hooks/useLocalStorage.ts
export function useLocalStorage<T>(key: string, initialValue: T) {
// hook implementation
}
```

1
web/CLAUDE.md Symbolic link
View File

@@ -0,0 +1 @@
AGENTS.md

View File

@@ -1,281 +0,0 @@
# Web Standards
This document outlines the coding standards and best practices for the `web` directory Next.js project.
## 1. Import Standards
**Always use absolute imports with the `@` prefix.**
**Reason:** Moving files around becomes easier since you don't also have to update those import statements. This makes modifications to the codebase much nicer.
```typescript
// ✅ Good
import { Button } from "@/components/ui/button";
import { useAuth } from "@/hooks/useAuth";
import { Text } from "@/refresh-components/texts/Text";
// ❌ Bad
import { Button } from "../../../components/ui/button";
import { useAuth } from "./hooks/useAuth";
```
## 2. React Component Functions
**Prefer regular functions over arrow functions for React components.**
**Reason:** Functions just become easier to read.
```typescript
// ✅ Good
function UserProfile({ userId }: UserProfileProps) {
return <div>User Profile</div>
}
// ❌ Bad
const UserProfile = ({ userId }: UserProfileProps) => {
return <div>User Profile</div>
}
```
## 3. Props Interface Extraction
**Extract prop types into their own interface definitions.**
**Reason:** Functions just become easier to read.
```typescript
// ✅ Good
interface UserCardProps {
user: User
showActions?: boolean
onEdit?: (userId: string) => void
}
function UserCard({ user, showActions = false, onEdit }: UserCardProps) {
return <div>User Card</div>
}
// ❌ Bad
function UserCard({
user,
showActions = false,
onEdit
}: {
user: User
showActions?: boolean
onEdit?: (userId: string) => void
}) {
return <div>User Card</div>
}
```
## 4. Spacing Guidelines
**Prefer padding over margins for spacing.**
**Reason:** We want to consolidate usage to paddings instead of margins.
```typescript
// ✅ Good
<div className="p-4 space-y-2">
<div className="p-2">Content</div>
</div>
// ❌ Bad
<div className="m-4 space-y-2">
<div className="m-2">Content</div>
</div>
```
## 5. Tailwind Dark Mode
**Strictly forbid using the `dark:` modifier in Tailwind classes, except for logo icon handling.**
**Reason:** The `colors.css` file already, VERY CAREFULLY, defines what the exact opposite colour of each light-mode colour is. Overriding this behaviour is VERY bad and will lead to horrible UI breakages.
**Exception:** The `createLogoIcon` helper in `web/src/components/icons/icons.tsx` uses `dark:` modifiers (`dark:invert`, `dark:hidden`, `dark:block`) to handle third-party logo icons that cannot automatically adapt through `colors.css`. This is the ONLY acceptable use of dark mode modifiers.
```typescript
// ✅ Good - Standard components use `tailwind-themes/tailwind.config.js` / `src/app/css/colors.css`
<div className="bg-background-neutral-03 text-text-02">
Content
</div>
// ✅ Good - Logo icons with dark mode handling via createLogoIcon
export const GithubIcon = createLogoIcon(githubLightIcon, {
monochromatic: true, // Will apply dark:invert internally
});
export const GitbookIcon = createLogoIcon(gitbookLightIcon, {
darkSrc: gitbookDarkIcon, // Will use dark:hidden/dark:block internally
});
// ❌ Bad - Manual dark mode overrides
<div className="bg-white dark:bg-black text-black dark:text-white">
Content
</div>
```
## 6. Class Name Utilities
**Use the `cn` utility instead of raw string formatting for classNames.**
**Reason:** `cn`s are easier to read. They also allow for more complex types (i.e., string-arrays) to get formatted properly (it flattens each element in that string array down). As a result, it can allow things such as conditionals (i.e., `myCondition && "some-tailwind-class"`, which evaluates to `false` when `myCondition` is `false`) to get filtered out.
```typescript
import { cn } from '@/lib/utils'
// ✅ Good
<div className={cn(
'base-class',
isActive && 'active-class',
className
)}>
Content
</div>
// ❌ Bad
<div className={`base-class ${isActive ? 'active-class' : ''} ${className}`}>
Content
</div>
```
## 7. Custom Hooks Organization
**Follow a "hook-per-file" layout. Each hook should live in its own file within `web/src/hooks`.**
**Reason:** This is just a layout preference. Keeps code clean.
```typescript
// web/src/hooks/useUserData.ts
export function useUserData(userId: string) {
// hook implementation
}
// web/src/hooks/useLocalStorage.ts
export function useLocalStorage<T>(key: string, initialValue: T) {
// hook implementation
}
```
## 8. Icon Usage
**ONLY use icons from the `web/src/icons` directory. Do NOT use icons from `react-icons`, `lucide`, or other external libraries.**
**Reason:** We have a very carefully curated selection of icons that match our Onyx guidelines. We do NOT want to muddy those up with different aesthetic stylings.
```typescript
// ✅ Good
import SvgX from "@/icons/x";
import SvgMoreHorizontal from "@/icons/more-horizontal";
// ❌ Bad
import { User } from "lucide-react";
import { FiSearch } from "react-icons/fi";
```
**Missing Icons**: If an icon is needed but doesn't exist in the `web/src/icons` directory, import it from Figma using the Figma MCP tool and add it to the icons directory.
If you need help with this step, reach out to `raunak@onyx.app`.
## 9. Text Rendering
**Prefer using the `refresh-components/texts/Text` component for all text rendering. Avoid "naked" text nodes.**
**Reason:** The `Text` component is fully compliant with the stylings provided in Figma. It provides easy utilities to specify the text-colour and font-size in the form of flags. Super duper easy.
```typescript
// ✅ Good
import { Text } from '@/refresh-components/texts/Text'
function UserCard({ name }: { name: string }) {
return (
<Text
{/* The `text03` flag makes the text it renders to be coloured the 3rd-scale grey */}
text03
{/* The `mainAction` flag makes the text it renders to be "main-action" font + line-height + weightage, as described in the Figma */}
mainAction
>
{name}
</Text>
)
}
// ❌ Bad
function UserCard({ name }: { name: string }) {
return (
<div>
<h2>{name}</h2>
<p>User details</p>
</div>
)
}
```
## 10. Component Usage
**Heavily avoid raw HTML input components. Always use components from the `web/src/refresh-components` or `web/lib/opal/src` directory.**
**Reason:** We've put in a lot of effort to unify the components that are rendered in the Onyx app. Using raw components breaks the entire UI of the application, and leaves it in a muddier state than before.
```typescript
// ✅ Good
import Button from '@/refresh-components/buttons/Button'
import InputTypeIn from '@/refresh-components/inputs/InputTypeIn'
import SvgPlusCircle from '@/icons/plus-circle'
function ContactForm() {
return (
<form>
<InputTypeIn placeholder="Search..." />
<Button type="submit" leftIcon={SvgPlusCircle}>Submit</Button>
</form>
)
}
// ❌ Bad
function ContactForm() {
return (
<form>
<input placeholder="Name" />
<textarea placeholder="Message" />
<button type="submit">Submit</button>
</form>
)
}
```
## 11. Colors
**Always use custom overrides for colors and borders rather than built in Tailwind CSS colors. These overrides live in `web/tailwind-themes/tailwind.config.js`.**
**Reason:** Our custom color system uses CSS variables that automatically handle dark mode and maintain design consistency across the app. Standard Tailwind colors bypass this system.
**Available color categories:**
- **Text:** `text-01` through `text-05`, `text-inverted-XX`
- **Backgrounds:** `background-neutral-XX`, `background-tint-XX` (and inverted variants)
- **Borders:** `border-01` through `border-05`, `border-inverted-XX`
- **Actions:** `action-link-XX`, `action-danger-XX`
- **Status:** `status-info-XX`, `status-success-XX`, `status-warning-XX`, `status-error-XX`
- **Theme:** `theme-primary-XX`, `theme-red-XX`, `theme-blue-XX`, etc.
```typescript
// ✅ Good - Use custom Onyx color classes
<div className="bg-background-neutral-01 border border-border-02" />
<div className="bg-background-tint-02 border border-border-01" />
<div className="bg-status-success-01" />
<div className="bg-action-link-01" />
<div className="bg-theme-primary-05" />
// ❌ Bad - Do NOT use standard Tailwind colors
<div className="bg-gray-100 border border-gray-300 text-gray-600" />
<div className="bg-white border border-slate-200" />
<div className="bg-green-100 text-green-700" />
<div className="bg-blue-100 text-blue-600" />
<div className="bg-indigo-500" />
```
## 12. Data Fetching
**Prefer using `useSWR` for data fetching. Data should generally be fetched on the client side. Components that need data should display a loader / placeholder while waiting for that data. Prefer loading data within the component that needs it rather than at the top level and passing it down.**
**Reason:** Client side fetching allows us to load the skeleton of the page without waiting for data to load, leading to a snappier UX. Loading data where needed reduces dependencies between a component and its parent component(s).

View File

@@ -52,3 +52,8 @@ export {
type PaginationProps,
type PaginationSize,
} from "@opal/components/pagination/components";
/* Table */
export { Table } from "@opal/components/table/components";
export { createTableColumns } from "@opal/components/table/columns";
export type { DataTableProps } from "@opal/components/table/components";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,82 @@
# Table
Config-driven table component with sorting, pagination, column visibility,
row selection, drag-and-drop reordering, and server-side mode.
## Usage
```tsx
import { Table, createTableColumns } from "@opal/components";
interface User {
id: string;
email: string;
name: string | null;
status: "active" | "invited";
}
const tc = createTableColumns<User>();
const columns = [
tc.qualifier({ content: "avatar-user", getInitials: (r) => r.name?.[0] ?? "?" }),
tc.column("email", {
header: "Name",
weight: 22,
minWidth: 140,
cell: (email, row) => <span>{row.name ?? email}</span>,
}),
tc.column("status", {
header: "Status",
weight: 14,
cell: (status) => <span>{status}</span>,
}),
tc.actions(),
];
function UsersTable({ users }: { users: User[] }) {
return (
<Table
data={users}
columns={columns}
getRowId={(r) => r.id}
pageSize={10}
footer={{ mode: "summary" }}
/>
);
}
```
## Props
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `data` | `TData[]` | required | Row data array |
| `columns` | `OnyxColumnDef<TData>[]` | required | Column definitions from `createTableColumns()` |
| `getRowId` | `(row: TData) => string` | required | Unique row identifier |
| `pageSize` | `number` | `10` | Rows per page (`Infinity` disables pagination) |
| `size` | `"md" \| "lg"` | `"lg"` | Density variant |
| `footer` | `DataTableFooterConfig` | — | Footer mode (`"selection"` or `"summary"`) |
| `initialSorting` | `SortingState` | — | Initial sort state |
| `initialColumnVisibility` | `VisibilityState` | — | Initial column visibility |
| `draggable` | `DataTableDraggableConfig` | — | Enable drag-and-drop reordering |
| `onSelectionChange` | `(ids: string[]) => void` | — | Selection callback |
| `onRowClick` | `(row: TData) => void` | — | Row click handler |
| `searchTerm` | `string` | — | Global text filter |
| `height` | `number \| string` | — | Max scrollable height |
| `headerBackground` | `string` | — | Sticky header background |
| `serverSide` | `ServerSideConfig` | — | Server-side pagination/sorting/filtering |
| `emptyState` | `ReactNode` | — | Empty state content |
## Column Builder
`createTableColumns<TData>()` returns a builder with:
- `tc.qualifier(opts)` — leading avatar/icon/checkbox column
- `tc.column(accessor, opts)` — data column with sorting/resizing
- `tc.displayColumn(opts)` — non-accessor custom column
- `tc.actions(opts)` — trailing actions column with visibility/sorting popovers
## Footer Modes
- **`"selection"`** — shows selection count, optional view/clear buttons, count pagination
- **`"summary"`** — shows "Showing X~Y of Z", list pagination, optional extra element

View File

@@ -0,0 +1,148 @@
import type { Meta, StoryObj } from "@storybook/react";
import { Table, createTableColumns } from "@opal/components";
// ---------------------------------------------------------------------------
// Sample data
// ---------------------------------------------------------------------------
interface User {
id: string;
email: string;
name: string;
role: "admin" | "user" | "viewer";
status: "active" | "invited" | "inactive";
}
const USERS: User[] = [
{
id: "1",
email: "alice@example.com",
name: "Alice Johnson",
role: "admin",
status: "active",
},
{
id: "2",
email: "bob@example.com",
name: "Bob Smith",
role: "user",
status: "active",
},
{
id: "3",
email: "carol@example.com",
name: "Carol White",
role: "viewer",
status: "invited",
},
{
id: "4",
email: "dave@example.com",
name: "Dave Brown",
role: "user",
status: "inactive",
},
{
id: "5",
email: "eve@example.com",
name: "Eve Davis",
role: "admin",
status: "active",
},
{
id: "6",
email: "frank@example.com",
name: "Frank Miller",
role: "viewer",
status: "active",
},
{
id: "7",
email: "grace@example.com",
name: "Grace Lee",
role: "user",
status: "invited",
},
{
id: "8",
email: "hank@example.com",
name: "Hank Wilson",
role: "user",
status: "active",
},
{
id: "9",
email: "iris@example.com",
name: "Iris Taylor",
role: "viewer",
status: "active",
},
{
id: "10",
email: "jack@example.com",
name: "Jack Moore",
role: "admin",
status: "active",
},
{
id: "11",
email: "kate@example.com",
name: "Kate Anderson",
role: "user",
status: "inactive",
},
{
id: "12",
email: "leo@example.com",
name: "Leo Thomas",
role: "viewer",
status: "active",
},
];
// ---------------------------------------------------------------------------
// Columns
// ---------------------------------------------------------------------------
const tc = createTableColumns<User>();
const columns = [
tc.qualifier({
content: "avatar-user",
getInitials: (r) =>
r.name
.split(" ")
.map((n) => n[0])
.join(""),
}),
tc.column("name", { header: "Name", weight: 25, minWidth: 120 }),
tc.column("email", { header: "Email", weight: 30, minWidth: 160 }),
tc.column("role", { header: "Role", weight: 15, minWidth: 80 }),
tc.column("status", { header: "Status", weight: 15, minWidth: 80 }),
tc.actions(),
];
// ---------------------------------------------------------------------------
// Story
// ---------------------------------------------------------------------------
const meta: Meta<typeof Table> = {
title: "opal/components/Table",
component: Table,
tags: ["autodocs"],
};
export default meta;
type Story = StoryObj<typeof Table>;
export const Default: Story = {
render: () => (
<Table
data={USERS}
columns={columns}
getRowId={(r) => r.id}
pageSize={8}
footer={{ mode: "summary" }}
/>
),
};

View File

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

View File

@@ -0,0 +1,71 @@
import React from "react";
import { cn } from "@opal/utils";
import type { WithoutStyles } from "@/types";
import type { ExtremaSizeVariants, SizeVariants } from "@opal/types";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type TableSize = Extract<SizeVariants, "md" | "lg">;
type TableVariant = "rows" | "cards";
type TableQualifier = "simple" | "avatar" | "icon";
type SelectionBehavior = "no-select" | "single-select" | "multi-select";
interface TableProps
extends WithoutStyles<React.TableHTMLAttributes<HTMLTableElement>> {
ref?: React.Ref<HTMLTableElement>;
/** Size preset for the table. @default "lg" */
size?: TableSize;
/** Visual row variant. @default "cards" */
variant?: TableVariant;
/** Row selection behavior. @default "no-select" */
selectionBehavior?: SelectionBehavior;
/** Leading qualifier column type. @default null */
qualifier?: TableQualifier;
/** Height behavior. `"fit"` = shrink to content, `"full"` = fill available space. */
heightVariant?: ExtremaSizeVariants;
/** Explicit pixel width for the table (e.g. from `table.getTotalSize()`).
* When provided the table uses exactly this width instead of stretching
* to fill its container, which prevents `table-layout: fixed` from
* redistributing extra space across columns on resize. */
width?: number;
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
function Table({
ref,
size = "lg",
variant = "cards",
selectionBehavior = "no-select",
qualifier = "simple",
heightVariant,
width,
...props
}: TableProps) {
return (
<table
ref={ref}
className={cn("border-separate border-spacing-0", !width && "min-w-full")}
style={{ tableLayout: "fixed", width }}
data-size={size}
data-variant={variant}
data-selection={selectionBehavior}
data-qualifier={qualifier}
data-height={heightVariant}
{...props}
/>
);
}
export default Table;
export type {
TableProps,
TableSize,
TableVariant,
TableQualifier,
SelectionBehavior,
};

View File

@@ -1,7 +1,7 @@
import { cn } from "@/lib/utils";
import { cn } from "@opal/utils";
import Text from "@/refresh-components/texts/Text";
import { useTableSize } from "@/refresh-components/table/TableSizeContext";
import type { TableSize } from "@/refresh-components/table/TableSizeContext";
import { useTableSize } from "@opal/components/table/TableSizeContext";
import type { TableSize } from "@opal/components/table/TableSizeContext";
import type { WithoutStyles } from "@/types";
import { Button } from "@opal/components";
import { SvgChevronDown, SvgChevronUp, SvgHandle, SvgSort } from "@opal/icons";
@@ -30,7 +30,7 @@ interface TableHeadCustomProps {
icon?: (sorted: SortDirection) => IconFunctionComponent;
/** Text alignment for the column. Defaults to `"left"`. */
alignment?: "left" | "center" | "right";
/** Cell density. `"small"` uses tighter padding for denser layouts. */
/** Cell density. `"md"` uses tighter padding for denser layouts. */
size?: TableSize;
/** Column width in pixels. Applied as an inline style on the `<th>`. */
width?: number;
@@ -88,7 +88,7 @@ export default function TableHead({
}: TableHeadProps) {
const contextSize = useTableSize();
const resolvedSize = size ?? contextSize;
const isSmall = resolvedSize === "small";
const isSmall = resolvedSize === "md";
return (
<th
{...thProps}

View File

@@ -1,12 +1,12 @@
"use client";
import React from "react";
import { cn } from "@/lib/utils";
import { useTableSize } from "@/refresh-components/table/TableSizeContext";
import type { TableSize } from "@/refresh-components/table/TableSizeContext";
import { cn } from "@opal/utils";
import { useTableSize } from "@opal/components/table/TableSizeContext";
import type { TableSize } from "@opal/components/table/TableSizeContext";
import { SvgUser } from "@opal/icons";
import type { IconFunctionComponent } from "@opal/types";
import type { QualifierContentType } from "@/refresh-components/table/types";
import type { QualifierContentType } from "@opal/components/table/types";
import Checkbox from "@/refresh-components/inputs/Checkbox";
import Text from "@/refresh-components/texts/Text";
@@ -35,8 +35,8 @@ interface TableQualifierProps {
}
const iconSizes = {
regular: 16,
small: 14,
lg: 16,
md: 14,
} as const;
function getQualifierStyles(selected: boolean, disabled: boolean) {
@@ -62,9 +62,9 @@ function getQualifierStyles(selected: boolean, disabled: boolean) {
container: "bg-background-tint-01",
icon: "stroke-text-03",
overlay:
"flex opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 bg-background-tint-01",
"flex opacity-0 group-hover/row:opacity-100 group-focus-within/row:opacity-100 bg-background-tint-01",
overlayImage:
"flex opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 bg-mask-01 group-hover:backdrop-blur-02 group-focus-within:backdrop-blur-02",
"flex opacity-0 group-hover/row:opacity-100 group-focus-within/row:opacity-100 bg-mask-01 group-hover/row:backdrop-blur-02 group-focus-within/row:backdrop-blur-02",
};
}
@@ -115,7 +115,7 @@ function TableQualifier({
<div
className={cn(
"flex items-center justify-center rounded-full bg-background-neutral-inverted-00",
resolvedSize === "regular" ? "h-7 w-7" : "h-6 w-6"
resolvedSize === "lg" ? "h-7 w-7" : "h-6 w-6"
)}
>
<Text
@@ -138,30 +138,36 @@ function TableQualifier({
<div
className={cn(
"group relative inline-flex shrink-0 items-center justify-center",
resolvedSize === "regular" ? "h-9 w-9" : "h-7 w-7",
resolvedSize === "lg" ? "h-9 w-9" : "h-7 w-7",
disabled ? "cursor-not-allowed" : "cursor-default",
className
)}
>
{/* Inner qualifier container */}
<div
className={cn(
"flex items-center justify-center overflow-hidden transition-colors",
resolvedSize === "regular" ? "h-9 w-9" : "h-7 w-7",
isRound ? "rounded-full" : "rounded-08",
styles.container,
content === "image" && disabled && !selected && "opacity-50"
)}
>
{renderContent()}
</div>
{/* Inner qualifier container — no background for "simple" */}
{content !== "simple" && (
<div
className={cn(
"flex items-center justify-center overflow-hidden transition-colors",
resolvedSize === "lg" ? "h-9 w-9" : "h-7 w-7",
isRound ? "rounded-full" : "rounded-08",
styles.container,
content === "image" && disabled && !selected && "opacity-50"
)}
>
{renderContent()}
</div>
)}
{/* Selection overlay */}
{selectable && (
<div
className={cn(
"absolute inset-0 items-center justify-center",
isRound ? "rounded-full" : "rounded-08",
content === "simple"
? "flex"
: isRound
? "rounded-full"
: "rounded-08",
content === "simple"
? "flex"
: content === "image"

View File

@@ -1,8 +1,8 @@
"use client";
import { cn } from "@/lib/utils";
import { useTableSize } from "@/refresh-components/table/TableSizeContext";
import type { TableSize } from "@/refresh-components/table/TableSizeContext";
import { cn } from "@opal/utils";
import { useTableSize } from "@opal/components/table/TableSizeContext";
import type { TableSize } from "@opal/components/table/TableSizeContext";
import type { WithoutStyles } from "@/types";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
@@ -18,8 +18,6 @@ interface TableRowProps
selected?: boolean;
/** Disables interaction and applies disabled styling */
disabled?: boolean;
/** Visual variant: "table" adds a bottom border, "list" adds rounded corners. Defaults to "list". */
variant?: "table" | "list";
/** When provided, makes this row sortable via @dnd-kit */
sortableId?: string;
/** Show drag handle overlay. Defaults to true when sortableId is set. */
@@ -36,7 +34,6 @@ function SortableTableRow({
sortableId,
showDragHandle = true,
size,
variant = "list",
selected,
disabled,
ref: _externalRef,
@@ -66,7 +63,6 @@ function SortableTableRow({
ref={setNodeRef}
style={style}
className="tbl-row group/row"
data-variant={variant}
data-drag-handle={showDragHandle || undefined}
data-selected={selected || undefined}
data-disabled={disabled || undefined}
@@ -95,7 +91,7 @@ function SortableTableRow({
{...listeners}
>
<SvgHandle
size={resolvedSize === "small" ? 12 : 16}
size={resolvedSize === "md" ? 12 : 16}
className="text-border-02"
/>
</button>
@@ -113,7 +109,6 @@ function TableRow({
sortableId,
showDragHandle,
size,
variant = "list",
selected,
disabled,
ref,
@@ -125,7 +120,6 @@ function TableRow({
sortableId={sortableId}
showDragHandle={showDragHandle}
size={size}
variant={variant}
selected={selected}
disabled={disabled}
ref={ref}
@@ -138,7 +132,6 @@ function TableRow({
<tr
ref={ref}
className="tbl-row group/row"
data-variant={variant}
data-selected={selected || undefined}
data-disabled={disabled || undefined}
{...props}

View File

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

View File

@@ -13,10 +13,10 @@ import type {
OnyxDataColumn,
OnyxDisplayColumn,
OnyxActionsColumn,
} from "@/refresh-components/table/types";
import type { TableSize } from "@/refresh-components/table/TableSizeContext";
} from "@opal/components/table/types";
import type { TableSize } from "@opal/components/table/TableSizeContext";
import type { IconFunctionComponent } from "@opal/types";
import type { SortDirection } from "@/refresh-components/table/TableHead";
import type { SortDirection } from "@opal/components/table/TableHead";
// ---------------------------------------------------------------------------
// Qualifier column config
@@ -160,7 +160,7 @@ export function createTableColumns<TData>(): TableColumnsBuilder<TData> {
id: "qualifier",
def,
width: (size: TableSize) =>
size === "small" ? { fixed: 40 } : { fixed: 56 },
size === "md" ? { fixed: 36 } : { fixed: 44 },
content,
headerContentType: config?.headerContentType,
getInitials: config?.getInitials,
@@ -241,14 +241,29 @@ export function createTableColumns<TData>(): TableColumnsBuilder<TData> {
: () => null,
};
const showVisibility = config?.showColumnVisibility ?? true;
const showSorting = config?.showSorting ?? true;
const buttonCount = (showVisibility ? 1 : 0) + (showSorting ? 1 : 0);
// Icon button sizes: "md" button = 28px, "sm" button = 24px
// px-1 on .tbl-actions = 4px each side = 8px total
const BUTTON_MD = 28;
const BUTTON_SM = 24;
const PADDING = 8;
return {
kind: "actions",
id: "__actions",
def,
width: (size: TableSize) =>
size === "small" ? { fixed: 20 } : { fixed: 88 },
showColumnVisibility: config?.showColumnVisibility ?? true,
showSorting: config?.showSorting ?? true,
width: (size: TableSize) => ({
fixed:
Math.max(
buttonCount * (size === "md" ? BUTTON_SM : BUTTON_MD),
size === "md" ? BUTTON_SM : BUTTON_MD
) + PADDING,
}),
showColumnVisibility: showVisibility,
showSorting: showSorting,
sortingFooterText: config?.sortingFooterText,
};
},

View File

@@ -1,39 +1,56 @@
"use client";
"use no memo";
import "@opal/components/table/styles.css";
import { useEffect, useMemo } from "react";
import { flexRender } from "@tanstack/react-table";
import useDataTable, {
toOnyxSortDirection,
} from "@/refresh-components/table/hooks/useDataTable";
import useColumnWidths from "@/refresh-components/table/hooks/useColumnWidths";
import useDraggableRows from "@/refresh-components/table/hooks/useDraggableRows";
import Table from "@/refresh-components/table/Table";
import TableHeader from "@/refresh-components/table/TableHeader";
import TableBody from "@/refresh-components/table/TableBody";
import TableRow from "@/refresh-components/table/TableRow";
import TableHead from "@/refresh-components/table/TableHead";
import TableCell from "@/refresh-components/table/TableCell";
import TableQualifier from "@/refresh-components/table/TableQualifier";
import QualifierContainer from "@/refresh-components/table/QualifierContainer";
import ActionsContainer from "@/refresh-components/table/ActionsContainer";
import DragOverlayRow from "@/refresh-components/table/DragOverlayRow";
import Footer from "@/refresh-components/table/Footer";
import { TableSizeProvider } from "@/refresh-components/table/TableSizeContext";
import { ColumnVisibilityPopover } from "@/refresh-components/table/ColumnVisibilityPopover";
import { SortingPopover } from "@/refresh-components/table/SortingPopover";
import type { WidthConfig } from "@/refresh-components/table/hooks/useColumnWidths";
} from "@opal/components/table/hooks/useDataTable";
import useColumnWidths from "@opal/components/table/hooks/useColumnWidths";
import useDraggableRows from "@opal/components/table/hooks/useDraggableRows";
import TableElement from "@opal/components/table/TableElement";
import TableHeader from "@opal/components/table/TableHeader";
import TableBody from "@opal/components/table/TableBody";
import TableRow from "@opal/components/table/TableRow";
import TableHead from "@opal/components/table/TableHead";
import TableCell from "@opal/components/table/TableCell";
import TableQualifier from "@opal/components/table/TableQualifier";
import QualifierContainer from "@opal/components/table/QualifierContainer";
import ActionsContainer from "@opal/components/table/ActionsContainer";
import DragOverlayRow from "@opal/components/table/DragOverlayRow";
import Footer from "@opal/components/table/Footer";
import Checkbox from "@/refresh-components/inputs/Checkbox";
import { TableSizeProvider } from "@opal/components/table/TableSizeContext";
import { ColumnVisibilityPopover } from "@opal/components/table/ColumnVisibilityPopover";
import { SortingPopover } from "@opal/components/table/ColumnSortabilityPopover";
import type { WidthConfig } from "@opal/components/table/hooks/useColumnWidths";
import type { ColumnDef } from "@tanstack/react-table";
import { cn } from "@/lib/utils";
import { cn } from "@opal/utils";
import type {
DataTableProps,
DataTableProps as BaseDataTableProps,
DataTableFooterConfig,
OnyxColumnDef,
OnyxDataColumn,
OnyxQualifierColumn,
OnyxActionsColumn,
} from "@/refresh-components/table/types";
import type { TableSize } from "@/refresh-components/table/TableSizeContext";
} from "@opal/components/table/types";
import type { TableSize } from "@opal/components/table/TableSizeContext";
// ---------------------------------------------------------------------------
// Qualifier × SelectionBehavior
// ---------------------------------------------------------------------------
type Qualifier = "simple" | "avatar" | "icon";
type SelectionBehavior = "no-select" | "single-select" | "multi-select";
export type DataTableProps<TData> = BaseDataTableProps<TData> & {
/** Leading qualifier column type. @default "simple" */
qualifier?: Qualifier;
/** Row selection behavior. @default "no-select" */
selectionBehavior?: SelectionBehavior;
};
// ---------------------------------------------------------------------------
// Internal: resolve size-dependent widths and build TanStack columns
@@ -57,6 +74,7 @@ function processColumns<TData>(
const columnMinWidths: Record<string, number> = {};
const columnKindMap = new Map<string, OnyxColumnDef<TData>>();
let qualifierColumn: OnyxQualifierColumn<TData> | null = null;
let firstDataColumnSeen = false;
for (const col of columns) {
const resolvedWidth =
@@ -70,6 +88,12 @@ function processColumns<TData>(
"fixed" in resolvedWidth ? resolvedWidth.fixed : resolvedWidth.weight,
};
// First data column is never hideable
if (col.kind === "data" && !firstDataColumnSeen) {
firstDataColumnSeen = true;
clonedDef.enableHiding = false;
}
tanstackColumns.push(clonedDef);
const id = col.id;
@@ -113,10 +137,10 @@ function processColumns<TData>(
* tc.actions(),
* ];
*
* <DataTable data={items} columns={columns} footer={{ mode: "selection" }} />
* <Table data={items} columns={columns} footer={{}} />
* ```
*/
export default function DataTable<TData>(props: DataTableProps<TData>) {
export function Table<TData>(props: DataTableProps<TData>) {
const {
data,
columns,
@@ -126,7 +150,10 @@ export default function DataTable<TData>(props: DataTableProps<TData>) {
initialColumnVisibility,
draggable,
footer,
size = "regular",
size = "lg",
variant = "cards",
qualifier = "simple",
selectionBehavior = "no-select",
onSelectionChange,
onRowClick,
searchTerm,
@@ -138,9 +165,37 @@ export default function DataTable<TData>(props: DataTableProps<TData>) {
const effectivePageSize = pageSize ?? (footer ? 10 : data.length);
// Whether the qualifier column should exist in the DOM.
// "simple" only gets a qualifier column for multi-select (checkboxes).
// "simple" + no-select/single-select = no qualifier column — single-select
// uses row-level background coloring instead.
const hasQualifierColumn =
qualifier !== "simple" || selectionBehavior === "multi-select";
// 1. Process columns (memoized on columns + size)
const { tanstackColumns, widthConfig, qualifierColumn, columnKindMap } =
useMemo(() => processColumns(columns, size), [columns, size]);
useMemo(() => {
const processed = processColumns(columns, size);
if (!hasQualifierColumn) {
// Remove qualifier from TanStack columns and width config entirely
return {
...processed,
tanstackColumns: processed.tanstackColumns.filter(
(c) => c.id !== "qualifier"
),
widthConfig: {
...processed.widthConfig,
fixedColumnIds: new Set(
Array.from(processed.widthConfig.fixedColumnIds).filter(
(id) => id !== "qualifier"
)
),
},
qualifierColumn: null,
};
}
return processed;
}, [columns, size, hasQualifierColumn]);
// 2. Call useDataTable
const {
@@ -155,7 +210,9 @@ export default function DataTable<TData>(props: DataTableProps<TData>) {
selectedRowIds,
clearSelection,
toggleAllPageRowsSelected,
toggleAllRowsSelected,
isAllPageRowsSelected,
isAllRowsSelected,
isViewingSelected,
enterViewMode,
exitViewMode,
@@ -193,16 +250,6 @@ export default function DataTable<TData>(props: DataTableProps<TData>) {
);
}
}, [!!serverSide, !!draggable]); // eslint-disable-line react-hooks/exhaustive-deps
const footerShowView =
footer?.mode === "selection" ? footer.showView : undefined;
useEffect(() => {
if (process.env.NODE_ENV !== "production" && serverSide && footerShowView) {
console.warn(
"DataTable: `showView` is ignored when `serverSide` is enabled. " +
"View mode requires client-side filtering."
);
}
}, [!!serverSide, !!footerShowView]); // eslint-disable-line react-hooks/exhaustive-deps
const effectiveDraggable = serverSide ? undefined : draggable;
const draggableReturn = useDraggableRows({
data,
@@ -212,10 +259,11 @@ export default function DataTable<TData>(props: DataTableProps<TData>) {
});
const hasDraggable = !!effectiveDraggable;
const rowVariant = hasDraggable ? "table" : "list";
const isSelectable =
qualifierColumn != null && qualifierColumn.selectable !== false;
const isSelectable = selectionBehavior !== "no-select";
const isMultiSelect = selectionBehavior === "multi-select";
// Checkboxes appear for any selectable table
const showQualifierCheckbox = isSelectable;
// ---------------------------------------------------------------------------
// Render
@@ -224,11 +272,13 @@ export default function DataTable<TData>(props: DataTableProps<TData>) {
const isServerLoading = !!serverSide?.isLoading;
function renderFooter(footerConfig: DataTableFooterConfig) {
if (footerConfig.mode === "selection") {
// Mode derived from selectionBehavior — single/multi-select use selection
// footer, no-select uses summary footer.
if (isSelectable) {
return (
<Footer
mode="selection"
multiSelect={footerConfig.multiSelect !== false}
multiSelect={isMultiSelect}
selectionState={selectionState}
selectedCount={selectedCount}
onClear={
@@ -239,22 +289,24 @@ export default function DataTable<TData>(props: DataTableProps<TData>) {
})
}
onView={
footerConfig.showView
!serverSide
? isViewingSelected
? exitViewMode
: enterViewMode
: undefined
}
isViewingSelected={isViewingSelected}
pageSize={resolvedPageSize}
totalItems={totalItems}
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setPage}
units={footerConfig.units}
/>
);
}
// Summary mode
// Summary mode (no-select only)
const rangeStart =
totalItems === 0
? 0
@@ -275,6 +327,7 @@ export default function DataTable<TData>(props: DataTableProps<TData>) {
totalPages={totalPages}
onPageChange={setPage}
leftExtra={footerConfig.leftExtra}
units={footerConfig.units}
/>
);
}
@@ -303,7 +356,10 @@ export default function DataTable<TData>(props: DataTableProps<TData>) {
: undefined),
}}
>
<Table
<TableElement
size={size}
variant={variant}
selectionBehavior={selectionBehavior}
width={
Object.keys(columnWidths).length > 0
? Object.values(columnWidths).reduce((sum, w) => sum + w, 0)
@@ -311,7 +367,7 @@ export default function DataTable<TData>(props: DataTableProps<TData>) {
}
>
<colgroup>
{table.getAllLeafColumns().map((col) => (
{table.getVisibleLeafColumns().map((col) => (
<col
key={col.id}
style={
@@ -328,28 +384,26 @@ export default function DataTable<TData>(props: DataTableProps<TData>) {
{headerGroup.headers.map((header, headerIndex) => {
const colDef = columnKindMap.get(header.id);
// Qualifier header
// Qualifier header — select-all checkbox only for multi-select
if (colDef?.kind === "qualifier") {
if (qualifierColumn?.header === false) {
return (
<QualifierContainer key={header.id} type="head" />
);
}
return (
<QualifierContainer key={header.id} type="head">
<TableQualifier
content={
qualifierColumn?.headerContentType ?? "simple"
}
selectable={isSelectable}
selected={isSelectable && isAllPageRowsSelected}
onSelectChange={
isSelectable
? (checked) =>
toggleAllPageRowsSelected(checked)
: undefined
}
/>
{isMultiSelect && (
<Checkbox
checked={isAllRowsSelected}
indeterminate={
!isAllRowsSelected && selectedCount > 0
}
onCheckedChange={(checked) => {
// Indeterminate → clear all; otherwise toggle normally
if (!isAllRowsSelected && selectedCount > 0) {
toggleAllRowsSelected(false);
} else {
toggleAllRowsSelected(checked);
}
}}
/>
)}
</QualifierContainer>
);
}
@@ -437,7 +491,6 @@ export default function DataTable<TData>(props: DataTableProps<TData>) {
return (
<DragOverlayRow
row={row}
variant={rowVariant}
columnWidths={columnWidths}
columnKindMap={columnKindMap}
qualifierColumn={qualifierColumn}
@@ -461,7 +514,6 @@ export default function DataTable<TData>(props: DataTableProps<TData>) {
return (
<TableRow
key={row.id}
variant={rowVariant}
sortableId={rowId}
selected={row.getIsSelected()}
onClick={() => {
@@ -474,6 +526,10 @@ export default function DataTable<TData>(props: DataTableProps<TData>) {
if (onRowClick) {
onRowClick(row.original);
} else if (isSelectable) {
if (!isMultiSelect) {
// single-select: clear all, then select this row
table.toggleAllRowsSelected(false);
}
row.toggleSelected();
}
}}
@@ -484,6 +540,13 @@ export default function DataTable<TData>(props: DataTableProps<TData>) {
// Qualifier cell
if (cellColDef?.kind === "qualifier") {
const qDef = cellColDef as OnyxQualifierColumn<TData>;
// Resolve content based on the qualifier prop:
// - "simple" renders nothing (checkbox only when selectable)
// - "avatar"/"icon" render from column config
const qualifierContent =
qualifier === "simple" ? "simple" : qDef.content;
return (
<QualifierContainer
key={cell.id}
@@ -491,15 +554,20 @@ export default function DataTable<TData>(props: DataTableProps<TData>) {
onClick={(e) => e.stopPropagation()}
>
<TableQualifier
content={qDef.content}
content={qualifierContent}
initials={qDef.getInitials?.(row.original)}
icon={qDef.getIcon?.(row.original)}
imageSrc={qDef.getImageSrc?.(row.original)}
selectable={isSelectable}
selected={isSelectable && row.getIsSelected()}
selectable={showQualifierCheckbox}
selected={
showQualifierCheckbox && row.getIsSelected()
}
onSelectChange={
isSelectable
showQualifierCheckbox
? (checked) => {
if (!isMultiSelect) {
table.toggleAllRowsSelected(false);
}
row.toggleSelected(checked);
}
: undefined
@@ -539,7 +607,7 @@ export default function DataTable<TData>(props: DataTableProps<TData>) {
);
})}
</TableBody>
</Table>
</TableElement>
</div>
{footer && renderFooter(footer)}

View File

@@ -153,6 +153,10 @@ interface UseDataTableReturn<TData extends RowData> {
clearSelection: () => void;
/** Select or deselect all rows on the current page. */
toggleAllPageRowsSelected: (selected: boolean) => void;
/** Select or deselect all rows across all pages. */
toggleAllRowsSelected: (selected: boolean) => void;
/** Whether every row across all pages is selected. */
isAllRowsSelected: boolean;
// View-mode (filter to selected rows)
/** Whether the table is currently filtered to show only selected rows. */
@@ -407,6 +411,16 @@ export default function useDataTable<TData extends RowData>(
table.toggleAllPageRowsSelected(selected);
};
// TODO (@raunakab): In server-side mode, these only operate on the loaded
// page data, not all rows across all pages. TanStack can't select rows it
// doesn't have. Fixing this requires a server-side callback (e.g.
// `onSelectAll`) and a `totalItems`-aware selection model.
const toggleAllRowsSelected = (selected: boolean) => {
table.toggleAllRowsSelected(selected);
};
const isAllRowsSelected = table.getIsAllRowsSelected();
// ---- view mode (filter to selected rows) --------------------------------
const isViewingSelected = globalFilter.selectedIds != null;
@@ -439,8 +453,10 @@ export default function useDataTable<TData extends RowData>(
selectedCount,
selectedRowIds,
isAllPageRowsSelected,
isAllRowsSelected,
clearSelection,
toggleAllPageRowsSelected,
toggleAllRowsSelected,
isViewingSelected,
enterViewMode,
exitViewMode,

View File

@@ -0,0 +1,164 @@
/* Imports shared timing tokens (--interactive-duration, --interactive-easing) */
@import "@opal/core/interactive/shared.css";
/* ---------------------------------------------------------------------------
* Table primitives — data-attribute driven styling
* Follows the same pattern as card.css / line-item.css.
* ------------------------------------------------------------------------- */
/* ---- TableCell ---- */
.tbl-cell[data-size="lg"] {
@apply px-1 py-0.5;
}
.tbl-cell[data-size="md"] {
@apply pl-0.5 pr-1.5 py-1.5;
}
.tbl-cell-inner[data-size="lg"] {
@apply h-10 px-1;
}
.tbl-cell-inner[data-size="md"] {
@apply h-6 px-0.5;
}
/* ---- TableHead ---- */
.table-head {
@apply relative sticky top-0 z-20;
background: var(--table-header-bg, transparent);
}
.table-head[data-size="lg"] {
@apply px-2 py-1;
}
.table-head[data-size="md"] {
@apply px-2 py-1;
}
.table-head[data-bottom-border] {
@apply border-b border-transparent hover:border-border-03;
}
/* Inner text wrapper */
.table-head[data-size="lg"] .table-head-label {
@apply py-2 px-0.5;
}
.table-head[data-size="md"] .table-head-label {
@apply py-1;
}
/* Sort button wrapper */
.table-head[data-size="lg"] .table-head-sort {
@apply py-1.5;
}
/* ---- TableRow (base) ---- */
.tbl-row > td {
@apply bg-background-tint-00;
transition: background-color var(--interactive-duration)
var(--interactive-easing);
}
.tbl-row[data-selected] > td {
@apply bg-[var(--action-link-01)];
}
.tbl-row[data-disabled] {
@apply pointer-events-none;
}
/* Suppress default focus ring on rows — the row bg is the indicator */
.tbl-row:focus,
.tbl-row:focus-visible {
outline: none;
}
/* ---- variant="rows" — traditional borders, no gaps ---- */
table[data-variant="rows"] .tbl-row > td {
@apply border-b border-border-01;
}
/* Hover/focus only for selectable tables */
table[data-variant="rows"][data-selection="single-select"] .tbl-row,
table[data-variant="rows"][data-selection="multi-select"] .tbl-row {
@apply cursor-pointer;
}
table[data-variant="rows"][data-selection="single-select"] .tbl-row:hover > td,
table[data-variant="rows"][data-selection="multi-select"] .tbl-row:hover > td {
@apply bg-background-tint-02;
}
table[data-variant="rows"] .tbl-row:focus-visible > td,
table[data-variant="rows"] .tbl-row:has(:focus-visible) > td {
@apply bg-action-link-01;
}
/* ---- variant="cards" — rounded cards with gap ---- */
table[data-variant="cards"] .tbl-row > td {
@apply bg-clip-padding border-y-[2px] border-x-0 border-transparent;
}
table[data-variant="cards"] .tbl-row > td:first-child {
@apply rounded-l-12;
}
table[data-variant="cards"] .tbl-row > td:last-child {
@apply rounded-r-12;
}
/* When a drag handle is present the second-to-last td gets the rounding */
table[data-variant="cards"] .tbl-row[data-drag-handle] > td:nth-last-child(2) {
@apply rounded-r-12;
}
table[data-variant="cards"] .tbl-row[data-drag-handle] > td:last-child {
border-radius: 0;
}
/* Hover/focus only for selectable tables */
table[data-variant="cards"][data-selection="single-select"] .tbl-row,
table[data-variant="cards"][data-selection="multi-select"] .tbl-row {
@apply cursor-pointer;
}
table[data-variant="cards"][data-selection="single-select"] .tbl-row:hover > td,
table[data-variant="cards"][data-selection="multi-select"] .tbl-row:hover > td {
@apply bg-background-tint-02;
}
table[data-variant="cards"] .tbl-row:focus-visible > td,
table[data-variant="cards"] .tbl-row:has(:focus-visible) > td {
@apply bg-action-link-01;
}
/* ---- QualifierContainer ---- */
.tbl-qualifier[data-type="head"] {
@apply w-px whitespace-nowrap py-1 sticky top-0 z-20;
background: var(--table-header-bg, transparent);
}
.tbl-qualifier[data-type="head"][data-size="md"] {
@apply py-0.5;
}
.tbl-qualifier[data-type="cell"] {
@apply w-px whitespace-nowrap py-1;
}
.tbl-qualifier[data-type="cell"][data-size="md"] {
@apply py-0.5;
}
/* ---- ActionsContainer ---- */
.tbl-actions {
@apply sticky right-0 w-px whitespace-nowrap px-1;
}
.tbl-actions[data-type="head"] {
@apply z-30 sticky top-0 px-2 py-1;
background: var(--table-header-bg, transparent);
}
/* ---- Footer ---- */
.table-footer[data-size="lg"] {
@apply min-h-[2.75rem];
}
.table-footer[data-size="md"] {
@apply min-h-[2.25rem];
}

View File

@@ -4,9 +4,10 @@ import type {
SortingState,
VisibilityState,
} from "@tanstack/react-table";
import type { TableSize } from "@/refresh-components/table/TableSizeContext";
import type { TableSize } from "@opal/components/table/TableSizeContext";
import type { TableVariant } from "@opal/components/table/TableElement";
import type { IconFunctionComponent } from "@opal/types";
import type { SortDirection } from "@/refresh-components/table/TableHead";
import type { SortDirection } from "@opal/components/table/TableHead";
// ---------------------------------------------------------------------------
// Column width (mirrors useColumnWidths types)
@@ -129,26 +130,16 @@ export interface DataTableDraggableConfig {
) => void | Promise<void>;
}
export interface DataTableFooterSelection {
mode: "selection";
/** Whether the table supports selecting multiple rows. @default true */
multiSelect?: boolean;
/** When true, shows a "View" button that filters the table to only selected rows. @default false */
showView?: boolean;
/** Handler for the "Clear" button. When omitted, the default clearSelection is used. */
/** Footer configuration. Mode is derived from `selectionBehavior` automatically. */
export interface DataTableFooterConfig {
/** Handler for the "Clear" button (multi-select only). When omitted, the default clearSelection is used. */
onClear?: () => void;
}
export interface DataTableFooterSummary {
mode: "summary";
/** Optional extra element rendered after the summary text (e.g. a download icon). */
/** Unit label for count pagination, e.g. "users", "documents" (multi-select only). */
units?: string;
/** Optional extra element rendered after the summary text, e.g. a download icon (summary mode only). */
leftExtra?: ReactNode;
}
export type DataTableFooterConfig =
| DataTableFooterSelection
| DataTableFooterSummary;
export interface DataTableProps<TData> {
/** Row data array. */
data: TData[];
@@ -166,8 +157,10 @@ export interface DataTableProps<TData> {
draggable?: DataTableDraggableConfig;
/** Footer configuration. */
footer?: DataTableFooterConfig;
/** Table size variant. @default "regular" */
/** Table size variant. @default "lg" */
size?: TableSize;
/** Visual row variant. @default "cards" */
variant?: TableVariant;
/** Called whenever the set of selected row IDs changes. Receives IDs produced by `getRowId`. */
onSelectionChange?: (selectedIds: string[]) => void;
/** Called when a row is clicked (replaces the default selection toggle). */

View File

@@ -0,0 +1,162 @@
import type { Meta, StoryObj } from "@storybook/react";
import { Hoverable } from "@opal/core";
// ---------------------------------------------------------------------------
// Meta
// ---------------------------------------------------------------------------
const meta: Meta = {
title: "Core/Hoverable",
tags: ["autodocs"],
parameters: {
layout: "centered",
},
};
export default meta;
// ---------------------------------------------------------------------------
// Stories
// ---------------------------------------------------------------------------
/** Group mode — hovering the root reveals hidden items. */
export const GroupMode: StoryObj = {
render: () => (
<Hoverable.Root group="demo">
<div
style={{
display: "flex",
alignItems: "center",
gap: "0.75rem",
padding: "1rem",
border: "1px solid var(--border-02)",
borderRadius: "0.5rem",
minWidth: 260,
}}
>
<span style={{ color: "var(--text-01)" }}>Hover this card</span>
<Hoverable.Item group="demo" variant="opacity-on-hover">
<span style={{ color: "var(--text-03)" }}> Revealed</span>
</Hoverable.Item>
</div>
</Hoverable.Root>
),
};
/** Local mode — hovering the item itself reveals it (no Root needed). */
export const LocalMode: StoryObj = {
render: () => (
<div
style={{
display: "flex",
alignItems: "center",
gap: "0.75rem",
padding: "1rem",
}}
>
<span style={{ color: "var(--text-01)" }}>Hover the icon </span>
<Hoverable.Item variant="opacity-on-hover">
<span style={{ fontSize: "1.25rem" }}>🗑</span>
</Hoverable.Item>
</div>
),
};
/** Multiple independent groups on the same page. */
export const MultipleGroups: StoryObj = {
render: () => (
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
{(["alpha", "beta"] as const).map((group) => (
<Hoverable.Root key={group} group={group}>
<div
style={{
display: "flex",
alignItems: "center",
gap: "0.75rem",
padding: "1rem",
border: "1px solid var(--border-02)",
borderRadius: "0.5rem",
}}
>
<span style={{ color: "var(--text-01)" }}>Group: {group}</span>
<Hoverable.Item group={group} variant="opacity-on-hover">
<span style={{ color: "var(--text-03)" }}> Revealed</span>
</Hoverable.Item>
</div>
</Hoverable.Root>
))}
</div>
),
};
/** Multiple items revealed by a single root. */
export const MultipleItems: StoryObj = {
render: () => (
<Hoverable.Root group="multi">
<div
style={{
display: "flex",
alignItems: "center",
gap: "0.75rem",
padding: "1rem",
border: "1px solid var(--border-02)",
borderRadius: "0.5rem",
}}
>
<span style={{ color: "var(--text-01)" }}>Hover to reveal all</span>
<Hoverable.Item group="multi" variant="opacity-on-hover">
<span>Edit</span>
</Hoverable.Item>
<Hoverable.Item group="multi" variant="opacity-on-hover">
<span>Delete</span>
</Hoverable.Item>
<Hoverable.Item group="multi" variant="opacity-on-hover">
<span>Share</span>
</Hoverable.Item>
</div>
</Hoverable.Root>
),
};
/** Nested groups — inner and outer hover independently. */
export const NestedGroups: StoryObj = {
render: () => (
<Hoverable.Root group="outer">
<div
style={{
padding: "1rem",
border: "1px solid var(--border-02)",
borderRadius: "0.5rem",
display: "flex",
flexDirection: "column",
gap: "0.75rem",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
<span style={{ color: "var(--text-01)" }}>Outer card</span>
<Hoverable.Item group="outer" variant="opacity-on-hover">
<span style={{ color: "var(--text-03)" }}>Outer action</span>
</Hoverable.Item>
</div>
<Hoverable.Root group="inner">
<div
style={{
display: "flex",
alignItems: "center",
gap: "0.75rem",
padding: "0.75rem",
border: "1px solid var(--border-03)",
borderRadius: "0.375rem",
}}
>
<span style={{ color: "var(--text-02)" }}>Inner card</span>
<Hoverable.Item group="inner" variant="opacity-on-hover">
<span style={{ color: "var(--text-03)" }}>Inner action</span>
</Hoverable.Item>
</div>
</Hoverable.Root>
</div>
</Hoverable.Root>
),
};

View File

@@ -1,127 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react";
import { Hoverable } from "@opal/core";
import SvgX from "@opal/icons/x";
// ---------------------------------------------------------------------------
// Shared styles
// ---------------------------------------------------------------------------
const cardStyle: React.CSSProperties = {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "1rem",
padding: "0.75rem 1rem",
borderRadius: "0.5rem",
border: "1px solid var(--border-02)",
background: "var(--background-neutral-01)",
minWidth: 220,
};
const labelStyle: React.CSSProperties = {
fontSize: "0.875rem",
fontWeight: 500,
};
// ---------------------------------------------------------------------------
// Meta
// ---------------------------------------------------------------------------
const meta: Meta = {
title: "Core/Hoverable",
tags: ["autodocs"],
parameters: {
layout: "centered",
},
};
export default meta;
// ---------------------------------------------------------------------------
// Stories
// ---------------------------------------------------------------------------
/**
* Local hover mode -- no `group` prop on the Item.
* The icon only appears when you hover directly over the Item element itself.
*/
export const LocalHover: StoryObj = {
render: () => (
<div style={cardStyle}>
<span style={labelStyle}>Hover this card area</span>
<Hoverable.Item variant="opacity-on-hover">
<SvgX width={16} height={16} />
</Hoverable.Item>
</div>
),
};
/**
* Group hover mode -- hovering anywhere inside the Root reveals the Item.
*/
export const GroupHover: StoryObj = {
render: () => (
<Hoverable.Root group="card">
<div style={cardStyle}>
<span style={labelStyle}>Hover anywhere on this card</span>
<Hoverable.Item group="card" variant="opacity-on-hover">
<SvgX width={16} height={16} />
</Hoverable.Item>
</div>
</Hoverable.Root>
),
};
/**
* Nested groups demonstrating isolation.
*
* - Hovering the outer card reveals only the outer icon.
* - Hovering the inner card reveals only the inner icon.
*/
export const NestedGroups: StoryObj = {
render: () => (
<Hoverable.Root group="outer">
<div
style={{
...cardStyle,
flexDirection: "column",
alignItems: "stretch",
gap: "0.75rem",
padding: "1rem",
minWidth: 300,
}}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<span style={labelStyle}>Outer card</span>
<Hoverable.Item group="outer" variant="opacity-on-hover">
<SvgX width={16} height={16} />
</Hoverable.Item>
</div>
<Hoverable.Root group="inner">
<div
style={{
...cardStyle,
background: "var(--background-neutral-02)",
}}
>
<span style={labelStyle}>Inner card</span>
<Hoverable.Item group="inner" variant="opacity-on-hover">
<SvgX width={16} height={16} />
</Hoverable.Item>
</div>
</Hoverable.Root>
</div>
</Hoverable.Root>
),
};

View File

@@ -15,13 +15,21 @@
initial-value: transparent;
}
/* Shared timing tokens — used by .interactive and other surfaces (e.g. table rows) */
:root {
--interactive-duration: 150ms;
--interactive-easing: ease-in-out;
}
/* Base interactive surface */
.interactive {
@apply cursor-pointer select-none;
transition:
background-color 150ms ease-in-out,
--interactive-foreground 150ms ease-in-out,
--interactive-foreground-icon 150ms ease-in-out;
background-color var(--interactive-duration) var(--interactive-easing),
--interactive-foreground var(--interactive-duration)
var(--interactive-easing),
--interactive-foreground-icon var(--interactive-duration)
var(--interactive-easing);
}
.interactive[data-disabled] {
@apply cursor-not-allowed;

View File

@@ -2,7 +2,14 @@
"extends": "../../tsconfig.json",
"compilerOptions": {
"paths": {
"@opal/*": ["./src/*"]
"@opal/*": ["./src/*"],
// TODO (@raunakab): Remove this once the table component migration is
// complete. The table internals still import app-layer modules (e.g.
// @/refresh-components/texts/Text, @/refresh-components/Popover) via the
// @/ alias. Without this entry the IDE cannot resolve those paths since
// opal's tsconfig only defines @opal/*. Once all @/ deps are replaced
// with opal-internal equivalents, this line should be deleted.
"@/*": ["../../src/*"]
}
},
"include": ["src/**/*"],

View File

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

View File

@@ -1,143 +0,0 @@
/* ---------------------------------------------------------------------------
* Table primitives — data-attribute driven styling
* Follows the same pattern as card.css / line-item.css.
* ------------------------------------------------------------------------- */
/* ---- TableCell ---- */
.tbl-cell[data-size="regular"] {
@apply px-1 py-0.5;
}
.tbl-cell[data-size="small"] {
@apply pl-0.5 pr-1.5 py-1.5;
}
.tbl-cell-inner[data-size="regular"] {
@apply h-10 px-1;
}
.tbl-cell-inner[data-size="small"] {
@apply h-6 px-0.5;
}
/* ---- TableHead ---- */
.table-head {
@apply relative sticky top-0 z-20;
background: var(--table-header-bg, transparent);
}
.table-head[data-size="regular"] {
@apply px-2 py-1;
}
.table-head[data-size="small"] {
@apply p-1.5;
}
.table-head[data-bottom-border] {
@apply border-b border-transparent hover:border-border-03;
}
/* Inner text wrapper */
.table-head[data-size="regular"] .table-head-label {
@apply py-2 px-0.5;
}
.table-head[data-size="small"] .table-head-label {
@apply py-1;
}
/* Sort button wrapper */
.table-head[data-size="regular"] .table-head-sort {
@apply py-1.5;
}
/* ---- TableRow ---- */
.tbl-row > td {
@apply bg-background-tint-00;
}
.tbl-row[data-variant="table"] > td {
@apply border-b border-border-01;
}
.tbl-row[data-variant="list"] > td {
@apply bg-clip-padding border-y-[4px] border-x-0 border-transparent;
}
.tbl-row[data-variant="list"] > td:first-child {
@apply rounded-l-12;
}
.tbl-row[data-variant="list"] > td:last-child {
@apply rounded-r-12;
}
/* When a drag handle is present the second-to-last td gets the rounding */
.tbl-row[data-variant="list"][data-drag-handle] > td:nth-last-child(2) {
@apply rounded-r-12;
}
.tbl-row[data-variant="list"][data-drag-handle] > td:last-child {
border-radius: 0;
}
/* ---- Row states (list variant) ---- */
.tbl-row[data-variant="list"]:hover > td {
@apply bg-background-tint-02;
}
.tbl-row[data-variant="list"]:focus-visible > td,
.tbl-row[data-variant="list"]:has(:focus-visible) > td {
@apply bg-action-link-01;
}
.tbl-row[data-disabled] {
@apply pointer-events-none;
}
/* ---- Row states (table variant) ---- */
.tbl-row[data-variant="table"]:hover > td {
@apply bg-background-tint-02;
}
.tbl-row[data-variant="table"]:focus-visible > td,
.tbl-row[data-variant="table"]:has(:focus-visible) > td {
@apply bg-action-link-01;
}
/* Suppress default focus ring on rows — the row bg is the indicator */
.tbl-row:focus,
.tbl-row:focus-visible {
outline: none;
}
/* ---- QualifierContainer ---- */
.tbl-qualifier[data-type="head"] {
@apply w-px whitespace-nowrap py-1 sticky top-0 z-20;
background: var(--table-header-bg, transparent);
}
.tbl-qualifier[data-type="head"][data-size="small"] {
@apply py-0.5;
}
.tbl-qualifier[data-type="cell"] {
@apply w-px whitespace-nowrap py-1 pl-1;
}
.tbl-qualifier[data-type="cell"][data-size="small"] {
@apply py-0.5 pl-0.5;
}
/* ---- ActionsContainer ---- */
.tbl-actions {
@apply sticky right-0 w-px whitespace-nowrap;
}
.tbl-actions[data-type="head"] {
@apply z-30 sticky top-0;
background: var(--table-header-bg, transparent);
}
/* ---- Footer ---- */
.table-footer[data-size="regular"] {
@apply min-h-[2.75rem];
}
.table-footer[data-size="small"] {
@apply min-h-[2.25rem];
}

View File

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

View File

@@ -16,7 +16,6 @@
@import "css/sizes.css";
@import "css/square-button.css";
@import "css/switch.css";
@import "css/table.css";
@import "css/z-index.css";
/* KH Teka Font */

View File

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

View File

@@ -9,6 +9,8 @@ export interface SeparatorProps
noPadding?: boolean;
/** Custom horizontal padding in rem. Overrides the default padding. */
paddingXRem?: number;
/** Custom vertical padding in rem. Overrides the default padding. */
paddingYRem?: number;
}
/**
@@ -37,7 +39,7 @@ const Separator = React.forwardRef(
{
noPadding,
paddingXRem,
paddingYRem,
className,
orientation = "horizontal",
decorative = true,
@@ -56,6 +58,12 @@ const Separator = React.forwardRef(
paddingRight: `${paddingXRem}rem`,
}
: {}),
...(paddingYRem != null
? {
paddingTop: `${paddingYRem}rem`,
paddingBottom: `${paddingYRem}rem`,
}
: {}),
}}
className={cn(
isHorizontal ? "w-full" : "h-full",

View File

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

View File

@@ -1,462 +0,0 @@
# DataTable
Config-driven table built on [TanStack Table](https://tanstack.com/table). Handles column sizing (weight-based proportional distribution), drag-and-drop row reordering, pagination, row selection, column visibility, and sorting out of the box.
## Quick Start
```tsx
import DataTable from "@/refresh-components/table/DataTable";
import { createTableColumns } from "@/refresh-components/table/columns";
interface Person {
name: string;
email: string;
role: string;
}
// Define columns at module scope (stable reference, no re-renders)
const tc = createTableColumns<Person>();
const columns = [
tc.qualifier(),
tc.column("name", { header: "Name", weight: 30, minWidth: 120 }),
tc.column("email", { header: "Email", weight: 40, minWidth: 150 }),
tc.column("role", { header: "Role", weight: 30, minWidth: 80 }),
tc.actions(),
];
function PeopleTable({ data }: { data: Person[] }) {
return (
<DataTable
data={data}
columns={columns}
pageSize={10}
footer={{ mode: "selection" }}
/>
);
}
```
## Column Builder API
`createTableColumns<TData>()` returns a typed builder with four methods. Each returns an `OnyxColumnDef<TData>` that you pass to the `columns` prop.
### `tc.qualifier(config?)`
Leading column for avatars, icons, images, or checkboxes.
| Option | Type | Default | Description |
| ------------------- | ----------------------------------------------------------------- | ---------- | --------------------------------------------- |
| `content` | `"simple" \| "icon" \| "image" \| "avatar-icon" \| "avatar-user"` | `"simple"` | Body row content type |
| `headerContentType` | same as `content` | `"simple"` | Header row content type |
| `getInitials` | `(row: TData) => string` | - | Extract initials (for `"avatar-user"`) |
| `getIcon` | `(row: TData) => IconFunctionComponent` | - | Extract icon (for `"icon"` / `"avatar-icon"`) |
| `getImageSrc` | `(row: TData) => string` | - | Extract image src (for `"image"`) |
| `selectable` | `boolean` | `true` | Show selection checkboxes |
| `header` | `boolean` | `true` | Render qualifier content in the header |
Width is fixed: 56px at `"regular"` size, 40px at `"small"`.
```ts
tc.qualifier({
content: "avatar-user",
getInitials: (row) => row.initials,
});
```
### `tc.column(accessor, config)`
Data column with sorting, resizing, and hiding. The `accessor` is a type-safe deep key into `TData`.
| Option | Type | Default | Description |
| ---------------- | -------------------------------------------------- | ----------------------- | -------------------------------- |
| `header` | `string` | **required** | Column header label |
| `cell` | `(value: TValue, row: TData) => ReactNode` | renders value as string | Custom cell renderer |
| `enableSorting` | `boolean` | `true` | Allow sorting |
| `enableResizing` | `boolean` | `true` | Allow column resize |
| `enableHiding` | `boolean` | `true` | Allow hiding via actions popover |
| `icon` | `(sorted: SortDirection) => IconFunctionComponent` | - | Override the sort indicator icon |
| `weight` | `number` | `20` | Proportional width weight |
| `minWidth` | `number` | `50` | Minimum width in pixels |
```ts
tc.column("email", {
header: "Email",
weight: 28,
minWidth: 150,
cell: (value) => <Content sizePreset="main-ui" variant="body" title={value} prominence="muted" />,
})
```
### `tc.displayColumn(config)`
Non-accessor column for custom content (e.g. computed values, action buttons per row).
| Option | Type | Default | Description |
| -------------- | --------------------------- | ------------ | -------------------------------------- |
| `id` | `string` | **required** | Unique column ID |
| `header` | `string` | - | Optional header label |
| `cell` | `(row: TData) => ReactNode` | **required** | Cell renderer |
| `width` | `ColumnWidth` | **required** | `{ weight, minWidth? }` or `{ fixed }` |
| `enableHiding` | `boolean` | `true` | Allow hiding |
```ts
tc.displayColumn({
id: "fullName",
header: "Full Name",
cell: (row) => `${row.firstName} ${row.lastName}`,
width: { weight: 25, minWidth: 100 },
});
```
### `tc.actions(config?)`
Fixed-width column rendered at the trailing edge. Houses column visibility and sorting popovers in the header.
| Option | Type | Default | Description |
| ---------------------- | --------------------------- | ------- | ------------------------------------------ |
| `showColumnVisibility` | `boolean` | `true` | Show the column visibility popover |
| `showSorting` | `boolean` | `true` | Show the sorting popover |
| `sortingFooterText` | `string` | - | Footer text inside the sorting popover |
| `cell` | `(row: TData) => ReactNode` | - | Row-level cell renderer for action buttons |
Width is fixed: 88px at `"regular"`, 20px at `"small"`.
```ts
tc.actions({
sortingFooterText: "Everyone will see agents in this order.",
});
```
Row-level actions — the `cell` callback receives the row data and renders content in each body row. Clicks inside the cell automatically call `stopPropagation`, so they won't trigger row selection.
```tsx
tc.actions({
cell: (row) => (
<div className="flex gap-x-1">
<IconButton icon={SvgPencil} onClick={() => openEdit(row.id)} />
<IconButton icon={SvgTrash} onClick={() => confirmDelete(row.id)} />
</div>
),
});
```
## DataTable Props
`DataTableProps<TData>`:
| Prop | Type | Default | Description |
| ------------------------- | --------------------------------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| `data` | `TData[]` | **required** | Row data |
| `columns` | `OnyxColumnDef<TData>[]` | **required** | Columns from `createTableColumns()` |
| `pageSize` | `number` | `10` (with footer) or `data.length` (without) | Rows per page. `Infinity` disables pagination |
| `initialSorting` | `SortingState` | `[]` | TanStack sorting state |
| `initialColumnVisibility` | `VisibilityState` | `{}` | Map of column ID to `false` to hide initially |
| `draggable` | `DataTableDraggableConfig<TData>` | - | Enable drag-and-drop (see below) |
| `footer` | `DataTableFooterConfig` | - | Footer mode (see below) |
| `size` | `"regular" \| "small"` | `"regular"` | Table density variant |
| `onRowClick` | `(row: TData) => void` | toggles selection | Called on row click, replaces default selection toggle |
| `height` | `number \| string` | - | Max height for scrollable body (header stays pinned). `300` or `"50vh"` |
| `headerBackground` | `string` | - | CSS color for the sticky header (prevents content showing through) |
| `searchTerm` | `string` | - | Search term for client-side global text filtering (case-insensitive match across all accessor columns) |
| `serverSide` | `ServerSideConfig` | - | Enable server-side mode for manual pagination, sorting, and filtering ([see below](#server-side-mode)) |
## Footer Config
The `footer` prop accepts a discriminated union on `mode`.
### Selection mode
For tables with selectable rows. Shows a selection message + count pagination.
```ts
footer={{
mode: "selection",
multiSelect: true, // default true
onView: () => { ... }, // optional "View" button
onClear: () => { ... }, // optional "Clear" button (falls back to default clearSelection)
}}
```
### Summary mode
For read-only tables. Shows "Showing X~Y of Z" + list pagination.
```ts
footer={{ mode: "summary" }}
```
## Draggable Config
Enable drag-and-drop row reordering. DnD is automatically disabled when column sorting is active.
```ts
<DataTable
data={items}
columns={columns}
draggable={{
getRowId: (row) => row.id,
onReorder: (ids, changedOrders) => {
// ids: new ordered array of all row IDs
// changedOrders: { [id]: newIndex } for rows that moved
setItems(ids.map((id) => items.find((r) => r.id === id)!));
},
}}
/>
```
| Option | Type | Description |
| ----------- | --------------------------------------------------------------------------------- | ---------------------------------------- |
| `getRowId` | `(row: TData) => string` | Extract a unique string ID from each row |
| `onReorder` | `(ids: string[], changedOrders: Record<string, number>) => void \| Promise<void>` | Called after a successful reorder |
## Server-Side Mode
Pass the `serverSide` prop to switch from client-side to server-side pagination, sorting, and filtering. In this mode `data` should contain **only the current page slice** — TanStack operates with `manualPagination`, `manualSorting`, and `manualFiltering` enabled. Drag-and-drop is automatically disabled.
### `ServerSideConfig`
| Prop | Type | Description |
| -------------------- | ----------------------------------------------- | ----------------------------------------------------------------------------------- |
| `totalItems` | `number` | Total row count from the server, used to compute page count |
| `isLoading` | `boolean` | Shows a loading overlay (opacity + pointer-events-none) while data is being fetched |
| `onSortingChange` | `(sorting: SortingState) => void` | Fired when the user clicks a column header |
| `onPaginationChange` | `(pageIndex: number, pageSize: number) => void` | Fired on page navigation and on automatic resets from sort/search changes |
| `onSearchTermChange` | `(searchTerm: string) => void` | Fired when the `searchTerm` prop changes |
### Callback contract
The callbacks fire in a predictable order:
- **Sort change** — `onSortingChange` fires first, then the page resets to 0 and `onPaginationChange(0, pageSize)` fires.
- **Page navigation** — only `onPaginationChange` fires.
- **Search change** — `onSearchTermChange` fires, and the page resets to 0. `onPaginationChange` only fires if the page was actually on a non-zero page. When already on page 0, `searchTerm` drives the re-fetch independently (e.g. via your SWR key) — no `onPaginationChange` is needed.
Your data-fetching layer should include `searchTerm` in its fetch dependencies (e.g. SWR key) so that search changes trigger re-fetches regardless of pagination state.
### Full example
```tsx
import { useState } from "react";
import useSWR from "swr";
import type { SortingState } from "@tanstack/react-table";
import DataTable from "@/refresh-components/table/DataTable";
import { createTableColumns } from "@/refresh-components/table/columns";
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
interface User {
id: string;
name: string;
email: string;
}
const tc = createTableColumns<User>();
const columns = [
tc.qualifier(),
tc.column("name", { header: "Name", weight: 40, minWidth: 120 }),
tc.column("email", { header: "Email", weight: 60, minWidth: 150 }),
tc.actions(),
];
function UsersTable() {
const [searchTerm, setSearchTerm] = useState("");
const [sorting, setSorting] = useState<SortingState>([]);
const [pageIndex, setPageIndex] = useState(0);
const [pageSize, setPageSize] = useState(10);
const { data: response, isLoading } = useSWR(
["/api/users", sorting, pageIndex, pageSize, searchTerm],
([url, sorting, pageIndex, pageSize, searchTerm]) =>
fetch(
`${url}?` +
new URLSearchParams({
page: String(pageIndex),
size: String(pageSize),
search: searchTerm,
...(sorting[0] && {
sortBy: sorting[0].id,
sortDir: sorting[0].desc ? "desc" : "asc",
}),
})
).then((r) => r.json())
);
return (
<div className="space-y-4">
<InputTypeIn
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search users..."
/>
<DataTable
data={response?.items ?? []}
columns={columns}
getRowId={(row) => row.id}
searchTerm={searchTerm}
pageSize={pageSize}
footer={{ mode: "summary" }}
serverSide={{
totalItems: response?.total ?? 0,
isLoading,
onSortingChange: setSorting,
onPaginationChange: (idx, size) => {
setPageIndex(idx);
setPageSize(size);
},
onSearchTermChange: () => {
// search state is already managed above via searchTerm prop;
// this callback is useful for analytics or debouncing
},
}}
/>
</div>
);
}
```
## Sizing
The `size` prop (`"regular"` or `"small"`) affects:
- Qualifier column width (56px vs 40px)
- Actions column width (88px vs 20px)
- Footer text styles and pagination size
- All child components via `TableSizeContext`
Column widths can be responsive to size using a function:
```ts
// In types.ts, width accepts:
width: ColumnWidth | ((size: TableSize) => ColumnWidth);
// Example (this is what qualifier/actions use internally):
width: (size) => (size === "small" ? { fixed: 40 } : { fixed: 56 });
```
### Width system
Data columns use **weight-based proportional distribution**. A column with `weight: 40` gets twice the space of one with `weight: 20`. When the container is narrower than the sum of `minWidth` values, columns clamp to their minimums.
Fixed columns (`{ fixed: N }`) take exactly N pixels and don't participate in proportional distribution.
Resizing uses **splitter semantics**: dragging a column border grows that column and shrinks its neighbor by the same amount, keeping total width constant.
## Advanced Examples
### Scrollable table with pinned header
```tsx
<DataTable
data={allRows}
columns={columns}
height={300}
headerBackground="var(--background-tint-00)"
/>
```
### Hidden columns on load
```tsx
<DataTable
data={data}
columns={columns}
initialColumnVisibility={{ department: false, joinDate: false }}
footer={{ mode: "selection" }}
/>
```
### Icon-based data column
```tsx
const STATUS_ICONS = {
active: SvgCheckCircle,
pending: SvgClock,
inactive: SvgAlertCircle,
} as const;
tc.column("status", {
header: "Status",
weight: 14,
minWidth: 80,
cell: (value) => (
<Content
sizePreset="main-ui"
variant="body"
icon={STATUS_ICONS[value]}
title={value.charAt(0).toUpperCase() + value.slice(1)}
/>
),
});
```
### Non-selectable qualifier with icons
```ts
tc.qualifier({
content: "icon",
getIcon: (row) => row.icon,
selectable: false,
header: false,
});
```
### Small variant in a bordered container
```tsx
<div className="border border-border-01 rounded-lg overflow-hidden">
<DataTable
data={data}
columns={columns}
size="small"
pageSize={10}
footer={{ mode: "selection" }}
/>
</div>
```
### Server-side pagination
Minimal wiring for server-side mode — manage sorting/pagination state externally and pass the current page slice as `data`.
```tsx
<DataTable
data={currentPageRows}
columns={columns}
getRowId={(row) => row.id}
searchTerm={searchTerm}
pageSize={pageSize}
footer={{ mode: "summary" }}
serverSide={{
totalItems: totalCount,
isLoading,
onSortingChange: setSorting,
onPaginationChange: (idx, size) => {
setPageIndex(idx);
setPageSize(size);
},
onSearchTermChange: (term) => setSearchTerm(term),
}}
/>
```
### Custom row click handler
```tsx
<DataTable
data={data}
columns={columns}
onRowClick={(row) => router.push(`/users/${row.id}`)}
/>
```
## Source Files
| File | Purpose |
| --------------------------- | -------------------------------- |
| `DataTable.tsx` | Main component |
| `columns.ts` | `createTableColumns` builder |
| `types.ts` | All TypeScript interfaces |
| `hooks/useDataTable.ts` | TanStack table wrapper hook |
| `hooks/useColumnWidths.ts` | Weight-based width system |
| `hooks/useDraggableRows.ts` | DnD hook (`@dnd-kit`) |
| `Footer.tsx` | Selection / Summary footer modes |
| `TableSizeContext.tsx` | Size context provider |

View File

@@ -1,281 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import Table from "./Table";
import TableHeader from "./TableHeader";
import TableBody from "./TableBody";
import TableRow from "./TableRow";
import TableHead from "./TableHead";
import TableCell from "./TableCell";
import { TableSizeProvider } from "./TableSizeContext";
import Text from "@/refresh-components/texts/Text";
// ---------------------------------------------------------------------------
// Meta
// ---------------------------------------------------------------------------
const meta: Meta<typeof Table> = {
title: "refresh-components/table/Table",
component: Table,
tags: ["autodocs"],
decorators: [
(Story) => (
<TooltipPrimitive.Provider>
<TableSizeProvider size="regular">
<div style={{ maxWidth: 800, padding: 16 }}>
<Story />
</div>
</TableSizeProvider>
</TooltipPrimitive.Provider>
),
],
parameters: {
layout: "padded",
},
};
export default meta;
type Story = StoryObj<typeof Table>;
// ---------------------------------------------------------------------------
// Sample data
// ---------------------------------------------------------------------------
const connectors = [
{
name: "Google Drive",
type: "Cloud Storage",
docs: 1_240,
status: "Active",
},
{ name: "Confluence", type: "Wiki", docs: 856, status: "Active" },
{ name: "Slack", type: "Messaging", docs: 3_102, status: "Syncing" },
{ name: "Notion", type: "Wiki", docs: 412, status: "Paused" },
{ name: "GitHub", type: "Code", docs: 2_890, status: "Active" },
];
// ---------------------------------------------------------------------------
// Stories
// ---------------------------------------------------------------------------
/** All primitive table components composed together (Table, TableHeader, TableBody, TableRow, TableHead, TableCell). */
export const ComposedPrimitives: Story = {
render: () => (
<Table>
<TableHeader>
<TableRow>
<TableHead width={200}>Connector</TableHead>
<TableHead width={150}>Type</TableHead>
<TableHead width={120} alignment="right">
Documents
</TableHead>
<TableHead width={120}>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{connectors.map((c) => (
<TableRow key={c.name}>
<TableCell>
<Text mainUiBody>{c.name}</Text>
</TableCell>
<TableCell>
<Text mainUiMuted text03>
{c.type}
</Text>
</TableCell>
<TableCell>
<Text mainUiMono text03>
{c.docs.toLocaleString()}
</Text>
</TableCell>
<TableCell>
<Text mainUiBody>{c.status}</Text>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
),
};
/** Table rows with the "table" variant (bottom border instead of rounded corners). */
export const TableVariantRows: Story = {
render: () => (
<Table>
<TableHeader>
<TableRow>
<TableHead width={200}>Connector</TableHead>
<TableHead width={150}>Type</TableHead>
<TableHead width={120}>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{connectors.map((c) => (
<TableRow key={c.name} variant="table">
<TableCell>
<Text mainUiBody>{c.name}</Text>
</TableCell>
<TableCell>
<Text mainUiMuted text03>
{c.type}
</Text>
</TableCell>
<TableCell>
<Text mainUiBody>{c.status}</Text>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
),
};
/** Row with selected state highlighted. */
export const SelectedRows: Story = {
render: () => (
<Table>
<TableHeader>
<TableRow>
<TableHead width={200}>Connector</TableHead>
<TableHead width={150}>Type</TableHead>
<TableHead width={120}>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{connectors.map((c, i) => (
<TableRow key={c.name} selected={i === 1 || i === 3}>
<TableCell>
<Text mainUiBody>{c.name}</Text>
</TableCell>
<TableCell>
<Text mainUiMuted text03>
{c.type}
</Text>
</TableCell>
<TableCell>
<Text mainUiBody>{c.status}</Text>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
),
};
/** Sortable table headers with sort indicators. */
export const SortableHeaders: Story = {
render: () => (
<Table>
<TableHeader>
<TableRow>
<TableHead width={200} sorted="ascending" onSort={() => {}}>
Connector
</TableHead>
<TableHead width={150} sorted="none" onSort={() => {}}>
Type
</TableHead>
<TableHead width={120} sorted="descending" onSort={() => {}}>
Documents
</TableHead>
<TableHead width={120}>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{connectors.map((c) => (
<TableRow key={c.name}>
<TableCell>
<Text mainUiBody>{c.name}</Text>
</TableCell>
<TableCell>
<Text mainUiMuted text03>
{c.type}
</Text>
</TableCell>
<TableCell>
<Text mainUiMono text03>
{c.docs.toLocaleString()}
</Text>
</TableCell>
<TableCell>
<Text mainUiBody>{c.status}</Text>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
),
};
/** Small size variant with denser spacing. */
export const SmallSize: Story = {
decorators: [
(Story) => (
<TooltipPrimitive.Provider>
<TableSizeProvider size="small">
<div style={{ maxWidth: 800, padding: 16 }}>
<Story />
</div>
</TableSizeProvider>
</TooltipPrimitive.Provider>
),
],
render: () => (
<Table>
<TableHeader>
<TableRow>
<TableHead width={200}>Connector</TableHead>
<TableHead width={150}>Type</TableHead>
<TableHead width={120}>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{connectors.map((c) => (
<TableRow key={c.name}>
<TableCell>
<Text secondaryBody>{c.name}</Text>
</TableCell>
<TableCell>
<Text secondaryBody text03>
{c.type}
</Text>
</TableCell>
<TableCell>
<Text secondaryBody>{c.status}</Text>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
),
};
/** Disabled rows styling. */
export const DisabledRows: Story = {
render: () => (
<Table>
<TableHeader>
<TableRow>
<TableHead width={200}>Connector</TableHead>
<TableHead width={150}>Type</TableHead>
<TableHead width={120}>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{connectors.map((c, i) => (
<TableRow key={c.name} disabled={i === 2 || i === 4}>
<TableCell>
<Text mainUiBody>{c.name}</Text>
</TableCell>
<TableCell>
<Text mainUiMuted text03>
{c.type}
</Text>
</TableCell>
<TableCell>
<Text mainUiBody>{c.status}</Text>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
),
};

View File

@@ -1,26 +0,0 @@
import { cn } from "@/lib/utils";
import type { WithoutStyles } from "@/types";
interface TableProps
extends WithoutStyles<React.TableHTMLAttributes<HTMLTableElement>> {
ref?: React.Ref<HTMLTableElement>;
/** Explicit pixel width for the table (e.g. from `table.getTotalSize()`).
* When provided the table uses exactly this width instead of stretching
* to fill its container, which prevents `table-layout: fixed` from
* redistributing extra space across columns on resize. */
width?: number;
}
function Table({ ref, width, ...props }: TableProps) {
return (
<table
ref={ref}
className={cn("border-separate border-spacing-0", "min-w-full")}
style={{ tableLayout: "fixed", width: width ?? undefined }}
{...props}
/>
);
}
export default Table;
export type { TableProps };

View File

@@ -0,0 +1,42 @@
"use client";
import type { UserGroup } from "@/lib/types";
import { SvgChevronRight, SvgUserManage, SvgUsers } from "@opal/icons";
import { ContentAction } from "@opal/layouts";
import { Section } from "@/layouts/general-layouts";
import Card from "@/refresh-components/cards/Card";
import IconButton from "@/refresh-components/buttons/IconButton";
import Text from "@/refresh-components/texts/Text";
import { buildGroupDescription, formatMemberCount } from "./utils";
interface GroupCardProps {
group: UserGroup;
}
function GroupCard({ group }: GroupCardProps) {
const isBasic = group.name === "Basic";
const isAdmin = group.name === "Admin";
return (
<Card padding={0.5}>
<ContentAction
icon={isAdmin ? SvgUserManage : SvgUsers}
title={group.name}
description={buildGroupDescription(group)}
sizePreset="main-content"
variant="section"
tag={isBasic ? { title: "Default" } : undefined}
rightChildren={
<Section flexDirection="row" alignItems="center">
<Text mainUiBody text03>
{formatMemberCount(group.users.length)}
</Text>
<IconButton icon={SvgChevronRight} tertiary tooltip="View group" />
</Section>
}
/>
</Card>
);
}
export default GroupCard;

View File

@@ -0,0 +1,54 @@
"use client";
import { useMemo } from "react";
import type { UserGroup } from "@/lib/types";
import Separator from "@/refresh-components/Separator";
import GroupCard from "./GroupCard";
import { isBuiltInGroup } from "./utils";
import { Section } from "@/layouts/general-layouts";
import { IllustrationContent } from "@opal/layouts";
import SvgNoResult from "@opal/illustrations/no-result";
interface GroupsListProps {
groups: UserGroup[];
searchQuery: string;
}
function GroupsList({ groups, searchQuery }: GroupsListProps) {
const filtered = useMemo(() => {
if (!searchQuery.trim()) return groups;
const q = searchQuery.toLowerCase();
return groups.filter((g) => g.name.toLowerCase().includes(q));
}, [groups, searchQuery]);
if (filtered.length === 0) {
return (
<IllustrationContent
illustration={SvgNoResult}
title="No groups found"
description={`No groups matching "${searchQuery}"`}
/>
);
}
const builtInGroups = filtered.filter(isBuiltInGroup);
const customGroups = filtered.filter((g) => !isBuiltInGroup(g));
return (
<Section flexDirection="column" gap={0.5}>
{builtInGroups.map((group) => (
<GroupCard key={group.id} group={group} />
))}
{builtInGroups.length > 0 && customGroups.length > 0 && (
<Separator paddingYRem={0.5} />
)}
{customGroups.map((group) => (
<GroupCard key={group.id} group={group} />
))}
</Section>
);
}
export default GroupsList;

View File

@@ -0,0 +1,66 @@
"use client";
import { useState } from "react";
import useSWR from "swr";
import { SvgPlusCircle, SvgUsers } from "@opal/icons";
import { Button } from "@opal/components";
import * as SettingsLayouts from "@/layouts/settings-layouts";
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
import SimpleLoader from "@/refresh-components/loaders/SimpleLoader";
import { errorHandlingFetcher } from "@/lib/fetcher";
import type { UserGroup } from "@/lib/types";
import { USER_GROUP_URL } from "./svc";
import GroupsList from "./GroupsList";
import { Section } from "@/layouts/general-layouts";
import { IllustrationContent } from "@opal/layouts";
import SvgNoResult from "@opal/illustrations/no-result";
function GroupsPage() {
const [searchQuery, setSearchQuery] = useState("");
const {
data: groups,
error,
isLoading,
} = useSWR<UserGroup[]>(USER_GROUP_URL, errorHandlingFetcher);
return (
<SettingsLayouts.Root>
{/* This is the sticky header for the groups page. It is used to display
* the groups page title and search input when scrolling down.
*/}
<div className="sticky top-0 z-settings-header bg-background-tint-01">
<SettingsLayouts.Header icon={SvgUsers} title="Groups" separator />
<Section flexDirection="row" padding={1}>
<InputTypeIn
placeholder="Search groups..."
variant="internal"
value={searchQuery}
leftSearchIcon
onChange={(e) => setSearchQuery(e.target.value)}
/>
<Button icon={SvgPlusCircle}>New Group</Button>
</Section>
</div>
<SettingsLayouts.Body>
{isLoading && <SimpleLoader />}
{error && (
<IllustrationContent
illustration={SvgNoResult}
title="Failed to load groups."
description="Please check the console for more details."
/>
)}
{!isLoading && !error && groups && (
<GroupsList groups={groups} searchQuery={searchQuery} />
)}
</SettingsLayouts.Body>
</SettingsLayouts.Root>
);
}
export default GroupsPage;

View File

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

View File

@@ -0,0 +1,57 @@
import type { UserGroup } from "@/lib/types";
/** Groups that are created by the system and cannot be deleted. */
export const BUILT_IN_GROUP_NAMES = ["Basic", "Admin"] as const;
export function isBuiltInGroup(group: UserGroup): boolean {
return (BUILT_IN_GROUP_NAMES as readonly string[]).includes(group.name);
}
/** Human-readable description for built-in groups. */
const BUILT_IN_DESCRIPTIONS: Record<string, string> = {
Basic: "Default group for all users with basic permissions.",
Admin: "Built-in admin group with full access to manage all permissions.",
};
/**
* Build the description line(s) shown beneath the group name.
*
* Built-in groups use a fixed label.
* Custom groups list resource counts ("3 connectors · 2 document sets · 2 agents")
* or fall back to "No private connectors / document sets / agents".
*/
export function buildGroupDescription(group: UserGroup): string {
if (isBuiltInGroup(group)) {
return BUILT_IN_DESCRIPTIONS[group.name] ?? "";
}
const parts: string[] = [];
if (group.cc_pairs.length > 0) {
parts.push(
`${group.cc_pairs.length} connector${
group.cc_pairs.length !== 1 ? "s" : ""
}`
);
}
if (group.document_sets.length > 0) {
parts.push(
`${group.document_sets.length} document set${
group.document_sets.length !== 1 ? "s" : ""
}`
);
}
if (group.personas.length > 0) {
parts.push(
`${group.personas.length} agent${group.personas.length !== 1 ? "s" : ""}`
);
}
return parts.length > 0
? parts.join(" · ")
: "No private connectors / document sets / agents";
}
/** Format the member count badge, e.g. "306 Members" or "1 Member". */
export function formatMemberCount(count: number): string {
return `${count} ${count === 1 ? "Member" : "Members"}`;
}

View File

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

View File

@@ -8,11 +8,11 @@ import { useScimToken } from "@/hooks/useScimToken";
import { usePaidEnterpriseFeaturesEnabled } from "@/components/settings/usePaidEnterpriseFeaturesEnabled";
import useUserCounts from "@/hooks/useUserCounts";
import { UserStatus } from "@/lib/types";
import type { StatusFilter } from "./UsersPage/interfaces";
import type { StatusFilter } from "./interfaces";
import UsersSummary from "./UsersPage/UsersSummary";
import UsersTable from "./UsersPage/UsersTable";
import InviteUsersModal from "./UsersPage/InviteUsersModal";
import UsersSummary from "./UsersSummary";
import UsersTable from "./UsersTable";
import InviteUsersModal from "./InviteUsersModal";
// ---------------------------------------------------------------------------
// Users page content