chore: merge latest main into model info discovery branch

This commit is contained in:
Moe Khalil 2026-09-17 00:19:16 +00:00
commit d0ed8145b0
21 changed files with 1234 additions and 172 deletions

View file

@ -244,6 +244,7 @@ telemetry = True
max_tokens: int = DEFAULT_MAX_TOKENS # OpenAI Defaults
drop_params = drop_params_env_flag(os.environ, verbose_logger)
modify_params = bool(os.getenv("LITELLM_MODIFY_PARAMS", False))
bedrock_neutralize_orphaned_tool_blocks: bool = True
use_chat_completions_url_for_anthropic_messages: bool = bool(
os.getenv("LITELLM_USE_CHAT_COMPLETIONS_URL_FOR_ANTHROPIC_MESSAGES", False)
) # When True, routes OpenAI /v1/messages requests to chat/completions instead of the Responses API

View file

@ -53,6 +53,7 @@ from litellm.types.llms.openai import (
AllMessageValues,
ChatCompletionAnnotation,
ChatCompletionAssistantMessage,
ChatCompletionAssistantToolCall,
ChatCompletionRedactedThinkingBlock,
ChatCompletionResponseMessage,
ChatCompletionSystemMessage,
@ -205,6 +206,84 @@ class AmazonConverseConfig(BaseConfig):
return messages_copy
@staticmethod
def _has_orphaned_tool_blocks(messages: list[AllMessageValues]) -> bool:
return any(
(m.get("role") == "assistant" and m.get("tool_calls")) or m.get("role") in ("tool", "function")
for m in messages
)
@staticmethod
def _neutralize_orphaned_tool_blocks(
messages: list[AllMessageValues], optional_params: dict
) -> list[AllMessageValues]:
if optional_params.get("tools") or not AmazonConverseConfig._has_orphaned_tool_blocks(messages):
return messages
from litellm.litellm_core_utils.prompt_templates.common_utils import (
convert_content_list_to_str,
)
def _tool_call_text(tool_call: ChatCompletionAssistantToolCall) -> str:
function = tool_call.get("function") or {}
name = function.get("name") or "unknown_tool"
arguments = function.get("arguments") or ""
call_id = tool_call.get("id")
label = f"tool call {call_id}" if call_id else "tool call"
return f"[{label}: {name}({arguments})]"
def _result_text(message: AllMessageValues) -> str:
rendered = convert_content_list_to_str(message).strip()
return rendered or "<non-text tool result omitted>"
guardrail_active: Final = "guardrailConfig" in optional_params
def _rewrite(message: AllMessageValues) -> AllMessageValues:
role = message.get("role")
tool_calls = message.get("tool_calls")
if role == "assistant" and tool_calls:
base_text: Final = convert_content_list_to_str(message)
call_texts: Final = tuple(_tool_call_text(call) for call in tool_calls)
text: Final = "\n".join(part for part in (base_text, *call_texts) if part)
return ChatCompletionAssistantMessage(role="assistant", content=text)
if role in ("tool", "function"):
tool_call_id = message.get("tool_call_id")
name = message.get("name")
label = f"tool result for {tool_call_id or name or 'unknown'}"
result_text: Final = f"[{label}: {_result_text(message)}]"
# Tool results are externally controlled, so guard them wherever they
# land in history; _convert_consecutive_user_messages_to_guarded_text
# only covers the trailing user turn.
content: Final = [{"type": "guarded_text", "text": result_text}] if guardrail_active else result_text
return ChatCompletionUserMessage(role="user", content=content)
return message
verbose_logger.warning(
"litellm.bedrock: request has tool blocks in message history but no "
"`tools=` param; neutralizing orphaned tool blocks to text so Bedrock "
"accepts the request without a toolConfig. Non-text tool-result "
"payloads are dropped. Pass `tools=` to preserve structured tool calling."
)
return [_rewrite(message) for message in messages]
@staticmethod
def _handle_orphaned_tool_blocks(messages: list[AllMessageValues], optional_params: dict) -> list[AllMessageValues]:
if litellm.bedrock_neutralize_orphaned_tool_blocks:
return AmazonConverseConfig._neutralize_orphaned_tool_blocks(messages, optional_params)
if "tools" in optional_params or not has_tool_call_blocks(messages):
return messages
if litellm.modify_params:
optional_params["tools"] = add_dummy_tool(custom_llm_provider="bedrock_converse")
return messages
raise litellm.utils.UnsupportedParamsError(
message="Bedrock doesn't support tool calling without `tools=` param specified. Pass `tools=` param OR set `litellm.modify_params = True` // `litellm_settings::modify_params: True` to add dummy tool to the request.",
model="",
llm_provider="bedrock",
)
@classmethod
def get_config(cls):
return {
@ -1609,20 +1688,6 @@ class AmazonConverseConfig(BaseConfig):
drop_params: bool = False,
litellm_params: Mapping[str, object] | None = None,
) -> CommonRequestObject:
## VALIDATE REQUEST
"""
Bedrock doesn't support tool calling without `tools=` param specified.
"""
if "tools" not in optional_params and messages is not None and has_tool_call_blocks(messages):
if litellm.modify_params:
optional_params["tools"] = add_dummy_tool(custom_llm_provider="bedrock_converse")
else:
raise litellm.UnsupportedParamsError(
message="Bedrock doesn't support tool calling without `tools=` param specified. Pass `tools=` param OR set `litellm.modify_params = True` // `litellm_settings::modify_params: True` to add dummy tool to the request.",
model="",
llm_provider="bedrock",
)
# Drop thinking param if thinking is enabled but thinking_blocks are missing
# This prevents the error: "Expected thinking or redacted_thinking, but found tool_use"
#
@ -1735,7 +1800,9 @@ class AmazonConverseConfig(BaseConfig):
messages, system_content_blocks = self._transform_system_message(messages, model=model)
# Convert last user message to guarded_text if guardrailConfig is present
messages = self._convert_consecutive_user_messages_to_guarded_text(messages, optional_params)
messages = self._convert_consecutive_user_messages_to_guarded_text(
self._handle_orphaned_tool_blocks(messages, optional_params), optional_params
)
## TRANSFORMATION ##
_data: Final[CommonRequestObject] = self._transform_request_helper(
@ -1796,7 +1863,9 @@ class AmazonConverseConfig(BaseConfig):
messages, system_content_blocks = self._transform_system_message(messages, model=model)
# Convert last user message to guarded_text if guardrailConfig is present
messages = self._convert_consecutive_user_messages_to_guarded_text(messages, optional_params)
messages = self._convert_consecutive_user_messages_to_guarded_text(
self._handle_orphaned_tool_blocks(messages, optional_params), optional_params
)
_data: Final[CommonRequestObject] = self._transform_request_helper(
model=model,

View file

@ -261,7 +261,6 @@ def test_aaparallel_function_call_with_anthropic_thinking(model):
from litellm.types.utils import ChatCompletionMessageToolCall, Function, Message
_PARALLEL_TOOL_HISTORY_MESSAGES = [
{
"role": "user",
@ -293,20 +292,11 @@ _PARALLEL_TOOL_HISTORY_MESSAGES = [
@pytest.mark.parametrize(
"model, messages, expect_unsupported_params_error",
"model, messages",
[
# Bedrock Converse still requires modify_params to inject the dummy tool.
(
"us.anthropic.claude-sonnet-4-5-20250929-v1:0",
_PARALLEL_TOOL_HISTORY_MESSAGES,
True,
),
# Anthropic Messages API: dummy tool is injected without modify_params.
(
"claude-haiku-4-5-20251001",
_PARALLEL_TOOL_HISTORY_MESSAGES,
False,
),
# Anthropic Messages API: a dummy tool is injected without modify_params,
# so tool history with no tools= completes instead of raising.
("claude-haiku-4-5-20251001", _PARALLEL_TOOL_HISTORY_MESSAGES),
(
"us.anthropic.claude-sonnet-4-5-20250929-v1:0",
[
@ -315,7 +305,6 @@ _PARALLEL_TOOL_HISTORY_MESSAGES = [
"content": "What's the weather like in San Francisco, Tokyo, and Paris? - give me 3 responses",
}
],
False,
),
(
"claude-haiku-4-5-20251001",
@ -325,48 +314,34 @@ _PARALLEL_TOOL_HISTORY_MESSAGES = [
"content": "What's the weather like in San Francisco, Tokyo, and Paris? - give me 3 responses",
}
],
False,
),
],
)
def test_parallel_function_call_anthropic_error_msg(
model, messages, expect_unsupported_params_error
):
def test_parallel_function_call_anthropic_error_msg(model, messages):
"""
Tool history without an explicit ``tools`` param:
Tool history without an explicit ``tools`` param must complete, not raise.
- Bedrock **Converse** still raises ``UnsupportedParamsError`` unless
``litellm.modify_params`` is enabled (dummy tool is only added there).
- **Anthropic** (and Bedrock Invoke via ``AnthropicConfig.transform_request``)
always get a dummy tool so CLIs work with ``modify_params`` left off.
Reference Issue: https://github.com/BerriAI/litellm/issues/5747, https://github.com/BerriAI/litellm/issues/5388
Anthropic (and Bedrock Invoke via ``AnthropicConfig.transform_request``)
inject a dummy tool so CLIs work with ``modify_params`` left off. Bedrock
Converse's no-raise behavior is covered offline in
``tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py``
(see #24158, #27138), which needs no live credentials.
"""
# Ensure modify_params is False so Bedrock Converse path still raises.
# Force modify_params off as a clean baseline: it exercises the Anthropic
# dummy-tool path, which injects regardless of modify_params
# (other tests in this file set it to True and don't reset it)
original_modify_params = litellm.modify_params
litellm.modify_params = False
try:
litellm.set_verbose = True
if expect_unsupported_params_error:
with pytest.raises(litellm.UnsupportedParamsError) as e:
litellm.completion(
model=model,
messages=messages,
temperature=0.2,
seed=22,
drop_params=True,
)
else:
second_response = litellm.completion(
model=model,
messages=messages,
temperature=0.2,
seed=22,
drop_params=True,
) # get a new response from the model where it can see the function response
print("second response\n", second_response)
second_response = litellm.completion(
model=model,
messages=messages,
temperature=0.2,
seed=22,
drop_params=True,
) # get a new response from the model where it can see the function response
print("second response\n", second_response)
except litellm.InternalServerError as e:
print(e)
except litellm.RateLimitError as e:

View file

@ -6534,6 +6534,446 @@ async def test_grounding_source_and_query_rendered_as_text():
assert {"text": "What is the capital of Japan?"} in user_content
def _orphaned_tool_history_messages():
return [
{"role": "user", "content": "What's the weather in Paris?"},
{
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "call_abc",
"type": "function",
"function": {
"name": "get_weather",
"arguments": '{"city": "Paris"}',
},
}
],
},
{
"role": "tool",
"tool_call_id": "call_abc",
"content": "Sunny, 25C",
},
{"role": "user", "content": "Summarize our conversation so far."},
]
def test_neutralize_orphaned_tool_blocks_rewrites_when_no_tools():
"""No tools= but history has tool blocks: assistant tool_calls and the tool
result must be rewritten to text, with the structured tool fields gone and
tool_call_id preserved, so Bedrock accepts the request without a toolConfig
(#24158, #27138)."""
messages = _orphaned_tool_history_messages()
result = AmazonConverseConfig._neutralize_orphaned_tool_blocks(
messages, optional_params={}
)
serialized = json.dumps(result)
assert "tool_calls" not in serialized
assert not any(m.get("role") in ("tool", "function") for m in result)
assert "get_weather" in serialized
# The arguments string contains quotes; after json.dumps the literal
# '{"city": "Paris"}' is escaped, so assert on quote-free tokens that survive.
assert "city" in serialized and "Paris" in serialized
assert "Sunny, 25C" in serialized
assert "[tool call call_abc: get_weather(" in result[1]["content"]
assert "[tool result for call_abc: Sunny, 25C]" in result[2]["content"]
@pytest.mark.parametrize("tools_value", [[], None])
def test_neutralize_orphaned_tool_blocks_rewrites_when_tools_empty(tools_value):
"""tools=[] and tools=None are 'no usable tools'; the gate must be on
truthiness, not key presence, or these slip through and still emit
structured tool blocks with no toolConfig."""
messages = _orphaned_tool_history_messages()
result = AmazonConverseConfig._neutralize_orphaned_tool_blocks(
messages, optional_params={"tools": tools_value}
)
serialized = json.dumps(result)
assert "tool_calls" not in serialized
assert "get_weather" in serialized
def test_neutralize_orphaned_tool_blocks_rewrites_tool_result_only_history():
"""A role:"tool"-only history (no assistant tool_calls) must also be
neutralized; has_tool_call_blocks misses this, but the factory still emits a
lone toolResult with no toolConfig."""
messages = [
{"role": "user", "content": "hi"},
{"role": "tool", "tool_call_id": "call_xyz", "content": "lookup result"},
]
result = AmazonConverseConfig._neutralize_orphaned_tool_blocks(
messages, optional_params={}
)
assert not any(m.get("role") in ("tool", "function") for m in result)
serialized = json.dumps(result)
assert "lookup result" in serialized
assert "call_xyz" in serialized
def test_neutralize_orphaned_tool_blocks_non_text_result_marked_not_empty():
"""Non-text tool-result payloads (image/file) collapse to an explicit
marker, never an empty string (Bedrock rejects empty text blocks) and never
a silent drop."""
messages = [
{"role": "user", "content": "hi"},
{
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "c1",
"type": "function",
"function": {"name": "render", "arguments": "{}"},
}
],
},
{
"role": "tool",
"tool_call_id": "c1",
"content": [
{
"type": "image_url",
"image_url": {"url": "data:image/png;base64,AAAA"},
}
],
},
]
result = AmazonConverseConfig._neutralize_orphaned_tool_blocks(
messages, optional_params={}
)
rewritten = next(
m for m in result if m.get("role") == "user" and m is not messages[0]
)
text = rewritten["content"]
assert text.strip() # never empty
assert "non-text tool result omitted" in text
def test_neutralize_orphaned_tool_blocks_noop_when_tools_present():
"""When a non-empty tools= is provided, tool blocks are legitimate and must
be left untouched (returns the same object, no rewriting)."""
messages = _orphaned_tool_history_messages()
result = AmazonConverseConfig._neutralize_orphaned_tool_blocks(
messages,
optional_params={"tools": [{"type": "function", "function": {"name": "x"}}]},
)
assert result is messages
def test_neutralize_orphaned_tool_blocks_noop_when_no_tool_history():
"""Plain conversation with no tool blocks is returned unchanged."""
messages = [{"role": "user", "content": "hi"}]
result = AmazonConverseConfig._neutralize_orphaned_tool_blocks(
messages, optional_params={}
)
assert result is messages
def test_neutralize_orphaned_tool_blocks_logs_warning(caplog):
"""Neutralization must surface at WARNING level so a developer who forgot
tools= sees it instead of a silent degrade."""
messages = _orphaned_tool_history_messages()
with caplog.at_level("WARNING"):
AmazonConverseConfig._neutralize_orphaned_tool_blocks(
messages, optional_params={}
)
assert any(
"neutralizing orphaned tool blocks" in record.getMessage()
for record in caplog.records
)
def _assert_no_structured_tool_blocks(result):
"""A valid Bedrock body for a neutralized request has no tool config AND no
structured tool blocks in messages. Checking only toolConfig is insufficient:
deleting the raise without rewriting still leaves toolUse/toolResult, the
exact shape Bedrock rejects."""
assert "toolConfig" not in result
serialized = json.dumps(result)
assert "toolUse" not in serialized
assert "toolResult" not in serialized
def test_transform_request_no_tools_with_tool_history_succeeds_24158(monkeypatch):
"""#24158: a compaction-style call (tool blocks in history, no tools=) must
not raise and must send no toolConfig or structured tool blocks, on
default settings."""
monkeypatch.setattr(litellm, "modify_params", False)
config = AmazonConverseConfig()
result = config.transform_request(
model="us.anthropic.claude-opus-4-5-20251101-v1:0",
messages=_orphaned_tool_history_messages(),
optional_params={},
litellm_params={},
headers={},
)
_assert_no_structured_tool_blocks(result)
serialized = json.dumps(result)
assert "get_weather" in serialized
assert "Sunny, 25C" in serialized
def test_transform_request_tool_unsupported_model_no_toolconfig_27138(monkeypatch):
"""#27138: a tool-incapable model with tool blocks in history and no tools=
must not get a toolConfig/toolUse/toolResult injected (which Bedrock would
400 on), even with modify_params on."""
monkeypatch.setattr(litellm, "modify_params", True)
config = AmazonConverseConfig()
result = config.transform_request(
model="meta.llama3-2-3b-instruct-v1:0",
messages=_orphaned_tool_history_messages(),
optional_params={},
litellm_params={},
headers={},
)
_assert_no_structured_tool_blocks(result)
@pytest.mark.parametrize("tools_value", [[], None])
def test_transform_request_empty_tools_with_tool_history(monkeypatch, tools_value):
"""tools=[] / tools=None must be neutralized like no tools at all; a
key-presence gate would skip them and emit toolUse/toolResult with no
toolConfig."""
monkeypatch.setattr(litellm, "modify_params", False)
config = AmazonConverseConfig()
result = config.transform_request(
model="us.anthropic.claude-opus-4-5-20251101-v1:0",
messages=_orphaned_tool_history_messages(),
optional_params={"tools": tools_value},
litellm_params={},
headers={},
)
_assert_no_structured_tool_blocks(result)
def test_transform_request_tool_result_only_history(monkeypatch):
"""A role:"tool"-only history (no assistant tool_calls) currently emits a
lone toolResult with no toolConfig; it must be neutralized."""
monkeypatch.setattr(litellm, "modify_params", False)
config = AmazonConverseConfig()
result = config.transform_request(
model="us.anthropic.claude-opus-4-5-20251101-v1:0",
messages=[
{"role": "user", "content": "hi"},
{"role": "tool", "tool_call_id": "call_xyz", "content": "lookup result"},
],
optional_params={},
litellm_params={},
headers={},
)
_assert_no_structured_tool_blocks(result)
assert "lookup result" in json.dumps(result)
def test_transform_request_neutralized_tool_output_is_guarded(monkeypatch):
"""With guardrailConfig present, a neutralized tool result that becomes the
trailing user turn must be emitted as guardContent, not plain text, so
untrusted tool output does not bypass the guardrail (neutralize must run
before guarded-text conversion)."""
monkeypatch.setattr(litellm, "modify_params", False)
config = AmazonConverseConfig()
result = config.transform_request(
model="us.anthropic.claude-opus-4-5-20251101-v1:0",
messages=[
{"role": "user", "content": "look it up"},
{
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "c1",
"type": "function",
"function": {"name": "lookup", "arguments": "{}"},
}
],
},
{"role": "tool", "tool_call_id": "c1", "content": "secret tool output"},
],
optional_params={
"guardrailConfig": {"guardrailIdentifier": "gid", "guardrailVersion": "1"}
},
litellm_params={},
headers={},
)
_assert_no_structured_tool_blocks(result)
serialized = json.dumps(result)
assert "guardContent" in serialized
assert "secret tool output" in serialized
def test_transform_request_neutralized_tool_output_guarded_mid_history(monkeypatch):
"""Regression: a neutralized tool result that is NOT the trailing turn (an
assistant reply and a later user turn follow it) must still be guardContent.
_convert_consecutive_user_messages_to_guarded_text only covers the trailing
user turn, so neutralize itself must guard untrusted tool output regardless
of position, else an attacker controlling the tool response bypasses the
guardrail (bot review)."""
monkeypatch.setattr(litellm, "modify_params", False)
config = AmazonConverseConfig()
result = config.transform_request(
model="us.anthropic.claude-opus-4-5-20251101-v1:0",
messages=[
{"role": "user", "content": "look it up"},
{
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "c1",
"type": "function",
"function": {"name": "lookup", "arguments": "{}"},
}
],
},
{"role": "tool", "tool_call_id": "c1", "content": "IGNORE_PRIOR malware"},
{"role": "assistant", "content": "Here is the summary."},
{"role": "user", "content": "thanks"},
],
optional_params={
"guardrailConfig": {"guardrailIdentifier": "gid", "guardrailVersion": "1"}
},
litellm_params={},
headers={},
)
_assert_no_structured_tool_blocks(result)
blocks = [block for message in result["messages"] for block in message["content"]]
guarded_texts = [
block["guardContent"]["text"]["text"] for block in blocks if "guardContent" in block
]
plain_texts = [block["text"] for block in blocks if "text" in block and "guardContent" not in block]
assert any("malware" in text for text in guarded_texts), "mid-history tool output must be guarded"
assert not any(
"malware" in text for text in plain_texts
), "mid-history tool output must not reach the model as unguarded text"
@pytest.mark.asyncio
async def test_async_transform_request_no_tools_with_tool_history(monkeypatch):
"""Async is a separate request assembler; it must neutralize identically."""
monkeypatch.setattr(litellm, "modify_params", False)
config = AmazonConverseConfig()
result = await config._async_transform_request(
model="us.anthropic.claude-opus-4-5-20251101-v1:0",
messages=_orphaned_tool_history_messages(),
optional_params={},
litellm_params={},
headers={},
)
_assert_no_structured_tool_blocks(result)
assert "get_weather" in json.dumps(result)
def test_transform_request_with_tools_still_builds_toolconfig(monkeypatch):
"""Guard: when a non-empty tools= IS provided, tool blocks are legitimate and
a toolConfig must still be produced (neutralization must not regress this)."""
monkeypatch.setattr(litellm, "modify_params", False)
config = AmazonConverseConfig()
result = config.transform_request(
model="us.anthropic.claude-opus-4-5-20251101-v1:0",
messages=_orphaned_tool_history_messages(),
optional_params={
"tools": [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get weather",
"parameters": {"type": "object", "properties": {}},
},
}
]
},
litellm_params={},
headers={},
)
assert "toolConfig" in result
def test_transform_request_flag_off_restores_raise(monkeypatch):
"""Opt-out: with bedrock_neutralize_orphaned_tool_blocks=False and
modify_params=False, the legacy UnsupportedParamsError contract is restored."""
monkeypatch.setattr(litellm, "bedrock_neutralize_orphaned_tool_blocks", False)
monkeypatch.setattr(litellm, "modify_params", False)
config = AmazonConverseConfig()
with pytest.raises(litellm.utils.UnsupportedParamsError, match="without `tools="):
config.transform_request(
model="us.anthropic.claude-opus-4-5-20251101-v1:0",
messages=_orphaned_tool_history_messages(),
optional_params={},
litellm_params={},
headers={},
)
def test_transform_request_flag_off_with_modify_params_restores_dummy_tool(monkeypatch):
"""Opt-out: with the flag off and modify_params=True, the legacy dummy-tool
injection is restored (a toolConfig is produced, not neutralized text)."""
monkeypatch.setattr(litellm, "bedrock_neutralize_orphaned_tool_blocks", False)
monkeypatch.setattr(litellm, "modify_params", True)
config = AmazonConverseConfig()
result = config.transform_request(
model="us.anthropic.claude-opus-4-5-20251101-v1:0",
messages=_orphaned_tool_history_messages(),
optional_params={},
litellm_params={},
headers={},
)
assert "toolConfig" in result
assert "dummy_tool" in json.dumps(result)
def test_transform_request_flag_on_is_default(monkeypatch):
"""Default-on: without touching the flag, neutralization is the behavior."""
monkeypatch.setattr(litellm, "modify_params", False)
config = AmazonConverseConfig()
assert litellm.bedrock_neutralize_orphaned_tool_blocks is True
result = config.transform_request(
model="us.anthropic.claude-opus-4-5-20251101-v1:0",
messages=_orphaned_tool_history_messages(),
optional_params={},
litellm_params={},
headers={},
)
_assert_no_structured_tool_blocks(result)
def _agentic_messages_with_ttl(ttl_target: str):
"""A tool-loop conversation with `ttl: 1h` cache_control at `ttl_target`:
'user', 'tool_call' (per-tool-call, on the assistant's tool call), or

View file

@ -972,11 +972,6 @@
"count": 2
}
},
"src/app/(dashboard)/projects/_components/ProjectKeysSection.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/projects/_components/ProjectModals/ProjectBaseForm.tsx": {
"react-hooks/set-state-in-effect": {
"count": 2

View file

@ -1,10 +1,23 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, render, screen, waitFor } from "@testing-library/react";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { NuqsTestingAdapter, type UrlUpdateEvent } from "nuqs/adapters/testing";
import React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type OrganizationsTableComponent from "./OrganizationsTable";
import type OrganizationInfoViewComponent from "@/components/organization/organization_view";
import type { OrganizationListFilters } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
const useOrganizationsSpy = vi.hoisted(() => vi.fn<(filters?: OrganizationListFilters) => void>());
vi.mock("@/app/(dashboard)/hooks/organizations/useOrganizations", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/app/(dashboard)/hooks/organizations/useOrganizations")>();
return {
...actual,
useOrganizations: (filters?: OrganizationListFilters) => {
useOrganizationsSpy(filters);
return actual.useOrganizations(filters);
},
};
});
vi.mock("@/components/vector_store_management/VectorStoreSelector", () => ({
__esModule: true,
@ -79,10 +92,13 @@ const renderPanel = ({ premiumUser = true, searchParams = "" }: RenderPanelOptio
const expectQueryString = (queryString: string) =>
waitFor(() => expect(onUrlUpdate).toHaveBeenLastCalledWith(expect.objectContaining({ queryString })));
const lastSearchParams = () => onUrlUpdate.mock.calls.at(-1)?.[0].searchParams;
beforeEach(() => {
capturedTableProps = null;
mockOrgInfoView.mockClear();
onUrlUpdate.mockClear();
useOrganizationsSpy.mockClear();
});
describe("OrganizationsPanel", () => {
@ -123,9 +139,7 @@ describe("OrganizationsPanel - org detail deep link (?org=)", () => {
it("opens the org detail directly from a ?org= deep link", () => {
renderPanel({ searchParams: "?org=org-from-url" });
expect(mockOrgInfoView).toHaveBeenLastCalledWith(
expect.objectContaining({ organizationId: "org-from-url", editOrg: false }),
);
expect(mockOrgInfoView).toHaveBeenLastCalledWith(expect.objectContaining({ organizationId: "org-from-url" }));
expect(screen.queryByTestId("organizations-table")).not.toBeInTheDocument();
});
@ -139,23 +153,24 @@ describe("OrganizationsPanel - org detail deep link (?org=)", () => {
expect(screen.getByTestId("organizations-table")).toBeInTheDocument();
});
it("the edit action opens the detail in edit mode with ?org= set", async () => {
it("the edit action pushes ?org= with ?org_tab=settings in one history entry", async () => {
renderPanel();
act(() => capturedTableProps?.onEditClick("org-edit"));
await expectQueryString("?org=org-edit");
expect(mockOrgInfoView).toHaveBeenLastCalledWith(
expect.objectContaining({ organizationId: "org-edit", editOrg: true }),
await expectQueryString("?org=org-edit&org_tab=settings");
expect(onUrlUpdate).toHaveBeenCalledTimes(1);
expect(onUrlUpdate).toHaveBeenLastCalledWith(
expect.objectContaining({ options: expect.objectContaining({ history: "push" }) }),
);
expect(mockOrgInfoView).toHaveBeenLastCalledWith(expect.objectContaining({ organizationId: "org-edit" }));
});
it("a plain row click after leaving an edit view via browser history does not reopen in edit mode", async () => {
it("a plain row click after leaving an edit view via browser history opens the detail without the settings tab", async () => {
const { navigate } = renderPanel();
act(() => capturedTableProps?.onEditClick("org-edit"));
await expectQueryString("?org=org-edit");
expect(mockOrgInfoView).toHaveBeenLastCalledWith(expect.objectContaining({ editOrg: true }));
await expectQueryString("?org=org-edit&org_tab=settings");
navigate("");
expect(screen.getByTestId("organizations-table")).toBeInTheDocument();
@ -163,8 +178,90 @@ describe("OrganizationsPanel - org detail deep link (?org=)", () => {
act(() => capturedTableProps?.onOrganizationClick("org-plain"));
await expectQueryString("?org=org-plain");
expect(mockOrgInfoView).toHaveBeenLastCalledWith(
expect.objectContaining({ organizationId: "org-plain", editOrg: false }),
expect(mockOrgInfoView).toHaveBeenLastCalledWith(expect.objectContaining({ organizationId: "org-plain" }));
});
it("a row click drops a leftover ?org_tab= so the detail opens on its default tab", async () => {
renderPanel({ searchParams: "?org_tab=settings" });
act(() => capturedTableProps?.onOrganizationClick("org-plain"));
await expectQueryString("?org=org-plain");
});
it("closing the org detail keeps the list's search, filter, sort and page in the URL", async () => {
renderPanel({
searchParams:
"?org_search=Acme&filter_org_id=org-7&sort_by=spend&sort_order=asc&page=2&org=org-x&org_tab=members",
});
act(() => mockOrgInfoView.mock.calls.at(-1)?.[0].onClose());
await expectQueryString("?org_search=Acme&filter_org_id=org-7&sort_by=spend&sort_order=asc&page=2");
expect(onUrlUpdate).toHaveBeenCalledTimes(1);
expect(screen.getByPlaceholderText("Search by Organization Name")).toHaveValue("Acme");
expect(useOrganizationsSpy).toHaveBeenLastCalledWith({ org_id: "org-7", org_alias: "Acme" });
});
it("closing the org detail drops ?org_tab= together with ?org=", async () => {
renderPanel({ searchParams: "?org=org-from-url&org_tab=members" });
act(() => mockOrgInfoView.mock.calls.at(-1)?.[0].onClose());
await expectQueryString("");
expect(onUrlUpdate).toHaveBeenCalledTimes(1);
expect(onUrlUpdate).toHaveBeenLastCalledWith(
expect.objectContaining({ options: expect.objectContaining({ history: "push" }) }),
);
});
});
describe("OrganizationsPanel - list filters in the URL", () => {
it("restores the name search and org ID filter from the URL and fetches with both", () => {
renderPanel({ searchParams: "?org_search=Acme&filter_org_id=org-7" });
expect(screen.getByPlaceholderText("Search by Organization Name")).toHaveValue("Acme");
expect(screen.getByPlaceholderText("Search by Organization ID")).toHaveValue("org-7");
expect(useOrganizationsSpy).toHaveBeenLastCalledWith({ org_id: "org-7", org_alias: "Acme" });
expect(capturedTableProps?.searchActive).toBe(true);
});
it("keeps the org ID filter panel collapsed when the URL has no org ID filter", () => {
renderPanel({ searchParams: "?org_search=Acme" });
expect(screen.queryByPlaceholderText("Search by Organization ID")).not.toBeInTheDocument();
expect(useOrganizationsSpy).toHaveBeenLastCalledWith({ org_id: "", org_alias: "Acme" });
});
it("writes the name search to ?org_search= and returns the list to the first page", async () => {
renderPanel({ searchParams: "?page=3" });
fireEvent.change(screen.getByPlaceholderText("Search by Organization Name"), { target: { value: "Acme" } });
await waitFor(() => expect(lastSearchParams()?.get("org_search")).toBe("Acme"));
expect(lastSearchParams()?.has("page")).toBe(false);
expect(useOrganizationsSpy).toHaveBeenLastCalledWith({ org_id: "", org_alias: "Acme" });
});
it("writes the org ID filter to ?filter_org_id= and returns the list to the first page", async () => {
renderPanel({ searchParams: "?page=3" });
fireEvent.click(screen.getByRole("button", { name: "Filters" }));
fireEvent.change(screen.getByPlaceholderText("Search by Organization ID"), { target: { value: "org-9" } });
await waitFor(() => expect(lastSearchParams()?.get("filter_org_id")).toBe("org-9"));
expect(lastSearchParams()?.has("page")).toBe(false);
expect(useOrganizationsSpy).toHaveBeenLastCalledWith({ org_id: "org-9", org_alias: "" });
});
it("clears the search, the org ID filter and the page in one update on reset", async () => {
renderPanel({ searchParams: "?org_search=Acme&filter_org_id=org-7&page=2" });
fireEvent.click(screen.getByRole("button", { name: "Reset Filters" }));
await expectQueryString("");
expect(onUrlUpdate).toHaveBeenCalledTimes(1);
expect(useOrganizationsSpy).toHaveBeenLastCalledWith({ org_id: "", org_alias: "" });
expect(capturedTableProps?.searchActive).toBe(false);
});
});

View file

@ -2,16 +2,18 @@ import { organizationKeys, useOrganizations } from "@/app/(dashboard)/hooks/orga
import { useUserModels } from "@/app/(dashboard)/hooks/models/useModels";
import OrganizationFilters, { FilterState } from "@/app/(dashboard)/organizations/OrganizationFilters";
import { useQueryClient } from "@tanstack/react-query";
import { parseAsString, useQueryState } from "nuqs";
import { parseAsString, parseAsStringLiteral, useQueryStates } from "nuqs";
import React, { useState } from "react";
import DeleteResourceModal from "@/components/common_components/DeleteResourceModal";
import { toast } from "@/lib/toast";
import { organizationDeleteCall } from "@/components/networking";
import { OrgCreateDialog } from "@/components/organization/org-create/OrgCreateDialog";
import OrganizationInfoView from "@/components/organization/organization_view";
import { ORGANIZATION_TAB_URL_KEY, ORGANIZATION_TABS } from "@/components/organization/organizationTabs";
import { Button } from "@/components/ui/button";
import OrganizationsTable from "./OrganizationsTable";
import { organizationIdFilter, useOrganizationsTableState } from "./useOrganizationsTableState";
interface OrganizationsPanelProps {
userRole: string;
@ -19,15 +21,25 @@ interface OrganizationsPanelProps {
premiumUser: boolean;
}
const ORGANIZATION_DETAIL_STATE = {
org: parseAsString,
tab: parseAsStringLiteral(ORGANIZATION_TABS),
};
const ORGANIZATION_DETAIL_URL_KEYS = { tab: ORGANIZATION_TAB_URL_KEY };
const OrganizationsPanel: React.FC<OrganizationsPanelProps> = ({ userRole, accessToken, premiumUser }) => {
const [selectedOrgId, setSelectedOrgId] = useQueryState("org", parseAsString.withOptions({ history: "push" }));
const [editOrg, setEditOrg] = useState(false);
const [{ org: selectedOrgId }, setOrganizationDetail] = useQueryStates(ORGANIZATION_DETAIL_STATE, {
history: "push",
urlKeys: ORGANIZATION_DETAIL_URL_KEYS,
});
const tableState = useOrganizationsTableState();
const { setSearch, onColumnFiltersChange } = tableState;
const filters: FilterState = { org_id: organizationIdFilter(tableState), org_alias: tableState.search };
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [orgToDelete, setOrgToDelete] = useState<string | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [isOrgModalVisible, setIsOrgModalVisible] = useState(false);
const [showFilters, setShowFilters] = useState(false);
const [filters, setFilters] = useState<FilterState>({ org_id: "", org_alias: "" });
const [showFilters, setShowFilters] = useState(() => filters.org_id !== "");
const queryClient = useQueryClient();
const { data: organizations = [], isLoading } = useOrganizations({
@ -41,11 +53,16 @@ const OrganizationsPanel: React.FC<OrganizationsPanelProps> = ({ userRole, acces
const refetchOrganizations = () => queryClient.invalidateQueries({ queryKey: organizationKeys.lists() });
const handleFilterChange = (key: keyof FilterState, value: string) => {
setFilters((previousFilters) => ({ ...previousFilters, [key]: value }));
if (key === "org_alias") {
setSearch(value);
return;
}
onColumnFiltersChange(value ? [{ id: "org_id", value }] : []);
};
const handleFilterReset = () => {
setFilters({ org_id: "", org_alias: "" });
setSearch("");
onColumnFiltersChange([]);
};
const handleDelete = (orgId: string | null) => {
@ -108,15 +125,11 @@ const OrganizationsPanel: React.FC<OrganizationsPanelProps> = ({ userRole, acces
{selectedOrgId ? (
<OrganizationInfoView
organizationId={selectedOrgId}
onClose={() => {
void setSelectedOrgId(null);
setEditOrg(false);
}}
onClose={() => void setOrganizationDetail(null)}
accessToken={accessToken}
is_org_admin={true}
is_proxy_admin={userRole === "Admin"}
userModels={userModels}
editOrg={editOrg}
/>
) : (
<>
@ -133,14 +146,8 @@ const OrganizationsPanel: React.FC<OrganizationsPanelProps> = ({ userRole, acces
isLoading={isLoading}
userRole={userRole}
searchActive={searchActive}
onOrganizationClick={(organizationId) => {
setEditOrg(false);
void setSelectedOrgId(organizationId);
}}
onEditClick={(organizationId) => {
void setSelectedOrgId(organizationId);
setEditOrg(true);
}}
onOrganizationClick={(organizationId) => void setOrganizationDetail({ org: organizationId, tab: null })}
onEditClick={(organizationId) => void setOrganizationDetail({ org: organizationId, tab: "settings" })}
onDeleteClick={handleDelete}
/>
</>

View file

@ -1,7 +1,9 @@
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { OnUrlUpdateFunction } from "nuqs/adapters/testing";
import React from "react";
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it, vi, type Mock } from "vitest";
import { renderWithProviders, screen, waitFor, within } from "../../../../../tests/test-utils";
import { Organization } from "@/components/networking";
@ -26,6 +28,34 @@ const makeOrganization = (overrides: Partial<Organization> = {}): Organization =
...overrides,
});
const thirtyOrganizations = Array.from({ length: 30 }, (_, index) =>
makeOrganization({ organization_id: `org-${index}`, organization_alias: `Org ${index}` }),
);
const sortableOrganization = (alias: string, createdAt: string, spend: number): Organization => {
const overrides: Partial<Organization> = {
organization_id: `org-${alias.toLowerCase()}`,
organization_alias: alias,
created_at: createdAt,
spend,
};
return makeOrganization(overrides);
};
const sortableOrganizations = [
sortableOrganization("Mid", "2024-03-01T00:00:00Z", 5),
sortableOrganization("Zed", "2023-01-01T00:00:00Z", 1),
sortableOrganization("Ace", "2025-01-01T00:00:00Z", 3),
];
const bodyRowAliases = () =>
screen
.getAllByRole("row")
.slice(1)
.map((row) => ["Ace", "Mid", "Zed"].find((alias) => within(row).queryByText(alias) !== null));
const lastSearchParams = (onUrlUpdate: Mock<OnUrlUpdateFunction>) => onUrlUpdate.mock.calls.at(-1)?.[0].searchParams;
const baseProps = {
isLoading: false,
userRole: "Admin",
@ -37,7 +67,7 @@ const baseProps = {
describe("OrganizationsTable", () => {
it("renders every column header", () => {
render(<OrganizationsTable {...baseProps} organizations={[]} />);
renderWithProviders(<OrganizationsTable {...baseProps} organizations={[]} />);
for (const header of [
"Organization ID",
"Organization Name",
@ -55,7 +85,7 @@ describe("OrganizationsTable", () => {
it("opens the detail view when the organization ID cell is clicked", async () => {
const user = userEvent.setup();
const onOrganizationClick = vi.fn();
render(
renderWithProviders(
<OrganizationsTable
{...baseProps}
onOrganizationClick={onOrganizationClick}
@ -72,7 +102,7 @@ describe("OrganizationsTable", () => {
const user = userEvent.setup();
const onEditClick = vi.fn();
const onDeleteClick = vi.fn();
render(
renderWithProviders(
<OrganizationsTable
{...baseProps}
userRole="Admin"
@ -92,7 +122,7 @@ describe("OrganizationsTable", () => {
});
it("hides the row actions menu from non-admins", () => {
render(
renderWithProviders(
<OrganizationsTable
{...baseProps}
userRole="Internal User"
@ -104,7 +134,7 @@ describe("OrganizationsTable", () => {
});
it("sorts by created_at descending by default", () => {
render(
renderWithProviders(
<OrganizationsTable
{...baseProps}
organizations={[
@ -129,7 +159,7 @@ describe("OrganizationsTable", () => {
});
it("renders budget, limits, members, and models for a fully-populated organization", () => {
render(
renderWithProviders(
<OrganizationsTable
{...baseProps}
organizations={[
@ -151,7 +181,7 @@ describe("OrganizationsTable", () => {
});
it("shows Unlimited budget and All Proxy Models when unset", () => {
render(
renderWithProviders(
<OrganizationsTable
{...baseProps}
organizations={[makeOrganization({ organization_id: "org-empty", litellm_budget_table: {}, models: [] })]}
@ -166,7 +196,7 @@ describe("OrganizationsTable", () => {
});
it("renders a tpm/rpm limit of 0 as 0, never as Unlimited", () => {
render(
renderWithProviders(
<OrganizationsTable
{...baseProps}
organizations={[makeOrganization({ litellm_budget_table: { max_budget: null, tpm_limit: 0, rpm_limit: 0 } })]}
@ -180,7 +210,7 @@ describe("OrganizationsTable", () => {
});
it("renders loading skeletons instead of rows while loading", () => {
render(
renderWithProviders(
<OrganizationsTable
{...baseProps}
isLoading
@ -194,10 +224,8 @@ describe("OrganizationsTable", () => {
it("pages long lists client-side with the shared size selector and footer", async () => {
const user = userEvent.setup();
const organizations = Array.from({ length: 30 }, (_, index) =>
makeOrganization({ organization_id: `org-${index}`, organization_alias: `Org ${index}` }),
);
render(<OrganizationsTable {...baseProps} organizations={organizations} />);
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>();
renderWithProviders(<OrganizationsTable {...baseProps} organizations={thirtyOrganizations} />, { onUrlUpdate });
expect(screen.getAllByRole("row")).toHaveLength(26);
expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 1-25 of 30");
@ -207,13 +235,87 @@ describe("OrganizationsTable", () => {
expect(screen.getAllByRole("row")).toHaveLength(31);
expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 1-30 of 30");
await waitFor(() => expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get("page_size")).toBe("50"));
});
it("uses a search-aware empty state", () => {
const { rerender } = render(<OrganizationsTable {...baseProps} searchActive={false} organizations={[]} />);
const { rerender } = renderWithProviders(
<OrganizationsTable {...baseProps} searchActive={false} organizations={[]} />,
);
expect(screen.getByText("No organizations yet")).toBeInTheDocument();
rerender(<OrganizationsTable {...baseProps} searchActive={true} organizations={[]} />);
expect(screen.getByText("No matching organizations")).toBeInTheDocument();
});
});
describe("OrganizationsTable URL state", () => {
it("restores the sort column and direction from ?sort_by=&sort_order=", () => {
renderWithProviders(<OrganizationsTable {...baseProps} organizations={sortableOrganizations} />, {
searchParams: "?sort_by=spend&sort_order=desc",
});
expect(bodyRowAliases()).toEqual(["Mid", "Ace", "Zed"]);
});
it("falls back to sorting by creation date for a ?sort_by= column that cannot be sorted", () => {
renderWithProviders(<OrganizationsTable {...baseProps} organizations={sortableOrganizations} />, {
searchParams: "?sort_by=members&sort_order=asc",
});
expect(bodyRowAliases()).toEqual(["Zed", "Mid", "Ace"]);
});
it("writes the clicked sort column to the URL and returns to the first page", async () => {
const user = userEvent.setup();
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>();
renderWithProviders(<OrganizationsTable {...baseProps} organizations={thirtyOrganizations} />, {
searchParams: "?page=2",
onUrlUpdate,
});
expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 26-30 of 30");
await user.click(screen.getByTestId("sort-header-organization_alias"));
await waitFor(() => expect(lastSearchParams(onUrlUpdate)?.get("sort_by")).toBe("organization_alias"));
expect(onUrlUpdate).toHaveBeenCalledTimes(1);
expect(lastSearchParams(onUrlUpdate)?.get("sort_order")).toBe("asc");
expect(lastSearchParams(onUrlUpdate)?.has("page")).toBe(false);
expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 1-25 of 30");
expect(within(screen.getAllByRole("row")[1]).getByText("Org 0")).toBeInTheDocument();
});
it("opens the page named by ?page= and writes page changes back to the URL", async () => {
const user = userEvent.setup();
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>();
renderWithProviders(<OrganizationsTable {...baseProps} organizations={thirtyOrganizations} />, {
searchParams: "?page=2",
onUrlUpdate,
});
expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 26-30 of 30");
expect(screen.getByText("org-29")).toBeInTheDocument();
await user.click(screen.getByTestId("pagination-prev"));
await waitFor(() => expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 1-25 of 30"));
expect(lastSearchParams(onUrlUpdate)?.has("page")).toBe(false);
await user.click(screen.getByTestId("pagination-next"));
await waitFor(() => expect(lastSearchParams(onUrlUpdate)?.get("page")).toBe("2"));
});
it("keeps a deep-linked ?page= while the organization list is still loading", async () => {
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>();
const { rerender } = renderWithProviders(<OrganizationsTable {...baseProps} isLoading organizations={[]} />, {
searchParams: "?page=2",
onUrlUpdate,
});
rerender(<OrganizationsTable {...baseProps} organizations={thirtyOrganizations} />);
await waitFor(() => expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 26-30 of 30"));
expect(onUrlUpdate).not.toHaveBeenCalled();
});
});

View file

@ -1,13 +1,13 @@
"use client";
import { SortingState } from "@tanstack/react-table";
import { Building2, SearchX } from "lucide-react";
import React, { useMemo, useState } from "react";
import React, { useMemo } from "react";
import { DataTable } from "@/components/shared/DataTable";
import { Organization } from "@/components/networking";
import { getOrganizationsTableColumns } from "./OrganizationsTableColumns";
import { useOrganizationsTableState } from "./useOrganizationsTableState";
interface OrganizationsTableProps {
organizations: Organization[];
@ -19,8 +19,6 @@ interface OrganizationsTableProps {
onDeleteClick: (organizationId: string) => void;
}
const DEFAULT_SORTING: SortingState = [{ id: "created_at", desc: true }];
function EmptyState({ searchActive }: { searchActive: boolean }) {
const Icon = searchActive ? SearchX : Building2;
return (
@ -49,7 +47,7 @@ const OrganizationsTable: React.FC<OrganizationsTableProps> = ({
onEditClick,
onDeleteClick,
}) => {
const [sorting, setSorting] = useState<SortingState>(DEFAULT_SORTING);
const { sorting, onSortingChange, pagination, onPaginationChange } = useOrganizationsTableState();
const columns = useMemo(() => {
const deps = { userRole, onOrganizationClick, onEditClick, onDeleteClick };
@ -60,11 +58,13 @@ const OrganizationsTable: React.FC<OrganizationsTableProps> = ({
<DataTable
data={organizations}
paginationMode="client"
pagination={pagination}
onPaginationChange={onPaginationChange}
columns={columns}
getRowId={(organization, index) => organization.organization_id || String(index)}
sortingMode="client"
sorting={sorting}
onSortingChange={setSorting}
onSortingChange={onSortingChange}
isLoading={isLoading}
loadingMessage="Loading organizations…"
noDataMessage={<EmptyState searchActive={searchActive} />}

View file

@ -0,0 +1,19 @@
import { useUrlTableState, type UrlTableState, type UrlTableStateOptions } from "@/components/shared/DataTable";
const FILTER_COLUMNS = ["org_id"] as const;
type FilterColumn = (typeof FILTER_COLUMNS)[number];
const TABLE_STATE_OPTIONS: UrlTableStateOptions<FilterColumn> = {
sortFields: ["organization_id", "organization_alias", "created_at", "spend"],
defaultSort: { id: "created_at", desc: true },
defaultPageSize: 25,
filterColumns: FILTER_COLUMNS,
urlKeys: { search: "org_search" },
};
export const useOrganizationsTableState = (): UrlTableState => useUrlTableState(TABLE_STATE_OPTIONS);
export const organizationIdFilter = ({ columnFilters }: Pick<UrlTableState, "columnFilters">): string => {
const value = columnFilters.find((filter) => filter.id === "org_id")?.value;
return typeof value === "string" ? value : "";
};

View file

@ -1,5 +1,7 @@
import { describe, it, expect, vi } from "vitest";
import { renderWithProviders, screen } from "../../../../../tests/test-utils";
import { describe, it, expect, vi, beforeEach, type Mock } from "vitest";
import userEvent from "@testing-library/user-event";
import type { OnUrlUpdateFunction } from "nuqs/adapters/testing";
import { fireEvent, renderWithProviders, screen, waitFor } from "../../../../../tests/test-utils";
import { ProjectKeysSection } from "./ProjectKeysSection";
const mockUseKeys = vi.fn();
@ -70,3 +72,136 @@ describe("ProjectKeysSection", () => {
);
});
});
describe("ProjectKeysSection URL state (keys_ prefix)", () => {
const fortyTwoKeys = {
data: { keys: [], total_count: 42, current_page: 1, total_pages: 9 },
isLoading: false,
isError: false,
};
const lastSearchParams = (onUrlUpdate: Mock<OnUrlUpdateFunction>) => onUrlUpdate.mock.calls.at(-1)?.[0].searchParams;
beforeEach(() => {
mockUseKeys.mockReset();
});
it("should fetch the page, page size and key name filter named by the keys_ params", () => {
mockUseKeys.mockReturnValue(fortyTwoKeys);
renderWithProviders(<ProjectKeysSection projectId="proj-1" />, {
searchParams: "?page=4&keys_page=2&keys_page_size=10&keys_search=prod",
});
expect(mockUseKeys).toHaveBeenLastCalledWith(
2,
10,
expect.objectContaining({ projectID: "proj-1", selectedKeyAlias: "prod" }),
);
expect(screen.getByPlaceholderText("Filter by key name...")).toHaveValue("prod");
expect(screen.getByTestId("pagination-page")).toHaveTextContent("Page 2 of 5");
});
it("should cap an oversized ?keys_page_size= at the largest offered page size", () => {
mockUseKeys.mockReturnValue(fortyTwoKeys);
renderWithProviders(<ProjectKeysSection projectId="proj-1" />, { searchParams: "?keys_page_size=500" });
expect(mockUseKeys).toHaveBeenLastCalledWith(1, 25, expect.anything());
expect(screen.getByTestId("pagination-page")).toHaveTextContent("Page 1 of 2");
});
it("should fall back to the default page size for a ?keys_page_size= outside the offered options", () => {
mockUseKeys.mockReturnValue(fortyTwoKeys);
renderWithProviders(<ProjectKeysSection projectId="proj-1" />, { searchParams: "?keys_page_size=7" });
expect(mockUseKeys).toHaveBeenLastCalledWith(1, 5, expect.anything());
expect(screen.getByTestId("pagination-page")).toHaveTextContent("Page 1 of 9");
});
it("should drop an unsupported ?keys_page_size= when the user pages forward", async () => {
const user = userEvent.setup();
mockUseKeys.mockReturnValue(fortyTwoKeys);
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>();
renderWithProviders(<ProjectKeysSection projectId="proj-1" />, { searchParams: "?keys_page_size=7", onUrlUpdate });
await user.click(screen.getByTestId("pagination-next"));
await waitFor(() => expect(onUrlUpdate.mock.calls.at(-1)?.[0].queryString).toBe("?keys_page=2"));
expect(mockUseKeys).toHaveBeenLastCalledWith(2, 5, expect.anything());
});
it("should write the key name filter to ?keys_search= and return the keys to their first page", async () => {
mockUseKeys.mockReturnValue(fortyTwoKeys);
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>();
renderWithProviders(<ProjectKeysSection projectId="proj-1" />, {
searchParams: "?page=4&keys_page=3",
onUrlUpdate,
});
fireEvent.change(screen.getByPlaceholderText("Filter by key name..."), { target: { value: "prod" } });
await waitFor(() => expect(lastSearchParams(onUrlUpdate)?.get("keys_search")).toBe("prod"));
expect(lastSearchParams(onUrlUpdate)?.has("keys_page")).toBe(false);
expect(lastSearchParams(onUrlUpdate)?.get("page")).toBe("4");
expect(mockUseKeys).toHaveBeenLastCalledWith(1, 5, expect.objectContaining({ selectedKeyAlias: "prod" }));
});
it("should remove ?keys_search= when the key filter is cleared", async () => {
const user = userEvent.setup();
mockUseKeys.mockReturnValue(fortyTwoKeys);
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>();
renderWithProviders(<ProjectKeysSection projectId="proj-1" />, { searchParams: "?keys_search=prod", onUrlUpdate });
await user.click(screen.getByRole("button", { name: /clear key filter/i }));
await waitFor(() => expect(lastSearchParams(onUrlUpdate)?.has("keys_search")).toBe(false));
expect(mockUseKeys).toHaveBeenLastCalledWith(1, 5, expect.objectContaining({ selectedKeyAlias: null }));
});
it("should write key pages to ?keys_page= without touching the projects list's ?page=", async () => {
const user = userEvent.setup();
mockUseKeys.mockReturnValue(fortyTwoKeys);
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>();
renderWithProviders(<ProjectKeysSection projectId="proj-1" />, { searchParams: "?page=4", onUrlUpdate });
await user.click(screen.getByTestId("pagination-next"));
await waitFor(() => expect(lastSearchParams(onUrlUpdate)?.get("keys_page")).toBe("2"));
expect(lastSearchParams(onUrlUpdate)?.get("page")).toBe("4");
expect(mockUseKeys).toHaveBeenLastCalledWith(2, 5, expect.anything());
});
it("should snap a ?keys_page= past the last page back to the last page once the keys load", async () => {
mockUseKeys.mockReturnValue({ data: undefined, isLoading: true, isError: false });
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>();
const { rerender } = renderWithProviders(<ProjectKeysSection projectId="proj-1" />, {
searchParams: "?keys_page=9",
onUrlUpdate,
});
expect(mockUseKeys).toHaveBeenLastCalledWith(9, 5, expect.anything());
mockUseKeys.mockReturnValue({
data: { keys: [], total_count: 6, current_page: 9, total_pages: 2 },
isLoading: false,
isError: false,
});
rerender(<ProjectKeysSection projectId="proj-1" />);
await waitFor(() => expect(lastSearchParams(onUrlUpdate)?.get("keys_page")).toBe("2"));
expect(mockUseKeys).toHaveBeenLastCalledWith(2, 5, expect.anything());
});
it("should keep a deep-linked ?keys_page= when the key fetch fails", async () => {
mockUseKeys.mockReturnValue({ data: undefined, isLoading: true, isError: false });
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>();
const { rerender } = renderWithProviders(<ProjectKeysSection projectId="proj-1" />, {
searchParams: "?keys_page=3",
onUrlUpdate,
});
mockUseKeys.mockReturnValue({ data: undefined, isLoading: false, isError: true });
rerender(<ProjectKeysSection projectId="proj-1" />);
await new Promise((resolve) => setTimeout(resolve, 100));
expect(onUrlUpdate).not.toHaveBeenCalled();
expect(mockUseKeys).toHaveBeenLastCalledWith(3, 5, expect.anything());
});
});

View file

@ -1,30 +1,27 @@
import { useKeys } from "@/app/(dashboard)/hooks/keys/useKeys";
import { PaginationState } from "@tanstack/react-table";
import { KeyIcon, SearchIcon, X } from "lucide-react";
import { useEffect, useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group";
import { ProjectKeysTable } from "./ProjectKeysTable";
import { useProjectKeysTableState } from "./useProjectsUrlState";
interface ProjectKeysSectionProps {
projectId: string;
}
const PAGE_SIZE = 5;
export function ProjectKeysSection({ projectId }: ProjectKeysSectionProps) {
const [pagination, setPagination] = useState<PaginationState>({ pageIndex: 0, pageSize: PAGE_SIZE });
const [keyAlias, setKeyAlias] = useState<string>("");
const {
search: keyAlias,
setSearch: setKeyAlias,
pagination,
onPaginationChange: setPagination,
} = useProjectKeysTableState();
const { data, isLoading } = useKeys(pagination.pageIndex + 1, pagination.pageSize, {
const { data, isLoading, isError } = useKeys(pagination.pageIndex + 1, pagination.pageSize, {
projectID: projectId,
selectedKeyAlias: keyAlias || null,
});
useEffect(() => {
setPagination((current) => ({ ...current, pageIndex: 0 }));
}, [keyAlias]);
const keys = data?.keys ?? [];
const totalCount = data?.total_count ?? 0;
@ -60,6 +57,7 @@ export function ProjectKeysSection({ projectId }: ProjectKeysSectionProps) {
keys={keys}
totalCount={totalCount}
isLoading={isLoading}
isError={isError}
pagination={pagination}
onPaginationChange={setPagination}
/>

View file

@ -8,17 +8,17 @@ import { KeyResponse } from "@/components/key_team_helpers/key_list";
import { DataTable } from "@/components/shared/DataTable";
import { getProjectKeysTableColumns } from "./ProjectKeysTableColumns";
import { PROJECT_KEYS_PAGE_SIZE_OPTIONS } from "./useProjectsUrlState";
interface ProjectKeysTableProps {
keys: KeyResponse[];
totalCount: number;
isLoading: boolean;
isError?: boolean;
pagination: PaginationState;
onPaginationChange: OnChangeFn<PaginationState>;
}
const PAGE_SIZE_OPTIONS = [5, 10, 25];
function EmptyState() {
return (
<div className="flex flex-col items-center gap-1 py-6">
@ -35,6 +35,7 @@ export function ProjectKeysTable({
keys,
totalCount,
isLoading,
isError = false,
pagination,
onPaginationChange,
}: ProjectKeysTableProps) {
@ -49,8 +50,9 @@ export function ProjectKeysTable({
pagination={pagination}
onPaginationChange={onPaginationChange}
rowCount={totalCount}
pageSizeOptions={PAGE_SIZE_OPTIONS}
pageSizeOptions={PROJECT_KEYS_PAGE_SIZE_OPTIONS}
isLoading={isLoading}
isError={isError}
loadingMessage="Loading keys…"
noDataMessage={<EmptyState />}
size="compact"

View file

@ -190,22 +190,48 @@ describe("ProjectsPage", () => {
it("should reset to the first page when the search text changes", async () => {
const user = userEvent.setup();
const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>();
const manyProjects = Array.from({ length: 12 }, (_, i) => ({
...mockProjects[0],
project_id: `proj-${i + 1}`,
project_alias: `Project ${String(i + 1).padStart(2, "0")}`,
}));
mockUseProjects.mockReturnValue({ data: manyProjects, isLoading: false });
renderWithProviders(<ProjectsPage />);
renderWithProviders(<ProjectsPage />, { onUrlUpdate });
await user.click(screen.getByTestId("pagination-next"));
expect(screen.getByTestId("pagination-page")).toHaveTextContent("Page 2 of 2");
await waitFor(() => expect(onUrlUpdate.mock.calls.at(-1)?.[0].searchParams.get("page")).toBe("2"));
fireEvent.change(screen.getByPlaceholderText(/search projects/i), { target: { value: "Project 01" } });
await waitFor(() => {
expect(screen.getByText("Project 01")).toBeInTheDocument();
expect(screen.getByTestId("pagination-page")).toHaveTextContent("Page 1 of 1");
});
await waitFor(() => expect(onUrlUpdate.mock.calls.at(-1)?.[0].queryString).toBe("?project_search=Project+01"));
expect(onUrlUpdate).toHaveBeenCalledTimes(2);
});
it("should restore the search box and filtered list from a ?project_search= deep link", () => {
mockUseProjects.mockReturnValue({ data: mockProjects, isLoading: false });
renderWithProviders(<ProjectsPage />, { searchParams: "?project_search=Beta" });
expect(screen.getByPlaceholderText(/search projects/i)).toHaveValue("Beta");
expect(screen.getByText("Beta Project")).toBeInTheDocument();
expect(screen.queryByText("Alpha Project")).not.toBeInTheDocument();
});
it("should remove ?project_search= when the search is cleared", async () => {
const user = userEvent.setup();
const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>();
mockUseProjects.mockReturnValue({ data: mockProjects, isLoading: false });
renderWithProviders(<ProjectsPage />, { searchParams: "?project_search=Beta", onUrlUpdate });
await user.click(screen.getByRole("button", { name: /clear search/i }));
await waitFor(() => expect(onUrlUpdate).toHaveBeenLastCalledWith(expect.objectContaining({ queryString: "" })));
expect(screen.getByPlaceholderText(/search projects/i)).toHaveValue("");
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
});
it("should open the detail view directly from a ?project= deep link", () => {
@ -250,6 +276,24 @@ describe("ProjectsPage", () => {
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
});
it("should drop the project's key table state but keep the list's search and page when the detail view is closed", async () => {
const user = userEvent.setup();
const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>();
mockUseProjects.mockReturnValue({ data: mockProjects, isLoading: false });
renderWithProviders(<ProjectsPage />, {
searchParams:
"?page=2&project_search=Project&project=proj-1&keys_page=3&keys_page_size=10&keys_search=prod&keys_sort_by=spend&keys_sort_order=asc",
onUrlUpdate,
});
await user.click(screen.getByRole("button", { name: /back to projects/i }));
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalledTimes(1));
const [update] = onUrlUpdate.mock.calls[0];
expect(update.queryString).toBe("?page=2&project_search=Project");
expect(update.options.history).toBe("replace");
});
it("should resolve team alias from the teams list in the Team column", () => {
mockUseTeams.mockReturnValue({
data: [{ team_id: "team-1", team_alias: "Engineering", models: [] }],

View file

@ -9,6 +9,7 @@ import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "
import { CreateProjectModal } from "./ProjectModals/CreateProjectModal";
import { ProjectDetail } from "./ProjectDetailsPage";
import { ProjectsTable } from "./ProjectsTable";
import { useClearProjectKeysTableState, useProjectsTableState } from "./useProjectsUrlState";
export function ProjectsPage() {
const { data: projects, isLoading } = useProjects();
@ -18,8 +19,9 @@ export function ProjectsPage() {
"project",
parseAsString.withOptions({ history: "push" }),
);
const clearProjectKeysTableState = useClearProjectKeysTableState();
const { search: searchText, setSearch: setSearchText } = useProjectsTableState();
const [isCreateModalVisible, setIsCreateModalVisible] = useState(false);
const [searchText, setSearchText] = useState("");
const teamAliasMap = useMemo(() => {
const map = new Map<string, string>();
@ -44,13 +46,13 @@ export function ProjectsPage() {
});
}, [projects, searchText, teamAliasMap]);
const closeProject = () => {
void setSelectedProjectId(null, { history: "replace" });
clearProjectKeysTableState();
};
if (selectedProjectId) {
return (
<ProjectDetail
projectId={selectedProjectId}
onBack={() => void setSelectedProjectId(null, { history: "replace" })}
/>
);
return <ProjectDetail projectId={selectedProjectId} onBack={closeProject} />;
}
return (

View file

@ -83,6 +83,7 @@ describe("ProjectsTable pagination URL state", () => {
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled());
const [update] = onUrlUpdate.mock.calls[0];
expect(update.searchParams.get("page")).toBe("2");
expect(update.searchParams.has("page_size")).toBe(false);
expect(update.options.history).toBe("push");
expect(firstDataRow().getByText("Project 11")).toBeInTheDocument();
});
@ -147,6 +148,7 @@ describe("ProjectsTable pagination URL state", () => {
const lastUpdate = onUrlUpdate.mock.calls.at(-1)?.[0];
expect(lastUpdate.searchParams.get("page")).toBeNull();
expect(lastUpdate.searchParams.get("page_size")).toBe("25");
expect(lastUpdate.options.history).toBe("push");
});
it("should apply both params from a ?page=2&page_size=25 deep link so the restored view matches", () => {

View file

@ -2,13 +2,13 @@
import { SortingState } from "@tanstack/react-table";
import { FolderKanban } from "lucide-react";
import { parseAsInteger, useQueryStates } from "nuqs";
import { useMemo, useState } from "react";
import { ProjectResponse } from "@/app/(dashboard)/hooks/projects/useProjects";
import { DataTable, DataTablePagination } from "@/components/shared/DataTable";
import { getProjectsTableColumns } from "./ProjectsTableColumns";
import { PROJECTS_DEFAULT_PAGE_SIZE, useProjectsTableState } from "./useProjectsUrlState";
interface ProjectsTableProps {
projects: ProjectResponse[];
@ -19,8 +19,7 @@ interface ProjectsTableProps {
isTeamsLoading: boolean;
}
const DEFAULT_PAGE_SIZE = 10;
const PAGE_SIZE_OPTIONS = [DEFAULT_PAGE_SIZE, 25, 50];
const PAGE_SIZE_OPTIONS = [PROJECTS_DEFAULT_PAGE_SIZE, 25, 50];
function EmptyState({ isFiltered }: { isFiltered: boolean }) {
return (
@ -47,11 +46,8 @@ export function ProjectsTable({
isTeamsLoading,
}: ProjectsTableProps) {
const [sorting, setSorting] = useState<SortingState>([]);
const [{ page, page_size }, setPagination] = useQueryStates(
{ page: parseAsInteger.withDefault(1), page_size: parseAsInteger.withDefault(DEFAULT_PAGE_SIZE) },
{ history: "push" },
);
const pageSize = PAGE_SIZE_OPTIONS.includes(page_size) ? page_size : DEFAULT_PAGE_SIZE;
const { pagination, onPaginationChange } = useProjectsTableState();
const pageSize = PAGE_SIZE_OPTIONS.includes(pagination.pageSize) ? pagination.pageSize : PROJECTS_DEFAULT_PAGE_SIZE;
const columns = useMemo(() => {
const deps = { onProjectClick, teamAliasMap, isTeamsLoading };
@ -59,7 +55,7 @@ export function ProjectsTable({
}, [onProjectClick, teamAliasMap, isTeamsLoading]);
const pageCount = Math.max(Math.ceil(projects.length / pageSize), 1);
const pageIndex = page >= 1 && page <= pageCount ? page - 1 : 0;
const pageIndex = pagination.pageIndex < pageCount ? pagination.pageIndex : 0;
return (
<DataTable
@ -77,8 +73,8 @@ export function ProjectsTable({
page={pageIndex}
pageSize={pageSize}
rowCount={projects.length}
onPageChange={(nextPageIndex) => void setPagination({ page: nextPageIndex + 1 })}
onPageSizeChange={(nextPageSize) => void setPagination({ page_size: nextPageSize, page: null })}
onPageChange={(nextPageIndex) => onPaginationChange({ pageIndex: nextPageIndex, pageSize })}
onPageSizeChange={(nextPageSize) => onPaginationChange({ pageIndex: 0, pageSize: nextPageSize })}
pageSizeOptions={PAGE_SIZE_OPTIONS}
isLoading={isLoading}
/>

View file

@ -0,0 +1,79 @@
import { functionalUpdate, type OnChangeFn, type PaginationState } from "@tanstack/react-table";
import { useUrlTableState, type UrlTableState, type UrlTableStateOptions } from "@/components/shared/DataTable";
import { parseAsInteger, useQueryStates } from "nuqs";
import { useCallback, useMemo } from "react";
export const PROJECTS_DEFAULT_PAGE_SIZE = 10;
export const PROJECT_KEYS_DEFAULT_PAGE_SIZE = 5;
export const PROJECT_KEYS_PAGE_SIZE_OPTIONS = [PROJECT_KEYS_DEFAULT_PAGE_SIZE, 10, 25];
const PROJECTS_TABLE_STATE_OPTIONS: UrlTableStateOptions<never> = {
sortFields: [],
defaultSort: { id: "created_at", desc: true },
defaultPageSize: PROJECTS_DEFAULT_PAGE_SIZE,
filterColumns: [],
urlKeys: { search: "project_search" },
};
const PROJECTS_PAGE_PARAMS = {
page: parseAsInteger.withDefault(1),
page_size: parseAsInteger.withDefault(PROJECTS_DEFAULT_PAGE_SIZE),
};
const PROJECT_KEYS_TABLE_STATE_OPTIONS: UrlTableStateOptions<never> = {
sortFields: [],
defaultSort: { id: "created_at", desc: true },
defaultPageSize: PROJECT_KEYS_DEFAULT_PAGE_SIZE,
maxPageSize: Math.max(...PROJECT_KEYS_PAGE_SIZE_OPTIONS),
filterColumns: [],
keyPrefix: "keys_",
};
export function useProjectsTableState(): UrlTableState {
const tableState = useUrlTableState(PROJECTS_TABLE_STATE_OPTIONS);
const [, setPageParams] = useQueryStates(PROJECTS_PAGE_PARAMS, { history: "push" });
const { pagination } = tableState;
const onPaginationChange = useCallback<OnChangeFn<PaginationState>>(
(updaterOrValue) => {
const next = functionalUpdate(updaterOrValue, pagination);
void setPageParams({ page: next.pageIndex + 1, page_size: next.pageSize });
},
[pagination, setPageParams],
);
return useMemo(() => ({ ...tableState, onPaginationChange }), [tableState, onPaginationChange]);
}
export function useProjectKeysTableState(): UrlTableState {
const tableState = useUrlTableState(PROJECT_KEYS_TABLE_STATE_OPTIONS);
const { pagination: urlPagination, onPaginationChange: writePagination } = tableState;
const pageSize = PROJECT_KEYS_PAGE_SIZE_OPTIONS.includes(urlPagination.pageSize)
? urlPagination.pageSize
: PROJECT_KEYS_DEFAULT_PAGE_SIZE;
const pagination = useMemo<PaginationState>(
() => ({ pageIndex: urlPagination.pageIndex, pageSize }),
[urlPagination.pageIndex, pageSize],
);
const onPaginationChange = useCallback<OnChangeFn<PaginationState>>(
(updaterOrValue) => writePagination(functionalUpdate(updaterOrValue, pagination)),
[pagination, writePagination],
);
return useMemo(
() => ({ ...tableState, pagination, onPaginationChange }),
[tableState, pagination, onPaginationChange],
);
}
export function useClearProjectKeysTableState(): () => void {
const { setSearch, onSortingChange, onColumnFiltersChange, onPaginationChange } = useProjectKeysTableState();
return useCallback(() => {
setSearch("");
onSortingChange([]);
onColumnFiltersChange([]);
onPaginationChange({ pageIndex: 0, pageSize: PROJECT_KEYS_DEFAULT_PAGE_SIZE });
}, [setSearch, onSortingChange, onColumnFiltersChange, onPaginationChange]);
}

View file

@ -0,0 +1,3 @@
export const ORGANIZATION_TABS = ["overview", "members", "settings"] as const;
export type OrganizationTab = (typeof ORGANIZATION_TABS)[number];
export const ORGANIZATION_TAB_URL_KEY = "org_tab";

View file

@ -1,8 +1,10 @@
import React from "react";
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { vi, test, expect, beforeEach } from "vitest";
import { renderWithProviders } from "../../../tests/test-utils";
import { QueryClientProvider } from "@tanstack/react-query";
import { NuqsTestingAdapter, type OnUrlUpdateFunction } from "nuqs/adapters/testing";
import { vi, test, expect, beforeEach, describe, type Mock } from "vitest";
import { renderWithProviders, testQueryClient } from "../../../tests/test-utils";
import OrganizationInfoView from "./organization_view";
import { useOrganization } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
@ -115,7 +117,6 @@ test("renders organization view after loading data", async () => {
is_org_admin={false}
is_proxy_admin={false}
userModels={[]}
editOrg={false}
/>,
);
@ -135,7 +136,6 @@ test("should display empty state when organization has no members", async () =>
is_org_admin={false}
is_proxy_admin={false}
userModels={[]}
editOrg={false}
/>,
);
@ -165,7 +165,6 @@ test("should display team aliases when teams are available", async () => {
is_org_admin={false}
is_proxy_admin={false}
userModels={[]}
editOrg={false}
/>,
);
@ -199,7 +198,6 @@ test("should display team ID as fallback when alias is not found", async () => {
is_org_admin={false}
is_proxy_admin={false}
userModels={[]}
editOrg={false}
/>,
);
@ -223,7 +221,6 @@ test("links each team badge to that team's detail page", async () => {
is_org_admin={false}
is_proxy_admin={false}
userModels={[]}
editOrg={false}
/>,
);
@ -250,7 +247,6 @@ test("model badges stay non-clickable", async () => {
is_org_admin={false}
is_proxy_admin={false}
userModels={[]}
editOrg={false}
/>,
);
@ -272,7 +268,6 @@ test("should keep unsaved settings edits when switching tabs and back", async ()
is_org_admin={false}
is_proxy_admin={true}
userModels={[]}
editOrg={false}
/>,
);
@ -308,7 +303,6 @@ test("renders a tpm/rpm limit of 0 as 0 in the overview and settings tabs, never
is_org_admin={false}
is_proxy_admin={true}
userModels={[]}
editOrg={false}
/>,
);
@ -323,3 +317,99 @@ test("renders a tpm/rpm limit of 0 as 0 in the overview and settings tabs, never
expect(screen.queryByText("TPM: Unlimited")).not.toBeInTheDocument();
expect(screen.queryByText("RPM: Unlimited")).not.toBeInTheDocument();
});
const renderOrgView = (props: { is_proxy_admin?: boolean } = {}) => (
<OrganizationInfoView
organizationId="org_123"
onClose={() => {}}
accessToken="test-token"
is_org_admin={false}
is_proxy_admin={props.is_proxy_admin ?? false}
userModels={[]}
/>
);
const lastSearchParams = (onUrlUpdate: Mock<OnUrlUpdateFunction>) => onUrlUpdate.mock.calls.at(-1)?.[0].searchParams;
describe("organization detail tab in the URL (?org_tab=)", () => {
beforeEach(() => {
mockUseOrganization.mockReturnValue({ data: mockOrg, isLoading: false } as unknown as ReturnType<
typeof useOrganization
>);
});
test("opens on the tab named by ?org_tab=", () => {
renderWithProviders(renderOrgView(), { searchParams: "?org=org_123&org_tab=members" });
expect(screen.getByRole("tab", { name: "Members" })).toHaveAttribute("aria-selected", "true");
expect(screen.getByText("No members found")).toBeInTheDocument();
});
test("the settings deep link used by the list's Edit action opens the Settings tab", () => {
renderWithProviders(renderOrgView({ is_proxy_admin: true }), { searchParams: "?org=org_123&org_tab=settings" });
expect(screen.getByRole("tab", { name: "Settings" })).toHaveAttribute("aria-selected", "true");
expect(screen.getByRole("button", { name: /Edit Settings/i })).toBeInTheDocument();
});
test("opens on Overview when the URL names no tab", () => {
renderWithProviders(renderOrgView(), { searchParams: "?org=org_123" });
expect(screen.getByRole("tab", { name: "Overview" })).toHaveAttribute("aria-selected", "true");
});
test("writes the selected tab to ?org_tab= and drops it again for Overview", async () => {
const user = userEvent.setup();
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>();
renderWithProviders(renderOrgView(), { searchParams: "?org=org_123", onUrlUpdate });
await user.click(screen.getByRole("tab", { name: "Settings" }));
await waitFor(() => expect(lastSearchParams(onUrlUpdate)?.get("org_tab")).toBe("settings"));
expect(lastSearchParams(onUrlUpdate)?.get("org")).toBe("org_123");
expect(screen.getByRole("tab", { name: "Settings" })).toHaveAttribute("aria-selected", "true");
await user.click(screen.getByRole("tab", { name: "Overview" }));
await waitFor(() => expect(lastSearchParams(onUrlUpdate)?.has("org_tab")).toBe(false));
expect(lastSearchParams(onUrlUpdate)?.get("org")).toBe("org_123");
});
test("falls back to Overview for an unknown ?org_tab= and removes it from the URL", async () => {
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>();
render(renderOrgView(), {
wrapper: ({ children }) => (
<NuqsTestingAdapter
searchParams="?org=org_123&org_tab=billing"
onUrlUpdate={onUrlUpdate}
hasMemory
resetUrlUpdateQueueOnMount={false}
>
<QueryClientProvider client={testQueryClient}>{children}</QueryClientProvider>
</NuqsTestingAdapter>
),
});
expect(screen.getByRole("tab", { name: "Overview" })).toHaveAttribute("aria-selected", "true");
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled());
expect(lastSearchParams(onUrlUpdate)?.has("org_tab")).toBe(false);
expect(lastSearchParams(onUrlUpdate)?.get("org")).toBe("org_123");
});
test("follows back and forward navigation between tabs while the detail view stays open", () => {
const atUrl = (searchParams: string) => (
<NuqsTestingAdapter searchParams={searchParams} hasMemory>
<QueryClientProvider client={testQueryClient}>{renderOrgView()}</QueryClientProvider>
</NuqsTestingAdapter>
);
const { rerender } = render(atUrl("?org=org_123&org_tab=members"));
expect(screen.getByRole("tab", { name: "Members" })).toHaveAttribute("aria-selected", "true");
rerender(atUrl("?org=org_123"));
expect(screen.getByRole("tab", { name: "Overview" })).toHaveAttribute("aria-selected", "true");
rerender(atUrl("?org=org_123&org_tab=members"));
expect(screen.getByRole("tab", { name: "Members" })).toHaveAttribute("aria-selected", "true");
expect(screen.getByText("No members found")).toBeInTheDocument();
});
});

View file

@ -1,6 +1,7 @@
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
import { organizationKeys, useOrganization } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
import { useQueryClient } from "@tanstack/react-query";
import { useUrlTab } from "@/hooks/useUrlTab";
import { useVisitedTabs } from "@/hooks/useVisitedTabs";
import { MoneyCell } from "@/components/shared/table_cells";
import CopyButton from "@/components/shared/CopyButton";
@ -25,6 +26,7 @@ import {
import ObjectPermissionsView from "../object_permissions_view";
import MemberModal from "../team/EditMembership";
import { OrgSettingsForm } from "./org-settings/OrgSettingsForm";
import { ORGANIZATION_TAB_URL_KEY, ORGANIZATION_TABS, type OrganizationTab } from "./organizationTabs";
interface OrganizationInfoProps {
organizationId: string;
@ -33,7 +35,6 @@ interface OrganizationInfoProps {
is_org_admin: boolean;
is_proxy_admin: boolean;
userModels: string[];
editOrg: boolean;
}
const OrganizationInfoView: React.FC<OrganizationInfoProps> = ({
@ -43,7 +44,6 @@ const OrganizationInfoView: React.FC<OrganizationInfoProps> = ({
is_org_admin,
is_proxy_admin,
userModels,
editOrg,
}) => {
const queryClient = useQueryClient();
const { data: orgData, isLoading: loading } = useOrganization(organizationId);
@ -53,10 +53,16 @@ const OrganizationInfoView: React.FC<OrganizationInfoProps> = ({
const [selectedEditMember, setSelectedEditMember] = useState<Member | null>(null);
const canEditOrg = is_org_admin || is_proxy_admin;
const { data: teams } = useTeams();
const { onTabChange, hasVisited } = useVisitedTabs(editOrg ? "settings" : "overview");
const [tab, setTab] = useUrlTab(ORGANIZATION_TABS, "overview", ORGANIZATION_TAB_URL_KEY);
const { onTabChange, hasVisited } = useVisitedTabs(tab);
const teamAliasMap = useMemo(() => createTeamAliasMap(teams), [teams]);
const handleTabChange = (value: OrganizationTab) => {
setTab(value);
onTabChange(value);
};
const handleMemberAdd = async (values: any) => {
try {
if (accessToken == null) {
@ -158,7 +164,7 @@ const OrganizationInfoView: React.FC<OrganizationInfoProps> = ({
</div>
</div>
<Tabs defaultValue={editOrg ? "settings" : "overview"} onValueChange={onTabChange} className="mb-4">
<Tabs value={tab} onValueChange={handleTabChange} className="mb-4">
<TabsList variant="line" className="h-auto w-full justify-start rounded-none border-b p-0">
<TabsTrigger value="overview" className="flex-none rounded-none px-4 py-2">
Overview