mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-02-08 22:05:08 -05:00
Compare commits
5 Commits
fix/execut
...
pwuts/open
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a65189f6e6 | ||
|
|
e8d37ab116 | ||
|
|
7f7ef6a271 | ||
|
|
aefac541d9 | ||
|
|
09369d2c3c |
@@ -1,8 +1,9 @@
|
|||||||
import logging
|
import logging
|
||||||
import queue
|
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
from multiprocessing import Manager
|
||||||
|
from queue import Empty
|
||||||
from typing import (
|
from typing import (
|
||||||
TYPE_CHECKING,
|
TYPE_CHECKING,
|
||||||
Annotated,
|
Annotated,
|
||||||
@@ -1163,16 +1164,12 @@ class NodeExecutionEntry(BaseModel):
|
|||||||
|
|
||||||
class ExecutionQueue(Generic[T]):
|
class ExecutionQueue(Generic[T]):
|
||||||
"""
|
"""
|
||||||
Thread-safe queue for managing node execution within a single graph execution.
|
Queue for managing the execution of agents.
|
||||||
|
This will be shared between different processes
|
||||||
Note: Uses queue.Queue (not multiprocessing.Queue) since all access is from
|
|
||||||
threads within the same process. If migrating back to ProcessPoolExecutor,
|
|
||||||
replace with multiprocessing.Manager().Queue() for cross-process safety.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
# Thread-safe queue (not multiprocessing) — see class docstring
|
self.queue = Manager().Queue()
|
||||||
self.queue: queue.Queue[T] = queue.Queue()
|
|
||||||
|
|
||||||
def add(self, execution: T) -> T:
|
def add(self, execution: T) -> T:
|
||||||
self.queue.put(execution)
|
self.queue.put(execution)
|
||||||
@@ -1187,7 +1184,7 @@ class ExecutionQueue(Generic[T]):
|
|||||||
def get_or_none(self) -> T | None:
|
def get_or_none(self) -> T | None:
|
||||||
try:
|
try:
|
||||||
return self.queue.get_nowait()
|
return self.queue.get_nowait()
|
||||||
except queue.Empty:
|
except Empty:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
"""Tests for ExecutionQueue thread-safety."""
|
|
||||||
|
|
||||||
import queue
|
|
||||||
import threading
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from backend.data.execution import ExecutionQueue
|
|
||||||
|
|
||||||
|
|
||||||
def test_execution_queue_uses_stdlib_queue():
|
|
||||||
"""Verify ExecutionQueue uses queue.Queue (not multiprocessing)."""
|
|
||||||
q = ExecutionQueue()
|
|
||||||
assert isinstance(q.queue, queue.Queue)
|
|
||||||
|
|
||||||
|
|
||||||
def test_basic_operations():
|
|
||||||
"""Test add, get, empty, and get_or_none."""
|
|
||||||
q = ExecutionQueue()
|
|
||||||
|
|
||||||
assert q.empty() is True
|
|
||||||
assert q.get_or_none() is None
|
|
||||||
|
|
||||||
result = q.add("item1")
|
|
||||||
assert result == "item1"
|
|
||||||
assert q.empty() is False
|
|
||||||
|
|
||||||
item = q.get()
|
|
||||||
assert item == "item1"
|
|
||||||
assert q.empty() is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_thread_safety():
|
|
||||||
"""Test concurrent access from multiple threads."""
|
|
||||||
q = ExecutionQueue()
|
|
||||||
results = []
|
|
||||||
num_items = 100
|
|
||||||
|
|
||||||
def producer():
|
|
||||||
for i in range(num_items):
|
|
||||||
q.add(f"item_{i}")
|
|
||||||
|
|
||||||
def consumer():
|
|
||||||
count = 0
|
|
||||||
while count < num_items:
|
|
||||||
item = q.get_or_none()
|
|
||||||
if item is not None:
|
|
||||||
results.append(item)
|
|
||||||
count += 1
|
|
||||||
|
|
||||||
producer_thread = threading.Thread(target=producer)
|
|
||||||
consumer_thread = threading.Thread(target=consumer)
|
|
||||||
|
|
||||||
producer_thread.start()
|
|
||||||
consumer_thread.start()
|
|
||||||
|
|
||||||
producer_thread.join(timeout=5)
|
|
||||||
consumer_thread.join(timeout=5)
|
|
||||||
|
|
||||||
assert len(results) == num_items
|
|
||||||
@@ -500,14 +500,33 @@ async def _execute_webhook_preset_trigger(
|
|||||||
return
|
return
|
||||||
logger.debug(f"Executing preset #{preset.id} for webhook #{webhook.id}")
|
logger.debug(f"Executing preset #{preset.id} for webhook #{webhook.id}")
|
||||||
|
|
||||||
|
# Separate trigger inputs from regular graph inputs
|
||||||
|
trigger_node_id = trigger_node.id.split("-")[0]
|
||||||
|
trigger_params_key = f"_trigger_params_{trigger_node_id}"
|
||||||
|
|
||||||
|
# Extract trigger parameters and regular inputs
|
||||||
|
graph_inputs = preset.inputs.copy()
|
||||||
|
trigger_inputs = graph_inputs.pop(trigger_params_key, None)
|
||||||
|
if trigger_inputs is None:
|
||||||
|
# We can't run this, so log a warning and skip
|
||||||
|
logger.warning(
|
||||||
|
f"Preset #{preset.id} is missing trigger parameters for node "
|
||||||
|
f"#{trigger_node.id}"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Add webhook payload to trigger inputs
|
||||||
|
trigger_inputs["payload"] = payload
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await add_graph_execution(
|
await add_graph_execution(
|
||||||
user_id=webhook.user_id,
|
user_id=webhook.user_id,
|
||||||
graph_id=preset.graph_id,
|
graph_id=preset.graph_id,
|
||||||
preset_id=preset.id,
|
preset_id=preset.id,
|
||||||
|
inputs=graph_inputs,
|
||||||
graph_version=preset.graph_version,
|
graph_version=preset.graph_version,
|
||||||
graph_credentials_inputs=preset.credentials,
|
graph_credentials_inputs=preset.credentials,
|
||||||
nodes_input_masks={trigger_node.id: {**preset.inputs, "payload": payload}},
|
nodes_input_masks={trigger_node.id: trigger_inputs},
|
||||||
)
|
)
|
||||||
except GraphNotInLibraryError as e:
|
except GraphNotInLibraryError as e:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
|
|||||||
@@ -299,6 +299,9 @@ class TriggeredPresetSetupRequest(pydantic.BaseModel):
|
|||||||
graph_version: int
|
graph_version: int
|
||||||
|
|
||||||
trigger_config: dict[str, Any]
|
trigger_config: dict[str, Any]
|
||||||
|
constant_inputs: dict[str, Any] = pydantic.Field(
|
||||||
|
default_factory=dict, description="Regular graph input values"
|
||||||
|
)
|
||||||
agent_credentials: dict[str, CredentialsMetaInput] = pydantic.Field(
|
agent_credentials: dict[str, CredentialsMetaInput] = pydantic.Field(
|
||||||
default_factory=dict
|
default_factory=dict
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -188,6 +188,13 @@ async def setup_trigger(
|
|||||||
detail=f"Could not set up webhook: {feedback}",
|
detail=f"Could not set up webhook: {feedback}",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Combine trigger config with graph inputs using the new convention
|
||||||
|
trigger_node_id = trigger_node.id.split("-")[0]
|
||||||
|
trigger_params_key = f"_trigger_params_{trigger_node_id}"
|
||||||
|
|
||||||
|
preset_inputs = params.constant_inputs.copy()
|
||||||
|
preset_inputs[trigger_params_key] = trigger_config_with_credentials
|
||||||
|
|
||||||
new_preset = await db.create_preset(
|
new_preset = await db.create_preset(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
preset=models.LibraryAgentPresetCreatable(
|
preset=models.LibraryAgentPresetCreatable(
|
||||||
@@ -195,7 +202,7 @@ async def setup_trigger(
|
|||||||
graph_version=params.graph_version,
|
graph_version=params.graph_version,
|
||||||
name=params.name,
|
name=params.name,
|
||||||
description=params.description,
|
description=params.description,
|
||||||
inputs=trigger_config_with_credentials,
|
inputs=preset_inputs,
|
||||||
credentials=params.agent_credentials,
|
credentials=params.agent_credentials,
|
||||||
webhook_id=new_webhook.id,
|
webhook_id=new_webhook.id,
|
||||||
is_active=True,
|
is_active=True,
|
||||||
@@ -247,8 +254,19 @@ async def update_preset(
|
|||||||
if (trigger_node := graph.webhook_input_node) and (
|
if (trigger_node := graph.webhook_input_node) and (
|
||||||
preset.inputs is not None and preset.credentials is not None
|
preset.inputs is not None and preset.credentials is not None
|
||||||
):
|
):
|
||||||
|
# Extract trigger config from the special key if it exists
|
||||||
|
trigger_node_id = trigger_node.id.split("-")[0]
|
||||||
|
trigger_params_key = f"_trigger_params_{trigger_node_id}"
|
||||||
|
|
||||||
|
trigger_config = preset.inputs.get(trigger_params_key, None)
|
||||||
|
if trigger_config is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Missing trigger configuration for node {trigger_node.id}",
|
||||||
|
)
|
||||||
|
|
||||||
trigger_config_with_credentials = {
|
trigger_config_with_credentials = {
|
||||||
**preset.inputs,
|
**trigger_config,
|
||||||
**(
|
**(
|
||||||
make_node_credentials_input_map(graph, preset.credentials).get(
|
make_node_credentials_input_map(graph, preset.credentials).get(
|
||||||
trigger_node.id
|
trigger_node.id
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
/*
|
||||||
|
Migration to support input blocks alongside webhook trigger blocks.
|
||||||
|
|
||||||
|
This migration converts existing triggered presets to use the new format where:
|
||||||
|
- Regular graph input values are stored as-is in AgentNodeExecutionInputOutput
|
||||||
|
- Trigger-specific parameters are stored under a special key: _trigger_params_{node_prefix}
|
||||||
|
|
||||||
|
This allows graphs to have both trigger blocks and input blocks simultaneously.
|
||||||
|
*/
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
SET LOCAL statement_timeout = '10min';
|
||||||
|
|
||||||
|
-- Find all graphs with webhook trigger nodes (triggered graphs)
|
||||||
|
-- NOTE: Must check graph structure, not just preset.webhookId, because
|
||||||
|
-- presets can be auto-disabled (webhookId = NULL) while still being triggered presets
|
||||||
|
WITH triggered_graphs AS (
|
||||||
|
SELECT DISTINCT
|
||||||
|
an."agentGraphId" as graph_id,
|
||||||
|
an."agentGraphVersion" as graph_version,
|
||||||
|
an."id" as webhook_node_id,
|
||||||
|
SPLIT_PART(an."id", '-', 1) as node_prefix
|
||||||
|
FROM "AgentNode" an
|
||||||
|
WHERE an."agentBlockId" IN (
|
||||||
|
'd0180ce6-ccb9-48c7-8256-b39e93e62801', -- Airtable Webhook Trigger block
|
||||||
|
'9464a020-ed1d-49e1-990f-7f2ac924a2b7', -- Compass AI Trigger block
|
||||||
|
'd0204ed8-8b81-408d-8b8d-ed087a546228', -- Exa Webset Webhook block
|
||||||
|
'8fa8c167-2002-47ce-aba8-97572fc5d387', -- Generic Webhook Trigger block
|
||||||
|
'6c60ec01-8128-419e-988f-96a063ee2fea', -- GitHub Pull Request Trigger block
|
||||||
|
'8a74c2ad-0104-4640-962f-26c6b69e58cd' -- Slant3D Order Webhook block
|
||||||
|
)
|
||||||
|
),
|
||||||
|
|
||||||
|
-- Find all presets using triggered graphs (both active and auto-disabled)
|
||||||
|
triggered_presets AS (
|
||||||
|
SELECT
|
||||||
|
ap."id" as preset_id,
|
||||||
|
tg.graph_id,
|
||||||
|
tg.graph_version,
|
||||||
|
tg.webhook_node_id,
|
||||||
|
tg.node_prefix,
|
||||||
|
ap."webhookId" -- May be NULL if auto-disabled
|
||||||
|
FROM "AgentPreset" ap
|
||||||
|
JOIN triggered_graphs tg ON tg.graph_id = ap."agentGraphId"
|
||||||
|
AND tg.graph_version = ap."agentGraphVersion"
|
||||||
|
WHERE ap."isDeleted" = false
|
||||||
|
),
|
||||||
|
|
||||||
|
-- Get all current input data for triggered presets
|
||||||
|
current_inputs AS (
|
||||||
|
SELECT
|
||||||
|
tp.preset_id,
|
||||||
|
tp.node_prefix,
|
||||||
|
aneio."name" as input_name,
|
||||||
|
aneio."data" as input_data
|
||||||
|
FROM triggered_presets tp
|
||||||
|
JOIN "AgentNodeExecutionInputOutput" aneio ON aneio."id" = ANY(
|
||||||
|
SELECT unnest(ap."InputPresets")
|
||||||
|
FROM "AgentPreset" ap
|
||||||
|
WHERE ap."id" = tp.preset_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
-- Create new trigger parameter entries and link them to presets
|
||||||
|
INSERT INTO "AgentNodeExecutionInputOutput" ("id", "name", "data", "agentPresetId")
|
||||||
|
SELECT
|
||||||
|
gen_random_uuid()::text,
|
||||||
|
'_trigger_params_' || ci.node_prefix,
|
||||||
|
jsonb_object_agg(ci.input_name, ci.input_data),
|
||||||
|
ci.preset_id
|
||||||
|
FROM current_inputs ci
|
||||||
|
GROUP BY ci.preset_id, ci.node_prefix;
|
||||||
|
|
||||||
|
-- Note: This migration converts ALL existing inputs in triggered presets to trigger parameters
|
||||||
|
-- In the new system, regular graph inputs would be stored alongside these trigger parameters
|
||||||
|
-- but without the special _trigger_params_ prefix
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -151,12 +151,6 @@ export function BlocksControl({
|
|||||||
(block.uiType == BlockUIType.WEBHOOK &&
|
(block.uiType == BlockUIType.WEBHOOK &&
|
||||||
graphHasWebhookNodes &&
|
graphHasWebhookNodes &&
|
||||||
"Agents can only have one webhook-triggered block") ||
|
"Agents can only have one webhook-triggered block") ||
|
||||||
(block.uiType == BlockUIType.WEBHOOK &&
|
|
||||||
graphHasInputNodes &&
|
|
||||||
"Webhook-triggered blocks can't be used together with input blocks") ||
|
|
||||||
(block.uiType == BlockUIType.INPUT &&
|
|
||||||
graphHasWebhookNodes &&
|
|
||||||
"Input blocks can't be used together with a webhook-triggered block") ||
|
|
||||||
null,
|
null,
|
||||||
}));
|
}));
|
||||||
}, [
|
}, [
|
||||||
|
|||||||
@@ -1,16 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
|
import type { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
|
||||||
import type {
|
import { Text } from "@/components/atoms/Text/Text";
|
||||||
BlockIOSubSchema,
|
import type { CredentialsMetaInput } from "@/lib/autogpt-server-api/types";
|
||||||
CredentialsMetaInput,
|
|
||||||
} from "@/lib/autogpt-server-api/types";
|
|
||||||
import { CredentialsInput } from "../CredentialsInputs/CredentialsInputs";
|
import { CredentialsInput } from "../CredentialsInputs/CredentialsInputs";
|
||||||
import {
|
import { RunAgentInputs } from "../RunAgentInputs/RunAgentInputs";
|
||||||
getAgentCredentialsFields,
|
import { getAgentCredentialsFields, getAgentInputFields } from "./helpers";
|
||||||
getAgentInputFields,
|
|
||||||
renderValue,
|
|
||||||
} from "./helpers";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
agent: LibraryAgent;
|
agent: LibraryAgent;
|
||||||
@@ -28,19 +23,23 @@ export function AgentInputsReadOnly({
|
|||||||
getAgentCredentialsFields(agent),
|
getAgentCredentialsFields(agent),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Take actual input entries as leading; augment with schema from input fields.
|
|
||||||
// TODO: ensure consistent ordering.
|
|
||||||
const inputEntries =
|
const inputEntries =
|
||||||
inputs &&
|
inputs &&
|
||||||
Object.entries(inputs).map<[string, [BlockIOSubSchema | undefined, any]]>(
|
Object.entries(inputs).map(([key, value]) => ({
|
||||||
([k, v]) => [k, [inputFields[k], v]],
|
key,
|
||||||
);
|
schema: inputFields[key],
|
||||||
|
value,
|
||||||
|
}));
|
||||||
|
|
||||||
const hasInputs = inputEntries && inputEntries.length > 0;
|
const hasInputs = inputEntries && inputEntries.length > 0;
|
||||||
const hasCredentials = credentialInputs && credentialFieldEntries.length > 0;
|
const hasCredentials = credentialInputs && credentialFieldEntries.length > 0;
|
||||||
|
|
||||||
if (!hasInputs && !hasCredentials) {
|
if (!hasInputs && !hasCredentials) {
|
||||||
return <div className="text-neutral-600">No input for this run.</div>;
|
return (
|
||||||
|
<Text variant="body" className="text-zinc-700">
|
||||||
|
No input for this run.
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -48,16 +47,20 @@ export function AgentInputsReadOnly({
|
|||||||
{/* Regular inputs */}
|
{/* Regular inputs */}
|
||||||
{hasInputs && (
|
{hasInputs && (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{inputEntries.map(([key, [schema, value]]) => (
|
{inputEntries.map(({ key, schema, value }) => {
|
||||||
<div key={key} className="flex flex-col gap-1.5">
|
if (!schema) return null;
|
||||||
<label className="text-sm font-medium">
|
|
||||||
{schema?.title || key}
|
return (
|
||||||
</label>
|
<RunAgentInputs
|
||||||
<p className="whitespace-pre-wrap break-words text-sm text-neutral-700">
|
key={key}
|
||||||
{renderValue(value)}
|
schema={schema}
|
||||||
</p>
|
value={value}
|
||||||
</div>
|
placeholder={schema.description}
|
||||||
))}
|
onChange={() => {}}
|
||||||
|
readOnly={true}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { Button } from "@/components/atoms/Button/Button";
|
|||||||
import { FileInput } from "@/components/atoms/FileInput/FileInput";
|
import { FileInput } from "@/components/atoms/FileInput/FileInput";
|
||||||
import { Switch } from "@/components/atoms/Switch/Switch";
|
import { Switch } from "@/components/atoms/Switch/Switch";
|
||||||
import { GoogleDrivePickerInput } from "@/components/contextual/GoogleDrivePicker/GoogleDrivePickerInput";
|
import { GoogleDrivePickerInput } from "@/components/contextual/GoogleDrivePicker/GoogleDrivePickerInput";
|
||||||
|
import { InformationTooltip } from "@/components/molecules/InformationTooltip/InformationTooltip";
|
||||||
import { TimePicker } from "@/components/molecules/TimePicker/TimePicker";
|
import { TimePicker } from "@/components/molecules/TimePicker/TimePicker";
|
||||||
import {
|
import {
|
||||||
BlockIOObjectSubSchema,
|
BlockIOObjectSubSchema,
|
||||||
@@ -32,6 +33,7 @@ interface Props {
|
|||||||
value?: any;
|
value?: any;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
onChange: (value: any) => void;
|
onChange: (value: any) => void;
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -44,6 +46,7 @@ export function RunAgentInputs({
|
|||||||
value,
|
value,
|
||||||
placeholder,
|
placeholder,
|
||||||
onChange,
|
onChange,
|
||||||
|
readOnly = false,
|
||||||
...props
|
...props
|
||||||
}: Props & React.HTMLAttributes<HTMLElement>) {
|
}: Props & React.HTMLAttributes<HTMLElement>) {
|
||||||
const { handleUploadFile, uploadProgress } = useRunAgentInputs();
|
const { handleUploadFile, uploadProgress } = useRunAgentInputs();
|
||||||
@@ -62,7 +65,6 @@ export function RunAgentInputs({
|
|||||||
id={`${baseId}-number`}
|
id={`${baseId}-number`}
|
||||||
label={schema.title ?? placeholder ?? "Number"}
|
label={schema.title ?? placeholder ?? "Number"}
|
||||||
hideLabel
|
hideLabel
|
||||||
size="small"
|
|
||||||
type="number"
|
type="number"
|
||||||
value={value ?? ""}
|
value={value ?? ""}
|
||||||
placeholder={placeholder || "Enter number"}
|
placeholder={placeholder || "Enter number"}
|
||||||
@@ -80,7 +82,6 @@ export function RunAgentInputs({
|
|||||||
id={`${baseId}-textarea`}
|
id={`${baseId}-textarea`}
|
||||||
label={schema.title ?? placeholder ?? "Text"}
|
label={schema.title ?? placeholder ?? "Text"}
|
||||||
hideLabel
|
hideLabel
|
||||||
size="small"
|
|
||||||
type="textarea"
|
type="textarea"
|
||||||
rows={3}
|
rows={3}
|
||||||
value={value ?? ""}
|
value={value ?? ""}
|
||||||
@@ -130,7 +131,6 @@ export function RunAgentInputs({
|
|||||||
id={`${baseId}-date`}
|
id={`${baseId}-date`}
|
||||||
label={schema.title ?? placeholder ?? "Date"}
|
label={schema.title ?? placeholder ?? "Date"}
|
||||||
hideLabel
|
hideLabel
|
||||||
size="small"
|
|
||||||
type="date"
|
type="date"
|
||||||
value={value ? format(value as Date, "yyyy-MM-dd") : ""}
|
value={value ? format(value as Date, "yyyy-MM-dd") : ""}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -159,7 +159,6 @@ export function RunAgentInputs({
|
|||||||
id={`${baseId}-datetime`}
|
id={`${baseId}-datetime`}
|
||||||
label={schema.title ?? placeholder ?? "Date time"}
|
label={schema.title ?? placeholder ?? "Date time"}
|
||||||
hideLabel
|
hideLabel
|
||||||
size="small"
|
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
value={value ?? ""}
|
value={value ?? ""}
|
||||||
onChange={(e) => onChange((e.target as HTMLInputElement).value)}
|
onChange={(e) => onChange((e.target as HTMLInputElement).value)}
|
||||||
@@ -194,7 +193,6 @@ export function RunAgentInputs({
|
|||||||
label={schema.title ?? placeholder ?? "Select"}
|
label={schema.title ?? placeholder ?? "Select"}
|
||||||
hideLabel
|
hideLabel
|
||||||
value={value ?? ""}
|
value={value ?? ""}
|
||||||
size="small"
|
|
||||||
onValueChange={(val: string) => onChange(val)}
|
onValueChange={(val: string) => onChange(val)}
|
||||||
placeholder={placeholder || "Select an option"}
|
placeholder={placeholder || "Select an option"}
|
||||||
options={schema.enum
|
options={schema.enum
|
||||||
@@ -217,7 +215,6 @@ export function RunAgentInputs({
|
|||||||
items={allKeys.map((key) => ({
|
items={allKeys.map((key) => ({
|
||||||
value: key,
|
value: key,
|
||||||
label: _schema.properties[key]?.title ?? key,
|
label: _schema.properties[key]?.title ?? key,
|
||||||
size: "small",
|
|
||||||
}))}
|
}))}
|
||||||
selectedValues={selectedValues}
|
selectedValues={selectedValues}
|
||||||
onChange={(values: string[]) =>
|
onChange={(values: string[]) =>
|
||||||
@@ -336,7 +333,6 @@ export function RunAgentInputs({
|
|||||||
id={`${baseId}-text`}
|
id={`${baseId}-text`}
|
||||||
label={schema.title ?? placeholder ?? "Text"}
|
label={schema.title ?? placeholder ?? "Text"}
|
||||||
hideLabel
|
hideLabel
|
||||||
size="small"
|
|
||||||
type="text"
|
type="text"
|
||||||
value={value ?? ""}
|
value={value ?? ""}
|
||||||
onChange={(e) => onChange((e.target as HTMLInputElement).value)}
|
onChange={(e) => onChange((e.target as HTMLInputElement).value)}
|
||||||
@@ -347,6 +343,17 @@ export function RunAgentInputs({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="no-drag relative flex w-full">{innerInputElement}</div>
|
<div className="flex w-full flex-col gap-0 space-y-2">
|
||||||
|
<label className="large-medium flex items-center gap-1 font-medium">
|
||||||
|
{schema.title || placeholder}
|
||||||
|
<InformationTooltip description={schema.description} />
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
className="no-drag relative flex w-full"
|
||||||
|
style={readOnly ? { pointerEvents: "none", opacity: 0.7 } : undefined}
|
||||||
|
>
|
||||||
|
{innerInputElement}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,22 +73,15 @@ export function ModalRunSection() {
|
|||||||
title="Task Inputs"
|
title="Task Inputs"
|
||||||
subtitle="Enter the information you want to provide to the agent for this task"
|
subtitle="Enter the information you want to provide to the agent for this task"
|
||||||
>
|
>
|
||||||
{/* Regular inputs */}
|
|
||||||
{inputFields.map(([key, inputSubSchema]) => (
|
{inputFields.map(([key, inputSubSchema]) => (
|
||||||
<div key={key} className="flex w-full flex-col gap-0 space-y-2">
|
<RunAgentInputs
|
||||||
<label className="flex items-center gap-1 text-sm font-medium">
|
key={key}
|
||||||
{inputSubSchema.title || key}
|
schema={inputSubSchema}
|
||||||
<InformationTooltip description={inputSubSchema.description} />
|
value={inputValues[key] ?? inputSubSchema.default}
|
||||||
</label>
|
placeholder={inputSubSchema.description}
|
||||||
|
onChange={(value) => setInputValue(key, value)}
|
||||||
<RunAgentInputs
|
data-testid={`agent-input-${key}`}
|
||||||
schema={inputSubSchema}
|
/>
|
||||||
value={inputValues[key] ?? inputSubSchema.default}
|
|
||||||
placeholder={inputSubSchema.description}
|
|
||||||
onChange={(value) => setInputValue(key, value)}
|
|
||||||
data-testid={`agent-input-${key}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</ModalSection>
|
</ModalSection>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -4,20 +4,19 @@ import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecut
|
|||||||
import type { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
|
import type { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
|
||||||
import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner";
|
import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner";
|
||||||
import { Text } from "@/components/atoms/Text/Text";
|
import { Text } from "@/components/atoms/Text/Text";
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipProvider,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from "@/components/atoms/Tooltip/BaseTooltip";
|
|
||||||
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
|
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
|
||||||
|
import { InformationTooltip } from "@/components/molecules/InformationTooltip/InformationTooltip";
|
||||||
|
import {
|
||||||
|
ScrollableTabs,
|
||||||
|
ScrollableTabsContent,
|
||||||
|
ScrollableTabsList,
|
||||||
|
ScrollableTabsTrigger,
|
||||||
|
} from "@/components/molecules/ScrollableTabs/ScrollableTabs";
|
||||||
import { PendingReviewsList } from "@/components/organisms/PendingReviewsList/PendingReviewsList";
|
import { PendingReviewsList } from "@/components/organisms/PendingReviewsList/PendingReviewsList";
|
||||||
import { usePendingReviewsForExecution } from "@/hooks/usePendingReviews";
|
import { usePendingReviewsForExecution } from "@/hooks/usePendingReviews";
|
||||||
import { isLargeScreen, useBreakpoint } from "@/lib/hooks/useBreakpoint";
|
import { isLargeScreen, useBreakpoint } from "@/lib/hooks/useBreakpoint";
|
||||||
import { InfoIcon } from "@phosphor-icons/react";
|
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { AgentInputsReadOnly } from "../../modals/AgentInputsReadOnly/AgentInputsReadOnly";
|
import { AgentInputsReadOnly } from "../../modals/AgentInputsReadOnly/AgentInputsReadOnly";
|
||||||
import { AnchorLinksWrap } from "../AnchorLinksWrap";
|
|
||||||
import { LoadingSelectedContent } from "../LoadingSelectedContent";
|
import { LoadingSelectedContent } from "../LoadingSelectedContent";
|
||||||
import { RunDetailCard } from "../RunDetailCard/RunDetailCard";
|
import { RunDetailCard } from "../RunDetailCard/RunDetailCard";
|
||||||
import { RunDetailHeader } from "../RunDetailHeader/RunDetailHeader";
|
import { RunDetailHeader } from "../RunDetailHeader/RunDetailHeader";
|
||||||
@@ -28,9 +27,6 @@ import { SelectedRunActions } from "./components/SelectedRunActions/SelectedRunA
|
|||||||
import { WebhookTriggerSection } from "./components/WebhookTriggerSection";
|
import { WebhookTriggerSection } from "./components/WebhookTriggerSection";
|
||||||
import { useSelectedRunView } from "./useSelectedRunView";
|
import { useSelectedRunView } from "./useSelectedRunView";
|
||||||
|
|
||||||
const anchorStyles =
|
|
||||||
"border-b-2 border-transparent pb-1 text-sm font-medium text-slate-600 transition-colors hover:text-slate-900 hover:border-slate-900";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
agent: LibraryAgent;
|
agent: LibraryAgent;
|
||||||
runId: string;
|
runId: string;
|
||||||
@@ -65,13 +61,6 @@ export function SelectedRunView({
|
|||||||
const withSummary = run?.stats?.activity_status;
|
const withSummary = run?.stats?.activity_status;
|
||||||
const withReviews = run?.status === AgentExecutionStatus.REVIEW;
|
const withReviews = run?.status === AgentExecutionStatus.REVIEW;
|
||||||
|
|
||||||
function scrollToSection(id: string) {
|
|
||||||
const element = document.getElementById(id);
|
|
||||||
if (element) {
|
|
||||||
element.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (responseError || httpError) {
|
if (responseError || httpError) {
|
||||||
return (
|
return (
|
||||||
<ErrorCard
|
<ErrorCard
|
||||||
@@ -112,118 +101,116 @@ export function SelectedRunView({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Navigation Links */}
|
<ScrollableTabs
|
||||||
<AnchorLinksWrap>
|
defaultValue="output"
|
||||||
{withSummary && (
|
className="-mt-2 flex flex-col"
|
||||||
<button
|
>
|
||||||
onClick={() => scrollToSection("summary")}
|
<ScrollableTabsList className="px-4">
|
||||||
className={anchorStyles}
|
{withSummary && (
|
||||||
>
|
<ScrollableTabsTrigger value="summary">
|
||||||
Summary
|
Summary
|
||||||
</button>
|
</ScrollableTabsTrigger>
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={() => scrollToSection("output")}
|
|
||||||
className={anchorStyles}
|
|
||||||
>
|
|
||||||
Output
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => scrollToSection("input")}
|
|
||||||
className={anchorStyles}
|
|
||||||
>
|
|
||||||
Your input
|
|
||||||
</button>
|
|
||||||
{withReviews && (
|
|
||||||
<button
|
|
||||||
onClick={() => scrollToSection("reviews")}
|
|
||||||
className={anchorStyles}
|
|
||||||
>
|
|
||||||
Reviews ({pendingReviews.length})
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</AnchorLinksWrap>
|
|
||||||
|
|
||||||
{/* Summary Section */}
|
|
||||||
{withSummary && (
|
|
||||||
<div id="summary" className="scroll-mt-4">
|
|
||||||
<RunDetailCard
|
|
||||||
title={
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Text variant="lead-semibold">Summary</Text>
|
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<InfoIcon
|
|
||||||
size={16}
|
|
||||||
className="cursor-help text-neutral-500 hover:text-neutral-700"
|
|
||||||
/>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p className="max-w-xs">
|
|
||||||
This AI-generated summary describes how the agent
|
|
||||||
handled your task. It's an experimental
|
|
||||||
feature and may occasionally be inaccurate.
|
|
||||||
</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<RunSummary run={run} />
|
|
||||||
</RunDetailCard>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Output Section */}
|
|
||||||
<div id="output" className="scroll-mt-4">
|
|
||||||
<RunDetailCard title="Output">
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="text-neutral-500">
|
|
||||||
<LoadingSpinner />
|
|
||||||
</div>
|
|
||||||
) : run && "outputs" in run ? (
|
|
||||||
<RunOutputs outputs={run.outputs as any} />
|
|
||||||
) : (
|
|
||||||
<Text variant="body" className="text-neutral-600">
|
|
||||||
No output from this run.
|
|
||||||
</Text>
|
|
||||||
)}
|
)}
|
||||||
</RunDetailCard>
|
<ScrollableTabsTrigger value="output">
|
||||||
</div>
|
Output
|
||||||
|
</ScrollableTabsTrigger>
|
||||||
{/* Input Section */}
|
<ScrollableTabsTrigger value="input">
|
||||||
<div id="input" className="scroll-mt-4">
|
Your input
|
||||||
<RunDetailCard title="Your input">
|
</ScrollableTabsTrigger>
|
||||||
<AgentInputsReadOnly
|
{withReviews && (
|
||||||
agent={agent}
|
<ScrollableTabsTrigger value="reviews">
|
||||||
inputs={run?.inputs}
|
Reviews ({pendingReviews.length})
|
||||||
credentialInputs={run?.credential_inputs}
|
</ScrollableTabsTrigger>
|
||||||
/>
|
)}
|
||||||
</RunDetailCard>
|
</ScrollableTabsList>
|
||||||
</div>
|
<div className="my-6 flex flex-col gap-6">
|
||||||
|
{/* Summary Section */}
|
||||||
{/* Reviews Section */}
|
{withSummary && (
|
||||||
{withReviews && (
|
<ScrollableTabsContent value="summary">
|
||||||
<div id="reviews" className="scroll-mt-4">
|
<div className="scroll-mt-4">
|
||||||
<RunDetailCard>
|
<RunDetailCard
|
||||||
{reviewsLoading ? (
|
title={
|
||||||
<div className="text-neutral-500">Loading reviews…</div>
|
<div className="flex items-center gap-1">
|
||||||
) : pendingReviews.length > 0 ? (
|
<Text variant="lead-semibold">Summary</Text>
|
||||||
<PendingReviewsList
|
<InformationTooltip
|
||||||
reviews={pendingReviews}
|
iconSize={20}
|
||||||
onReviewComplete={refetchReviews}
|
description="This AI-generated summary describes how the agent handled your task. It's an experimental feature and may occasionally be inaccurate."
|
||||||
emptyMessage="No pending reviews for this execution"
|
/>
|
||||||
/>
|
</div>
|
||||||
) : (
|
}
|
||||||
<div className="text-neutral-600">
|
>
|
||||||
No pending reviews for this execution
|
<RunSummary run={run} />
|
||||||
|
</RunDetailCard>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</ScrollableTabsContent>
|
||||||
</RunDetailCard>
|
)}
|
||||||
|
|
||||||
|
{/* Output Section */}
|
||||||
|
<ScrollableTabsContent value="output">
|
||||||
|
<div className="scroll-mt-4">
|
||||||
|
<RunDetailCard title="Output">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-neutral-500">
|
||||||
|
<LoadingSpinner />
|
||||||
|
</div>
|
||||||
|
) : run && "outputs" in run ? (
|
||||||
|
<RunOutputs outputs={run.outputs as any} />
|
||||||
|
) : (
|
||||||
|
<Text variant="body" className="text-neutral-600">
|
||||||
|
No output from this run.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</RunDetailCard>
|
||||||
|
</div>
|
||||||
|
</ScrollableTabsContent>
|
||||||
|
|
||||||
|
{/* Input Section */}
|
||||||
|
<ScrollableTabsContent value="input">
|
||||||
|
<div id="input" className="scroll-mt-4">
|
||||||
|
<RunDetailCard
|
||||||
|
title={
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Text variant="lead-semibold">Your input</Text>
|
||||||
|
<InformationTooltip
|
||||||
|
iconSize={20}
|
||||||
|
description="This is the input that was provided to the agent for running this task."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<AgentInputsReadOnly
|
||||||
|
agent={agent}
|
||||||
|
inputs={run?.inputs}
|
||||||
|
credentialInputs={run?.credential_inputs}
|
||||||
|
/>
|
||||||
|
</RunDetailCard>
|
||||||
|
</div>
|
||||||
|
</ScrollableTabsContent>
|
||||||
|
|
||||||
|
{/* Reviews Section */}
|
||||||
|
{withReviews && (
|
||||||
|
<ScrollableTabsContent value="reviews">
|
||||||
|
<div className="scroll-mt-4">
|
||||||
|
<RunDetailCard>
|
||||||
|
{reviewsLoading ? (
|
||||||
|
<LoadingSpinner size="small" />
|
||||||
|
) : pendingReviews.length > 0 ? (
|
||||||
|
<PendingReviewsList
|
||||||
|
reviews={pendingReviews}
|
||||||
|
onReviewComplete={refetchReviews}
|
||||||
|
emptyMessage="No pending reviews for this execution"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Text variant="body" className="text-zinc-700">
|
||||||
|
No pending reviews for this execution
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</RunDetailCard>
|
||||||
|
</div>
|
||||||
|
</ScrollableTabsContent>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</ScrollableTabs>
|
||||||
</div>
|
</div>
|
||||||
</SelectedViewLayout>
|
</SelectedViewLayout>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { humanizeCronExpression } from "@/lib/cron-expression-utils";
|
|||||||
import { isLargeScreen, useBreakpoint } from "@/lib/hooks/useBreakpoint";
|
import { isLargeScreen, useBreakpoint } from "@/lib/hooks/useBreakpoint";
|
||||||
import { formatInTimezone, getTimezoneDisplayName } from "@/lib/timezone-utils";
|
import { formatInTimezone, getTimezoneDisplayName } from "@/lib/timezone-utils";
|
||||||
import { AgentInputsReadOnly } from "../../modals/AgentInputsReadOnly/AgentInputsReadOnly";
|
import { AgentInputsReadOnly } from "../../modals/AgentInputsReadOnly/AgentInputsReadOnly";
|
||||||
import { AnchorLinksWrap } from "../AnchorLinksWrap";
|
|
||||||
import { LoadingSelectedContent } from "../LoadingSelectedContent";
|
import { LoadingSelectedContent } from "../LoadingSelectedContent";
|
||||||
import { RunDetailCard } from "../RunDetailCard/RunDetailCard";
|
import { RunDetailCard } from "../RunDetailCard/RunDetailCard";
|
||||||
import { RunDetailHeader } from "../RunDetailHeader/RunDetailHeader";
|
import { RunDetailHeader } from "../RunDetailHeader/RunDetailHeader";
|
||||||
@@ -17,9 +16,6 @@ import { SelectedViewLayout } from "../SelectedViewLayout";
|
|||||||
import { SelectedScheduleActions } from "./components/SelectedScheduleActions";
|
import { SelectedScheduleActions } from "./components/SelectedScheduleActions";
|
||||||
import { useSelectedScheduleView } from "./useSelectedScheduleView";
|
import { useSelectedScheduleView } from "./useSelectedScheduleView";
|
||||||
|
|
||||||
const anchorStyles =
|
|
||||||
"border-b-2 border-transparent pb-1 text-sm font-medium text-slate-600 transition-colors hover:text-slate-900 hover:border-slate-900";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
agent: LibraryAgent;
|
agent: LibraryAgent;
|
||||||
scheduleId: string;
|
scheduleId: string;
|
||||||
@@ -45,13 +41,6 @@ export function SelectedScheduleView({
|
|||||||
const breakpoint = useBreakpoint();
|
const breakpoint = useBreakpoint();
|
||||||
const isLgScreenUp = isLargeScreen(breakpoint);
|
const isLgScreenUp = isLargeScreen(breakpoint);
|
||||||
|
|
||||||
function scrollToSection(id: string) {
|
|
||||||
const element = document.getElementById(id);
|
|
||||||
if (element) {
|
|
||||||
element.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<ErrorCard
|
<ErrorCard
|
||||||
@@ -108,22 +97,6 @@ export function SelectedScheduleView({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Navigation Links */}
|
|
||||||
<AnchorLinksWrap>
|
|
||||||
<button
|
|
||||||
onClick={() => scrollToSection("schedule")}
|
|
||||||
className={anchorStyles}
|
|
||||||
>
|
|
||||||
Schedule
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => scrollToSection("input")}
|
|
||||||
className={anchorStyles}
|
|
||||||
>
|
|
||||||
Your input
|
|
||||||
</button>
|
|
||||||
</AnchorLinksWrap>
|
|
||||||
|
|
||||||
{/* Schedule Section */}
|
{/* Schedule Section */}
|
||||||
<div id="schedule" className="scroll-mt-4">
|
<div id="schedule" className="scroll-mt-4">
|
||||||
<RunDetailCard title="Schedule">
|
<RunDetailCard title="Schedule">
|
||||||
|
|||||||
@@ -1,84 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import type { GraphExecutionJobInfo } from "@/app/api/__generated__/models/graphExecutionJobInfo";
|
|
||||||
import type { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
|
|
||||||
import { Button } from "@/components/atoms/Button/Button";
|
|
||||||
import { Text } from "@/components/atoms/Text/Text";
|
|
||||||
import { Dialog } from "@/components/molecules/Dialog/Dialog";
|
|
||||||
import { PencilSimpleIcon } from "@phosphor-icons/react";
|
|
||||||
import { RunAgentInputs } from "../../../../modals/RunAgentInputs/RunAgentInputs";
|
|
||||||
import { useEditInputsModal } from "./useEditInputsModal";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
agent: LibraryAgent;
|
|
||||||
schedule: GraphExecutionJobInfo;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function EditInputsModal({ agent, schedule }: Props) {
|
|
||||||
const {
|
|
||||||
isOpen,
|
|
||||||
setIsOpen,
|
|
||||||
inputFields,
|
|
||||||
values,
|
|
||||||
setValues,
|
|
||||||
handleSave,
|
|
||||||
isSaving,
|
|
||||||
} = useEditInputsModal(agent, schedule);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog
|
|
||||||
controlled={{ isOpen, set: setIsOpen }}
|
|
||||||
styling={{ maxWidth: "32rem" }}
|
|
||||||
>
|
|
||||||
<Dialog.Trigger>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="small"
|
|
||||||
className="absolute -right-2 -top-2"
|
|
||||||
>
|
|
||||||
<PencilSimpleIcon className="size-4" /> Edit inputs
|
|
||||||
</Button>
|
|
||||||
</Dialog.Trigger>
|
|
||||||
<Dialog.Content>
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<Text variant="h3">Edit inputs</Text>
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
{Object.entries(inputFields).map(([key, fieldSchema]) => (
|
|
||||||
<div key={key} className="flex flex-col gap-1.5">
|
|
||||||
<label className="text-sm font-medium">
|
|
||||||
{fieldSchema?.title || key}
|
|
||||||
</label>
|
|
||||||
<RunAgentInputs
|
|
||||||
schema={fieldSchema as any}
|
|
||||||
value={values[key]}
|
|
||||||
onChange={(v) => setValues((prev) => ({ ...prev, [key]: v }))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Dialog.Footer>
|
|
||||||
<div className="flex w-full justify-end gap-2">
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="small"
|
|
||||||
onClick={() => setIsOpen(false)}
|
|
||||||
className="min-w-32"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
size="small"
|
|
||||||
onClick={handleSave}
|
|
||||||
loading={isSaving}
|
|
||||||
className="min-w-32"
|
|
||||||
>
|
|
||||||
{isSaving ? "Saving…" : "Save"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Dialog.Footer>
|
|
||||||
</Dialog.Content>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { getGetV1ListExecutionSchedulesForAGraphQueryKey } from "@/app/api/__generated__/endpoints/schedules/schedules";
|
|
||||||
import type { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
|
|
||||||
import type { GraphExecutionJobInfo } from "@/app/api/__generated__/models/graphExecutionJobInfo";
|
|
||||||
import { useToast } from "@/components/molecules/Toast/use-toast";
|
|
||||||
|
|
||||||
function getAgentInputFields(agent: LibraryAgent): Record<string, any> {
|
|
||||||
const schema = agent.input_schema as unknown as {
|
|
||||||
properties?: Record<string, any>;
|
|
||||||
} | null;
|
|
||||||
if (!schema || !schema.properties) return {};
|
|
||||||
const properties = schema.properties as Record<string, any>;
|
|
||||||
const visibleEntries = Object.entries(properties).filter(
|
|
||||||
([, sub]) => !sub?.hidden,
|
|
||||||
);
|
|
||||||
return Object.fromEntries(visibleEntries);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useEditInputsModal(
|
|
||||||
agent: LibraryAgent,
|
|
||||||
schedule: GraphExecutionJobInfo,
|
|
||||||
) {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const { toast } = useToast();
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
|
||||||
const inputFields = useMemo(() => getAgentInputFields(agent), [agent]);
|
|
||||||
const [values, setValues] = useState<Record<string, any>>({
|
|
||||||
...(schedule.input_data as Record<string, any>),
|
|
||||||
});
|
|
||||||
|
|
||||||
async function handleSave() {
|
|
||||||
setIsSaving(true);
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/schedules/${schedule.id}`, {
|
|
||||||
method: "PATCH",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ inputs: values }),
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
let message = "Failed to update schedule inputs";
|
|
||||||
const data = await res.json();
|
|
||||||
message = data?.message || data?.detail || message;
|
|
||||||
throw new Error(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
await queryClient.invalidateQueries({
|
|
||||||
queryKey: getGetV1ListExecutionSchedulesForAGraphQueryKey(
|
|
||||||
schedule.graph_id,
|
|
||||||
),
|
|
||||||
});
|
|
||||||
toast({
|
|
||||||
title: "Schedule inputs updated",
|
|
||||||
});
|
|
||||||
setIsOpen(false);
|
|
||||||
} catch (error: any) {
|
|
||||||
toast({
|
|
||||||
title: "Failed to update schedule inputs",
|
|
||||||
description: error?.message || "An unexpected error occurred.",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
setIsSaving(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
isOpen,
|
|
||||||
setIsOpen,
|
|
||||||
inputFields,
|
|
||||||
values,
|
|
||||||
setValues,
|
|
||||||
handleSave,
|
|
||||||
isSaving,
|
|
||||||
} as const;
|
|
||||||
}
|
|
||||||
@@ -25,9 +25,10 @@ export function SelectedScheduleActions({ agent, scheduleId }: Props) {
|
|||||||
<Button
|
<Button
|
||||||
variant="icon"
|
variant="icon"
|
||||||
size="icon"
|
size="icon"
|
||||||
aria-label="Open in builder"
|
|
||||||
as="NextLink"
|
as="NextLink"
|
||||||
href={openInBuilderHref}
|
href={openInBuilderHref}
|
||||||
|
target="_blank"
|
||||||
|
aria-label="View scheduled task details"
|
||||||
>
|
>
|
||||||
<EyeIcon weight="bold" size={18} className="text-zinc-700" />
|
<EyeIcon weight="bold" size={18} className="text-zinc-700" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import type { GraphExecutionMeta } from "@/app/api/__generated__/models/graphExe
|
|||||||
import type { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
|
import type { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
|
||||||
import { Input } from "@/components/atoms/Input/Input";
|
import { Input } from "@/components/atoms/Input/Input";
|
||||||
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
|
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
|
||||||
import { InformationTooltip } from "@/components/molecules/InformationTooltip/InformationTooltip";
|
|
||||||
import {
|
import {
|
||||||
getAgentCredentialsFields,
|
getAgentCredentialsFields,
|
||||||
getAgentInputFields,
|
getAgentInputFields,
|
||||||
@@ -138,25 +137,13 @@ export function SelectedTemplateView({
|
|||||||
<RunDetailCard title="Your Input">
|
<RunDetailCard title="Your Input">
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{inputFields.map(([key, inputSubSchema]) => (
|
{inputFields.map(([key, inputSubSchema]) => (
|
||||||
<div
|
<RunAgentInputs
|
||||||
key={key}
|
key={key}
|
||||||
className="flex w-full flex-col gap-0 space-y-2"
|
schema={inputSubSchema}
|
||||||
>
|
value={inputs[key] ?? inputSubSchema.default}
|
||||||
<label className="flex items-center gap-1 text-sm font-medium">
|
placeholder={inputSubSchema.description}
|
||||||
{inputSubSchema.title || key}
|
onChange={(value) => setInputValue(key, value)}
|
||||||
{inputSubSchema.description && (
|
/>
|
||||||
<InformationTooltip
|
|
||||||
description={inputSubSchema.description}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</label>
|
|
||||||
<RunAgentInputs
|
|
||||||
schema={inputSubSchema}
|
|
||||||
value={inputs[key] ?? inputSubSchema.default}
|
|
||||||
placeholder={inputSubSchema.description}
|
|
||||||
onChange={(value) => setInputValue(key, value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</RunDetailCard>
|
</RunDetailCard>
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
import type { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
|
import type { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
|
||||||
import { Input } from "@/components/atoms/Input/Input";
|
import { Input } from "@/components/atoms/Input/Input";
|
||||||
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
|
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
|
||||||
import { InformationTooltip } from "@/components/molecules/InformationTooltip/InformationTooltip";
|
|
||||||
import {
|
import {
|
||||||
getAgentCredentialsFields,
|
getAgentCredentialsFields,
|
||||||
getAgentInputFields,
|
getAgentInputFields,
|
||||||
@@ -131,25 +130,13 @@ export function SelectedTriggerView({
|
|||||||
<RunDetailCard title="Your Input">
|
<RunDetailCard title="Your Input">
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{inputFields.map(([key, inputSubSchema]) => (
|
{inputFields.map(([key, inputSubSchema]) => (
|
||||||
<div
|
<RunAgentInputs
|
||||||
key={key}
|
key={key}
|
||||||
className="flex w-full flex-col gap-0 space-y-2"
|
schema={inputSubSchema}
|
||||||
>
|
value={inputs[key] ?? inputSubSchema.default}
|
||||||
<label className="flex items-center gap-1 text-sm font-medium">
|
placeholder={inputSubSchema.description}
|
||||||
{inputSubSchema.title || key}
|
onChange={(value) => setInputValue(key, value)}
|
||||||
{inputSubSchema.description && (
|
/>
|
||||||
<InformationTooltip
|
|
||||||
description={inputSubSchema.description}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</label>
|
|
||||||
<RunAgentInputs
|
|
||||||
schema={inputSubSchema}
|
|
||||||
value={inputs[key] ?? inputSubSchema.default}
|
|
||||||
placeholder={inputSubSchema.description}
|
|
||||||
onChange={(value) => setInputValue(key, value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</RunDetailCard>
|
</RunDetailCard>
|
||||||
|
|||||||
@@ -680,28 +680,20 @@ export function AgentRunDraftView({
|
|||||||
|
|
||||||
{/* Regular inputs */}
|
{/* Regular inputs */}
|
||||||
{Object.entries(agentInputFields).map(([key, inputSubSchema]) => (
|
{Object.entries(agentInputFields).map(([key, inputSubSchema]) => (
|
||||||
<div key={key} className="flex flex-col space-y-2">
|
<RunAgentInputs
|
||||||
<label className="flex items-center gap-1 text-sm font-medium">
|
key={key}
|
||||||
{inputSubSchema.title || key}
|
schema={inputSubSchema}
|
||||||
<InformationTooltip
|
value={inputValues[key] ?? inputSubSchema.default}
|
||||||
description={inputSubSchema.description}
|
placeholder={inputSubSchema.description}
|
||||||
/>
|
onChange={(value) => {
|
||||||
</label>
|
setInputValues((obj) => ({
|
||||||
|
...obj,
|
||||||
<RunAgentInputs
|
[key]: value,
|
||||||
schema={inputSubSchema}
|
}));
|
||||||
value={inputValues[key] ?? inputSubSchema.default}
|
setChangedPresetAttributes((prev) => prev.add("inputs"));
|
||||||
placeholder={inputSubSchema.description}
|
}}
|
||||||
onChange={(value) => {
|
data-testid={`agent-input-${key}`}
|
||||||
setInputValues((obj) => ({
|
/>
|
||||||
...obj,
|
|
||||||
[key]: value,
|
|
||||||
}));
|
|
||||||
setChangedPresetAttributes((prev) => prev.add("inputs"));
|
|
||||||
}}
|
|
||||||
data-testid={`agent-input-${key}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,36 +1,33 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { LaunchDarklyProvider } from "@/services/feature-flags/feature-flag-provider";
|
import { TooltipProvider } from "@/components/atoms/Tooltip/BaseTooltip";
|
||||||
import OnboardingProvider from "@/providers/onboarding/onboarding-provider";
|
import { SentryUserTracker } from "@/components/monitor/SentryUserTracker";
|
||||||
import { BackendAPIProvider } from "@/lib/autogpt-server-api/context";
|
import { BackendAPIProvider } from "@/lib/autogpt-server-api/context";
|
||||||
import { getQueryClient } from "@/lib/react-query/queryClient";
|
import { getQueryClient } from "@/lib/react-query/queryClient";
|
||||||
import { QueryClientProvider } from "@tanstack/react-query";
|
|
||||||
import {
|
|
||||||
ThemeProvider as NextThemesProvider,
|
|
||||||
ThemeProviderProps,
|
|
||||||
} from "next-themes";
|
|
||||||
import { NuqsAdapter } from "nuqs/adapters/next/app";
|
|
||||||
import { TooltipProvider } from "@/components/atoms/Tooltip/BaseTooltip";
|
|
||||||
import CredentialsProvider from "@/providers/agent-credentials/credentials-provider";
|
import CredentialsProvider from "@/providers/agent-credentials/credentials-provider";
|
||||||
import { SentryUserTracker } from "@/components/monitor/SentryUserTracker";
|
import OnboardingProvider from "@/providers/onboarding/onboarding-provider";
|
||||||
|
import { LaunchDarklyProvider } from "@/services/feature-flags/feature-flag-provider";
|
||||||
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { ThemeProvider, ThemeProviderProps } from "next-themes";
|
||||||
|
import { NuqsAdapter } from "nuqs/adapters/next/app";
|
||||||
|
|
||||||
export function Providers({ children, ...props }: ThemeProviderProps) {
|
export function Providers({ children, ...props }: ThemeProviderProps) {
|
||||||
const queryClient = getQueryClient();
|
const queryClient = getQueryClient();
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<NuqsAdapter>
|
<NuqsAdapter>
|
||||||
<NextThemesProvider {...props}>
|
<BackendAPIProvider>
|
||||||
<BackendAPIProvider>
|
<SentryUserTracker />
|
||||||
<SentryUserTracker />
|
<CredentialsProvider>
|
||||||
<CredentialsProvider>
|
<LaunchDarklyProvider>
|
||||||
<LaunchDarklyProvider>
|
<OnboardingProvider>
|
||||||
<OnboardingProvider>
|
<ThemeProvider forcedTheme="light" {...props}>
|
||||||
<TooltipProvider>{children}</TooltipProvider>
|
<TooltipProvider>{children}</TooltipProvider>
|
||||||
</OnboardingProvider>
|
</ThemeProvider>
|
||||||
</LaunchDarklyProvider>
|
</OnboardingProvider>
|
||||||
</CredentialsProvider>
|
</LaunchDarklyProvider>
|
||||||
</BackendAPIProvider>
|
</CredentialsProvider>
|
||||||
</NextThemesProvider>
|
</BackendAPIProvider>
|
||||||
</NuqsAdapter>
|
</NuqsAdapter>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,16 +9,20 @@ import ReactMarkdown from "react-markdown";
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
description?: string;
|
description?: string;
|
||||||
|
iconSize?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function InformationTooltip({ description }: Props) {
|
export function InformationTooltip({ description, iconSize = 24 }: Props) {
|
||||||
if (!description) return null;
|
if (!description) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider delayDuration={400}>
|
<TooltipProvider delayDuration={400}>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Info className="rounded-full p-1 hover:bg-slate-50" size={24} />
|
<Info
|
||||||
|
className="rounded-full p-1 hover:bg-slate-50"
|
||||||
|
size={iconSize}
|
||||||
|
/>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<ReactMarkdown
|
<ReactMarkdown
|
||||||
|
|||||||
@@ -0,0 +1,437 @@
|
|||||||
|
import type { Meta, StoryObj } from "@storybook/nextjs";
|
||||||
|
import {
|
||||||
|
ScrollableTabs,
|
||||||
|
ScrollableTabsContent,
|
||||||
|
ScrollableTabsList,
|
||||||
|
ScrollableTabsTrigger,
|
||||||
|
} from "./ScrollableTabs";
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: "Molecules/ScrollableTabs",
|
||||||
|
component: ScrollableTabs,
|
||||||
|
parameters: {
|
||||||
|
layout: "fullscreen",
|
||||||
|
},
|
||||||
|
tags: ["autodocs"],
|
||||||
|
argTypes: {},
|
||||||
|
} satisfies Meta<typeof ScrollableTabs>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
function ScrollableTabsDemo() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-8 p-8">
|
||||||
|
<h2 className="text-2xl font-bold">ScrollableTabs Examples</h2>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="mb-4 text-lg font-semibold">
|
||||||
|
Short Content (Tabs Hidden)
|
||||||
|
</h3>
|
||||||
|
<div className="h-[300px] overflow-y-auto border border-zinc-200">
|
||||||
|
<ScrollableTabs defaultValue="tab1" className="h-full">
|
||||||
|
<ScrollableTabsList>
|
||||||
|
<ScrollableTabsTrigger value="tab1">
|
||||||
|
Account
|
||||||
|
</ScrollableTabsTrigger>
|
||||||
|
<ScrollableTabsTrigger value="tab2">
|
||||||
|
Password
|
||||||
|
</ScrollableTabsTrigger>
|
||||||
|
<ScrollableTabsTrigger value="tab3">
|
||||||
|
Settings
|
||||||
|
</ScrollableTabsTrigger>
|
||||||
|
</ScrollableTabsList>
|
||||||
|
<ScrollableTabsContent value="tab1">
|
||||||
|
<div className="p-4 text-sm">
|
||||||
|
Make changes to your account here. Click save when you're
|
||||||
|
done.
|
||||||
|
</div>
|
||||||
|
</ScrollableTabsContent>
|
||||||
|
<ScrollableTabsContent value="tab2">
|
||||||
|
<div className="p-4 text-sm">
|
||||||
|
Change your password here. After saving, you'll be logged
|
||||||
|
out.
|
||||||
|
</div>
|
||||||
|
</ScrollableTabsContent>
|
||||||
|
<ScrollableTabsContent value="tab3">
|
||||||
|
<div className="p-4 text-sm">
|
||||||
|
Update your preferences and settings here.
|
||||||
|
</div>
|
||||||
|
</ScrollableTabsContent>
|
||||||
|
</ScrollableTabs>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="mb-4 text-lg font-semibold">
|
||||||
|
Long Content (Tabs Visible)
|
||||||
|
</h3>
|
||||||
|
<div className="h-[400px] overflow-y-auto border border-zinc-200">
|
||||||
|
<ScrollableTabs defaultValue="tab1" className="h-full">
|
||||||
|
<ScrollableTabsList>
|
||||||
|
<ScrollableTabsTrigger value="tab1">
|
||||||
|
Account
|
||||||
|
</ScrollableTabsTrigger>
|
||||||
|
<ScrollableTabsTrigger value="tab2">
|
||||||
|
Password
|
||||||
|
</ScrollableTabsTrigger>
|
||||||
|
<ScrollableTabsTrigger value="tab3">
|
||||||
|
Settings
|
||||||
|
</ScrollableTabsTrigger>
|
||||||
|
</ScrollableTabsList>
|
||||||
|
<ScrollableTabsContent value="tab1">
|
||||||
|
<div className="p-8 text-sm">
|
||||||
|
<h4 className="mb-4 text-lg font-semibold">
|
||||||
|
Account Settings
|
||||||
|
</h4>
|
||||||
|
<p className="mb-4">
|
||||||
|
Make changes to your account here. Click save when
|
||||||
|
you're done.
|
||||||
|
</p>
|
||||||
|
<p className="mb-4">
|
||||||
|
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed
|
||||||
|
do eiusmod tempor incididunt ut labore et dolore magna
|
||||||
|
aliqua. Ut enim ad minim veniam, quis nostrud exercitation
|
||||||
|
ullamco laboris.
|
||||||
|
</p>
|
||||||
|
<p className="mb-4">
|
||||||
|
Duis aute irure dolor in reprehenderit in voluptate velit
|
||||||
|
esse cillum dolore eu fugiat nulla pariatur. Excepteur sint
|
||||||
|
occaecat cupidatat non proident.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Sed ut perspiciatis unde omnis iste natus error sit
|
||||||
|
voluptatem accusantium doloremque laudantium, totam rem
|
||||||
|
aperiam.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</ScrollableTabsContent>
|
||||||
|
<ScrollableTabsContent value="tab2">
|
||||||
|
<div className="p-8 text-sm">
|
||||||
|
<h4 className="mb-4 text-lg font-semibold">
|
||||||
|
Password Settings
|
||||||
|
</h4>
|
||||||
|
<p className="mb-4">
|
||||||
|
Change your password here. After saving, you'll be
|
||||||
|
logged out.
|
||||||
|
</p>
|
||||||
|
<p className="mb-4">
|
||||||
|
At vero eos et accusamus et iusto odio dignissimos ducimus
|
||||||
|
qui blanditiis praesentium voluptatum deleniti atque
|
||||||
|
corrupti quos dolores et quas molestias excepturi sint
|
||||||
|
occaecati cupiditate.
|
||||||
|
</p>
|
||||||
|
<p className="mb-4">
|
||||||
|
Et harum quidem rerum facilis est et expedita distinctio.
|
||||||
|
Nam libero tempore, cum soluta nobis est eligendi optio
|
||||||
|
cumque nihil impedit quo minus.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Temporibus autem quibusdam et aut officiis debitis aut rerum
|
||||||
|
necessitatibus saepe eveniet ut et voluptates repudiandae
|
||||||
|
sint.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</ScrollableTabsContent>
|
||||||
|
<ScrollableTabsContent value="tab3">
|
||||||
|
<div className="p-8 text-sm">
|
||||||
|
<h4 className="mb-4 text-lg font-semibold">
|
||||||
|
General Settings
|
||||||
|
</h4>
|
||||||
|
<p className="mb-4">
|
||||||
|
Update your preferences and settings here.
|
||||||
|
</p>
|
||||||
|
<p className="mb-4">
|
||||||
|
Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut
|
||||||
|
odit aut fugit, sed quia consequuntur magni dolores eos qui
|
||||||
|
ratione voluptatem sequi nesciunt.
|
||||||
|
</p>
|
||||||
|
<p className="mb-4">
|
||||||
|
Neque porro quisquam est, qui dolorem ipsum quia dolor sit
|
||||||
|
amet, consectetur, adipisci velit, sed quia non numquam eius
|
||||||
|
modi tempora incidunt ut labore et dolore magnam aliquam
|
||||||
|
quaerat voluptatem.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Ut enim ad minima veniam, quis nostrum exercitationem ullam
|
||||||
|
corporis suscipit laboriosam, nisi ut aliquid ex ea commodi
|
||||||
|
consequatur.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</ScrollableTabsContent>
|
||||||
|
</ScrollableTabs>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="mb-4 text-lg font-semibold">Many Tabs</h3>
|
||||||
|
<div className="h-[500px] overflow-y-auto border border-zinc-200">
|
||||||
|
<ScrollableTabs defaultValue="overview" className="h-full">
|
||||||
|
<ScrollableTabsList>
|
||||||
|
<ScrollableTabsTrigger value="overview">
|
||||||
|
Overview
|
||||||
|
</ScrollableTabsTrigger>
|
||||||
|
<ScrollableTabsTrigger value="analytics">
|
||||||
|
Analytics
|
||||||
|
</ScrollableTabsTrigger>
|
||||||
|
<ScrollableTabsTrigger value="reports">
|
||||||
|
Reports
|
||||||
|
</ScrollableTabsTrigger>
|
||||||
|
<ScrollableTabsTrigger value="notifications">
|
||||||
|
Notifications
|
||||||
|
</ScrollableTabsTrigger>
|
||||||
|
<ScrollableTabsTrigger value="integrations">
|
||||||
|
Integrations
|
||||||
|
</ScrollableTabsTrigger>
|
||||||
|
<ScrollableTabsTrigger value="billing">
|
||||||
|
Billing
|
||||||
|
</ScrollableTabsTrigger>
|
||||||
|
</ScrollableTabsList>
|
||||||
|
<ScrollableTabsContent value="overview">
|
||||||
|
<div className="p-8 text-sm">
|
||||||
|
<h4 className="mb-4 text-lg font-semibold">
|
||||||
|
Dashboard Overview
|
||||||
|
</h4>
|
||||||
|
<p className="mb-4">
|
||||||
|
Dashboard overview with key metrics and recent activity.
|
||||||
|
</p>
|
||||||
|
<p className="mb-4">
|
||||||
|
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed
|
||||||
|
do eiusmod tempor incididunt ut labore et dolore magna
|
||||||
|
aliqua.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Ut enim ad minim veniam, quis nostrud exercitation ullamco
|
||||||
|
laboris nisi ut aliquip ex ea commodo consequat.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</ScrollableTabsContent>
|
||||||
|
<ScrollableTabsContent value="analytics">
|
||||||
|
<div className="p-8 text-sm">
|
||||||
|
<h4 className="mb-4 text-lg font-semibold">Analytics</h4>
|
||||||
|
<p className="mb-4">
|
||||||
|
Detailed analytics and performance metrics.
|
||||||
|
</p>
|
||||||
|
<p className="mb-4">
|
||||||
|
Duis aute irure dolor in reprehenderit in voluptate velit
|
||||||
|
esse cillum dolore eu fugiat nulla pariatur.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Excepteur sint occaecat cupidatat non proident, sunt in
|
||||||
|
culpa qui officia deserunt mollit anim id est laborum.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</ScrollableTabsContent>
|
||||||
|
<ScrollableTabsContent value="reports">
|
||||||
|
<div className="p-8 text-sm">
|
||||||
|
<h4 className="mb-4 text-lg font-semibold">Reports</h4>
|
||||||
|
<p className="mb-4">
|
||||||
|
Generate and view reports for your account.
|
||||||
|
</p>
|
||||||
|
<p className="mb-4">
|
||||||
|
Sed ut perspiciatis unde omnis iste natus error sit
|
||||||
|
voluptatem accusantium doloremque laudantium.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Totam rem aperiam, eaque ipsa quae ab illo inventore
|
||||||
|
veritatis et quasi architecto beatae vitae dicta sunt
|
||||||
|
explicabo.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</ScrollableTabsContent>
|
||||||
|
<ScrollableTabsContent value="notifications">
|
||||||
|
<div className="p-8 text-sm">
|
||||||
|
<h4 className="mb-4 text-lg font-semibold">Notifications</h4>
|
||||||
|
<p className="mb-4">Manage your notification preferences.</p>
|
||||||
|
<p className="mb-4">
|
||||||
|
Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut
|
||||||
|
odit aut fugit.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Sed quia consequuntur magni dolores eos qui ratione
|
||||||
|
voluptatem sequi nesciunt.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</ScrollableTabsContent>
|
||||||
|
<ScrollableTabsContent value="integrations">
|
||||||
|
<div className="p-8 text-sm">
|
||||||
|
<h4 className="mb-4 text-lg font-semibold">Integrations</h4>
|
||||||
|
<p className="mb-4">
|
||||||
|
Connect and manage third-party integrations.
|
||||||
|
</p>
|
||||||
|
<p className="mb-4">
|
||||||
|
Neque porro quisquam est, qui dolorem ipsum quia dolor sit
|
||||||
|
amet.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Consectetur, adipisci velit, sed quia non numquam eius modi
|
||||||
|
tempora incidunt.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</ScrollableTabsContent>
|
||||||
|
<ScrollableTabsContent value="billing">
|
||||||
|
<div className="p-8 text-sm">
|
||||||
|
<h4 className="mb-4 text-lg font-semibold">Billing</h4>
|
||||||
|
<p className="mb-4">
|
||||||
|
View and manage your billing information.
|
||||||
|
</p>
|
||||||
|
<p className="mb-4">
|
||||||
|
Ut enim ad minima veniam, quis nostrum exercitationem ullam
|
||||||
|
corporis suscipit laboriosam.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Nisi ut aliquid ex ea commodi consequatur? Quis autem vel
|
||||||
|
eum iure reprehenderit qui in ea voluptate velit esse.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</ScrollableTabsContent>
|
||||||
|
</ScrollableTabs>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Default = {
|
||||||
|
render: () => <ScrollableTabsDemo />,
|
||||||
|
} satisfies Story;
|
||||||
|
|
||||||
|
export const ShortContent = {
|
||||||
|
render: () => (
|
||||||
|
<div className="p-8">
|
||||||
|
<div className="h-[200px] overflow-y-auto border border-zinc-200">
|
||||||
|
<ScrollableTabs defaultValue="account" className="h-full">
|
||||||
|
<ScrollableTabsList>
|
||||||
|
<ScrollableTabsTrigger value="account">
|
||||||
|
Account
|
||||||
|
</ScrollableTabsTrigger>
|
||||||
|
<ScrollableTabsTrigger value="password">
|
||||||
|
Password
|
||||||
|
</ScrollableTabsTrigger>
|
||||||
|
</ScrollableTabsList>
|
||||||
|
<ScrollableTabsContent value="account">
|
||||||
|
<div className="p-4 text-sm">
|
||||||
|
Make changes to your account here. Click save when you're
|
||||||
|
done.
|
||||||
|
</div>
|
||||||
|
</ScrollableTabsContent>
|
||||||
|
<ScrollableTabsContent value="password">
|
||||||
|
<div className="p-4 text-sm">
|
||||||
|
Change your password here. After saving, you'll be logged
|
||||||
|
out.
|
||||||
|
</div>
|
||||||
|
</ScrollableTabsContent>
|
||||||
|
</ScrollableTabs>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
} satisfies Story;
|
||||||
|
|
||||||
|
export const LongContent = {
|
||||||
|
render: () => (
|
||||||
|
<div className="p-8">
|
||||||
|
<div className="h-[600px] overflow-y-auto border border-zinc-200">
|
||||||
|
<ScrollableTabs defaultValue="tab1" className="h-full">
|
||||||
|
<ScrollableTabsList>
|
||||||
|
<ScrollableTabsTrigger value="tab1">Account</ScrollableTabsTrigger>
|
||||||
|
<ScrollableTabsTrigger value="tab2">Password</ScrollableTabsTrigger>
|
||||||
|
<ScrollableTabsTrigger value="tab3">Settings</ScrollableTabsTrigger>
|
||||||
|
</ScrollableTabsList>
|
||||||
|
<ScrollableTabsContent value="tab1">
|
||||||
|
<div className="p-8 text-sm">
|
||||||
|
<h4 className="mb-4 text-lg font-semibold">Account Settings</h4>
|
||||||
|
<p className="mb-4">
|
||||||
|
Make changes to your account here. Click save when you're
|
||||||
|
done.
|
||||||
|
</p>
|
||||||
|
<p className="mb-4">
|
||||||
|
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do
|
||||||
|
eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut
|
||||||
|
enim ad minim veniam, quis nostrud exercitation ullamco laboris
|
||||||
|
nisi ut aliquip ex ea commodo consequat.
|
||||||
|
</p>
|
||||||
|
<p className="mb-4">
|
||||||
|
Duis aute irure dolor in reprehenderit in voluptate velit esse
|
||||||
|
cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat
|
||||||
|
cupidatat non proident, sunt in culpa qui officia deserunt
|
||||||
|
mollit anim id est laborum.
|
||||||
|
</p>
|
||||||
|
<p className="mb-4">
|
||||||
|
Sed ut perspiciatis unde omnis iste natus error sit voluptatem
|
||||||
|
accusantium doloremque laudantium, totam rem aperiam, eaque ipsa
|
||||||
|
quae ab illo inventore veritatis et quasi architecto beatae
|
||||||
|
vitae dicta sunt explicabo.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit
|
||||||
|
aut fugit, sed quia consequuntur magni dolores eos qui ratione
|
||||||
|
voluptatem sequi nesciunt.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</ScrollableTabsContent>
|
||||||
|
<ScrollableTabsContent value="tab2">
|
||||||
|
<div className="p-8 text-sm">
|
||||||
|
<h4 className="mb-4 text-lg font-semibold">Password Settings</h4>
|
||||||
|
<p className="mb-4">
|
||||||
|
Change your password here. After saving, you'll be logged
|
||||||
|
out.
|
||||||
|
</p>
|
||||||
|
<p className="mb-4">
|
||||||
|
At vero eos et accusamus et iusto odio dignissimos ducimus qui
|
||||||
|
blanditiis praesentium voluptatum deleniti atque corrupti quos
|
||||||
|
dolores et quas molestias excepturi sint occaecati cupiditate
|
||||||
|
non provident.
|
||||||
|
</p>
|
||||||
|
<p className="mb-4">
|
||||||
|
Similique sunt in culpa qui officia deserunt mollitia animi, id
|
||||||
|
est laborum et dolorum fuga. Et harum quidem rerum facilis est
|
||||||
|
et expedita distinctio.
|
||||||
|
</p>
|
||||||
|
<p className="mb-4">
|
||||||
|
Nam libero tempore, cum soluta nobis est eligendi optio cumque
|
||||||
|
nihil impedit quo minus id quod maxime placeat facere possimus,
|
||||||
|
omnis voluptas assumenda est, omnis dolor repellendus.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Temporibus autem quibusdam et aut officiis debitis aut rerum
|
||||||
|
necessitatibus saepe eveniet ut et voluptates repudiandae sint
|
||||||
|
et molestiae non recusandae.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</ScrollableTabsContent>
|
||||||
|
<ScrollableTabsContent value="tab3">
|
||||||
|
<div className="p-8 text-sm">
|
||||||
|
<h4 className="mb-4 text-lg font-semibold">General Settings</h4>
|
||||||
|
<p className="mb-4">Update your preferences and settings here.</p>
|
||||||
|
<p className="mb-4">
|
||||||
|
Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet,
|
||||||
|
consectetur, adipisci velit, sed quia non numquam eius modi
|
||||||
|
tempora incidunt ut labore et dolore magnam aliquam quaerat
|
||||||
|
voluptatem.
|
||||||
|
</p>
|
||||||
|
<p className="mb-4">
|
||||||
|
Ut enim ad minima veniam, quis nostrum exercitationem ullam
|
||||||
|
corporis suscipit laboriosam, nisi ut aliquid ex ea commodi
|
||||||
|
consequatur? Quis autem vel eum iure reprehenderit qui in ea
|
||||||
|
voluptate velit esse quam nihil molestiae consequatur.
|
||||||
|
</p>
|
||||||
|
<p className="mb-4">
|
||||||
|
Vel illum qui dolorem eum fugiat quo voluptas nulla pariatur? At
|
||||||
|
vero eos et accusamus et iusto odio dignissimos ducimus qui
|
||||||
|
blanditiis praesentium voluptatum deleniti atque corrupti quos
|
||||||
|
dolores.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Et quas molestias excepturi sint occaecati cupiditate non
|
||||||
|
provident, similique sunt in culpa qui officia deserunt mollitia
|
||||||
|
animi, id est laborum et dolorum fuga.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</ScrollableTabsContent>
|
||||||
|
</ScrollableTabs>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
} satisfies Story;
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Children } from "react";
|
||||||
|
import { ScrollableTabsContent } from "./components/ScrollableTabsContent";
|
||||||
|
import { ScrollableTabsList } from "./components/ScrollableTabsList";
|
||||||
|
import { ScrollableTabsTrigger } from "./components/ScrollableTabsTrigger";
|
||||||
|
import { ScrollableTabsContext } from "./context";
|
||||||
|
import { findContentElements, findListElement } from "./helpers";
|
||||||
|
import { useScrollableTabsInternal } from "./useScrollableTabs";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
defaultValue?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScrollableTabs({ children, className, defaultValue }: Props) {
|
||||||
|
const {
|
||||||
|
activeValue,
|
||||||
|
setActiveValue,
|
||||||
|
registerContent,
|
||||||
|
scrollToSection,
|
||||||
|
scrollContainer,
|
||||||
|
contentContainerRef,
|
||||||
|
} = useScrollableTabsInternal({ defaultValue });
|
||||||
|
|
||||||
|
const childrenArray = Children.toArray(children);
|
||||||
|
const listElement = findListElement(childrenArray);
|
||||||
|
const contentElements = findContentElements(childrenArray);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollableTabsContext.Provider
|
||||||
|
value={{
|
||||||
|
activeValue,
|
||||||
|
setActiveValue,
|
||||||
|
registerContent,
|
||||||
|
scrollToSection,
|
||||||
|
scrollContainer,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className={cn("relative flex flex-col", className)}>
|
||||||
|
{listElement}
|
||||||
|
<div
|
||||||
|
ref={(node) => {
|
||||||
|
if (contentContainerRef) {
|
||||||
|
contentContainerRef.current = node;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="max-h-[64rem] overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-zinc-300 dark:scrollbar-thumb-zinc-700"
|
||||||
|
>
|
||||||
|
<div className="min-h-full pb-[200px]">{contentElements}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ScrollableTabsContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ScrollableTabsContent, ScrollableTabsList, ScrollableTabsTrigger };
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import * as React from "react";
|
||||||
|
import { useScrollableTabs } from "../context";
|
||||||
|
|
||||||
|
interface Props extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ScrollableTabsContent = React.forwardRef<HTMLDivElement, Props>(
|
||||||
|
function ScrollableTabsContent(
|
||||||
|
{ className, value, children, ...props },
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
const { registerContent } = useScrollableTabs();
|
||||||
|
const contentRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (contentRef.current) {
|
||||||
|
registerContent(value, contentRef.current);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
registerContent(value, null);
|
||||||
|
};
|
||||||
|
}, [value, registerContent]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={(node) => {
|
||||||
|
if (typeof ref === "function") ref(node);
|
||||||
|
else if (ref) ref.current = node;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
contentRef.current = node;
|
||||||
|
}}
|
||||||
|
data-scrollable-tab-content
|
||||||
|
data-value={value}
|
||||||
|
className={cn("focus-visible:outline-none", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
ScrollableTabsContent.displayName = "ScrollableTabsContent";
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import * as React from "react";
|
||||||
|
import { useScrollableTabs } from "../context";
|
||||||
|
|
||||||
|
export const ScrollableTabsList = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(function ScrollableTabsList({ className, children, ...props }, ref) {
|
||||||
|
const { activeValue } = useScrollableTabs();
|
||||||
|
const [activeTabElement, setActiveTabElement] =
|
||||||
|
React.useState<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const activeButton = Array.from(
|
||||||
|
document.querySelectorAll<HTMLElement>(
|
||||||
|
'[data-scrollable-tab-trigger][data-value="' + activeValue + '"]',
|
||||||
|
),
|
||||||
|
)[0];
|
||||||
|
|
||||||
|
if (activeButton) {
|
||||||
|
setActiveTabElement(activeButton);
|
||||||
|
}
|
||||||
|
}, [activeValue]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative" ref={ref}>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"inline-flex w-full items-center justify-start border-b border-zinc-100",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
{activeTabElement && (
|
||||||
|
<div
|
||||||
|
className="transition-left transition-right absolute bottom-0 h-0.5 bg-purple-600 duration-200 ease-in-out"
|
||||||
|
style={{
|
||||||
|
left: activeTabElement.offsetLeft,
|
||||||
|
width: activeTabElement.offsetWidth,
|
||||||
|
willChange: "left, width",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
ScrollableTabsList.displayName = "ScrollableTabsList";
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import * as React from "react";
|
||||||
|
import { useScrollableTabs } from "../context";
|
||||||
|
|
||||||
|
interface Props extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ScrollableTabsTrigger = React.forwardRef<HTMLButtonElement, Props>(
|
||||||
|
function ScrollableTabsTrigger(
|
||||||
|
{ className, value, children, ...props },
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
const { activeValue, scrollToSection } = useScrollableTabs();
|
||||||
|
const elementRef = React.useRef<HTMLButtonElement>(null);
|
||||||
|
const isActive = activeValue === value;
|
||||||
|
|
||||||
|
function handleClick(e: React.MouseEvent<HTMLButtonElement>) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
scrollToSection(value);
|
||||||
|
props.onClick?.(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
ref={(node) => {
|
||||||
|
if (typeof ref === "function") ref(node);
|
||||||
|
else if (ref) ref.current = node;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
elementRef.current = node;
|
||||||
|
}}
|
||||||
|
data-scrollable-tab-trigger
|
||||||
|
data-value={value}
|
||||||
|
onClick={handleClick}
|
||||||
|
className={cn(
|
||||||
|
"relative inline-flex items-center justify-center whitespace-nowrap px-3 py-3 font-sans text-[0.875rem] font-medium leading-[1.5rem] text-zinc-700 transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-400 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||||
|
isActive && "text-purple-600",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
ScrollableTabsTrigger.displayName = "ScrollableTabsTrigger";
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { createContext, useContext } from "react";
|
||||||
|
|
||||||
|
interface ScrollableTabsContextValue {
|
||||||
|
activeValue: string | null;
|
||||||
|
setActiveValue: React.Dispatch<React.SetStateAction<string | null>>;
|
||||||
|
registerContent: (value: string, element: HTMLElement | null) => void;
|
||||||
|
scrollToSection: (value: string) => void;
|
||||||
|
scrollContainer: HTMLElement | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ScrollableTabsContext = createContext<
|
||||||
|
ScrollableTabsContextValue | undefined
|
||||||
|
>(undefined);
|
||||||
|
|
||||||
|
export function useScrollableTabs() {
|
||||||
|
const context = useContext(ScrollableTabsContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useScrollableTabs must be used within a ScrollableTabs");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
const HEADER_OFFSET = 100;
|
||||||
|
|
||||||
|
export function calculateScrollPosition(
|
||||||
|
elementRect: DOMRect,
|
||||||
|
containerRect: DOMRect,
|
||||||
|
currentScrollTop: number,
|
||||||
|
): number {
|
||||||
|
const elementTopRelativeToContainer =
|
||||||
|
elementRect.top - containerRect.top + currentScrollTop - HEADER_OFFSET;
|
||||||
|
|
||||||
|
return Math.max(0, elementTopRelativeToContainer);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasDisplayName(
|
||||||
|
type: unknown,
|
||||||
|
displayName: string,
|
||||||
|
): type is { displayName: string } {
|
||||||
|
return (
|
||||||
|
typeof type === "object" &&
|
||||||
|
type !== null &&
|
||||||
|
"displayName" in type &&
|
||||||
|
(type as { displayName: unknown }).displayName === displayName
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findListElement(
|
||||||
|
children: React.ReactNode[],
|
||||||
|
): React.ReactElement | undefined {
|
||||||
|
return children.find(
|
||||||
|
(child) =>
|
||||||
|
React.isValidElement(child) &&
|
||||||
|
hasDisplayName(child.type, "ScrollableTabsList"),
|
||||||
|
) as React.ReactElement | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findContentElements(
|
||||||
|
children: React.ReactNode[],
|
||||||
|
): React.ReactNode[] {
|
||||||
|
return children.filter(
|
||||||
|
(child) =>
|
||||||
|
!(
|
||||||
|
React.isValidElement(child) &&
|
||||||
|
hasDisplayName(child.type, "ScrollableTabsList")
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { useCallback, useRef, useState } from "react";
|
||||||
|
import { calculateScrollPosition } from "./helpers";
|
||||||
|
|
||||||
|
interface Args {
|
||||||
|
defaultValue?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useScrollableTabsInternal({ defaultValue }: Args) {
|
||||||
|
const [activeValue, setActiveValue] = useState<string | null>(
|
||||||
|
defaultValue || null,
|
||||||
|
);
|
||||||
|
const contentRefs = useRef<Map<string, HTMLElement>>(new Map());
|
||||||
|
const contentContainerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
function registerContent(value: string, element: HTMLElement | null) {
|
||||||
|
if (element) {
|
||||||
|
contentRefs.current.set(value, element);
|
||||||
|
} else {
|
||||||
|
contentRefs.current.delete(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToSection(value: string) {
|
||||||
|
const element = contentRefs.current.get(value);
|
||||||
|
const scrollContainer = contentContainerRef.current;
|
||||||
|
if (!element || !scrollContainer) return;
|
||||||
|
|
||||||
|
setActiveValue(value);
|
||||||
|
|
||||||
|
const containerRect = scrollContainer.getBoundingClientRect();
|
||||||
|
const elementRect = element.getBoundingClientRect();
|
||||||
|
const currentScrollTop = scrollContainer.scrollTop;
|
||||||
|
const scrollTop = calculateScrollPosition(
|
||||||
|
elementRect,
|
||||||
|
containerRect,
|
||||||
|
currentScrollTop,
|
||||||
|
);
|
||||||
|
|
||||||
|
const maxScrollTop =
|
||||||
|
scrollContainer.scrollHeight - scrollContainer.clientHeight;
|
||||||
|
const clampedScrollTop = Math.min(Math.max(0, scrollTop), maxScrollTop);
|
||||||
|
|
||||||
|
scrollContainer.scrollTo({
|
||||||
|
top: clampedScrollTop,
|
||||||
|
behavior: "smooth",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const memoizedRegisterContent = useCallback(registerContent, []);
|
||||||
|
const memoizedScrollToSection = useCallback(scrollToSection, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeValue,
|
||||||
|
setActiveValue,
|
||||||
|
registerContent: memoizedRegisterContent,
|
||||||
|
scrollToSection: memoizedScrollToSection,
|
||||||
|
scrollContainer: contentContainerRef.current,
|
||||||
|
contentContainerRef,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
|
import scrollbar from "tailwind-scrollbar";
|
||||||
import type { Config } from "tailwindcss";
|
import type { Config } from "tailwindcss";
|
||||||
import tailwindcssAnimate from "tailwindcss-animate";
|
import tailwindcssAnimate from "tailwindcss-animate";
|
||||||
import scrollbar from "tailwind-scrollbar";
|
|
||||||
import { colors } from "./src/components/styles/colors";
|
import { colors } from "./src/components/styles/colors";
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
darkMode: ["class"],
|
darkMode: ["class", ".dark-mode"], // ignore dark: prefix classes for now until we fully support dark mode
|
||||||
content: ["./src/**/*.{ts,tsx}"],
|
content: ["./src/**/*.{ts,tsx}"],
|
||||||
prefix: "",
|
prefix: "",
|
||||||
theme: {
|
theme: {
|
||||||
|
|||||||
Reference in New Issue
Block a user