mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-19 00:01:29 +00:00
chore: merge latest main into model info discovery branch
This commit is contained in:
commit
d0ed8145b0
21 changed files with 1234 additions and 172 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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} />}
|
||||
|
|
|
|||
|
|
@ -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 : "";
|
||||
};
|
||||
|
|
@ -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());
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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: [] }],
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue