mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-07-25 12:01:04 +02:00
chore: incporporate review comments
This commit is contained in:
parent
70e866bd3e
commit
656977d7cd
7 changed files with 200 additions and 16 deletions
|
|
@ -0,0 +1,77 @@
|
|||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { useState } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ContextDestinationRouteRow } from "../../config";
|
||||
import { TransferCallToolConfig } from "./TransferCallToolConfig";
|
||||
|
||||
const noop = vi.fn();
|
||||
|
||||
function ContextMappingHarness() {
|
||||
const [routes, setRoutes] = useState<ContextDestinationRouteRow[]>([
|
||||
{
|
||||
id: "existing-route",
|
||||
context_value: "support",
|
||||
destination: "PJSIP/support",
|
||||
},
|
||||
]);
|
||||
|
||||
return (
|
||||
<TransferCallToolConfig
|
||||
name="Transfer call"
|
||||
onNameChange={noop}
|
||||
description=""
|
||||
onDescriptionChange={noop}
|
||||
destinationSource="context_mapping"
|
||||
onDestinationSourceChange={noop}
|
||||
destination=""
|
||||
onDestinationChange={noop}
|
||||
messageType="none"
|
||||
onMessageTypeChange={noop}
|
||||
customMessage=""
|
||||
onCustomMessageChange={noop}
|
||||
audioRecordingId=""
|
||||
onAudioRecordingIdChange={noop}
|
||||
timeout={30}
|
||||
onTimeoutChange={noop}
|
||||
resolverUrl=""
|
||||
onResolverUrlChange={noop}
|
||||
resolverCredentialUuid=""
|
||||
onResolverCredentialUuidChange={noop}
|
||||
resolverHeaders={[]}
|
||||
onResolverHeadersChange={noop}
|
||||
resolverTimeoutMs={3000}
|
||||
onResolverTimeoutMsChange={noop}
|
||||
resolverWaitMessage=""
|
||||
onResolverWaitMessageChange={noop}
|
||||
parameters={[]}
|
||||
onParametersChange={noop}
|
||||
presetParameters={[]}
|
||||
onPresetParametersChange={noop}
|
||||
externalPbxRoutingEnabled
|
||||
contextMappingPath="department"
|
||||
onContextMappingPathChange={noop}
|
||||
contextDestinationRoutes={routes}
|
||||
onContextDestinationRoutesChange={setRoutes}
|
||||
fallbackDestination=""
|
||||
onFallbackDestinationChange={noop}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe("TransferCallToolConfig context mappings", () => {
|
||||
it("preserves the focused row when an earlier mapping is removed", () => {
|
||||
render(<ContextMappingHarness />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add mapping" }));
|
||||
|
||||
const addedDestination = screen.getByLabelText("PBX destination 2");
|
||||
addedDestination.focus();
|
||||
expect(document.activeElement).toBe(addedDestination);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Remove mapping 1" }));
|
||||
|
||||
expect(screen.getByLabelText("PBX destination 1")).toBe(addedDestination);
|
||||
expect(document.activeElement).toBe(addedDestination);
|
||||
});
|
||||
});
|
||||
|
|
@ -23,7 +23,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
import {
|
||||
type ContextDestinationRoute,
|
||||
type ContextDestinationRouteRow,
|
||||
type EndCallMessageType,
|
||||
type TransferDestinationSource,
|
||||
} from "../../config";
|
||||
|
|
@ -63,8 +63,8 @@ export interface TransferCallToolConfigProps {
|
|||
externalPbxRoutingEnabled: boolean;
|
||||
contextMappingPath: string;
|
||||
onContextMappingPathChange: (path: string) => void;
|
||||
contextDestinationRoutes: ContextDestinationRoute[];
|
||||
onContextDestinationRoutesChange: (routes: ContextDestinationRoute[]) => void;
|
||||
contextDestinationRoutes: ContextDestinationRouteRow[];
|
||||
onContextDestinationRoutesChange: (routes: ContextDestinationRouteRow[]) => void;
|
||||
fallbackDestination: string;
|
||||
onFallbackDestinationChange: (destination: string) => void;
|
||||
}
|
||||
|
|
@ -396,20 +396,24 @@ export function TransferCallToolConfig({
|
|||
size="sm"
|
||||
onClick={() => onContextDestinationRoutesChange([
|
||||
...contextDestinationRoutes,
|
||||
{ context_value: "", destination: "" },
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
context_value: "",
|
||||
destination: "",
|
||||
},
|
||||
])}
|
||||
>
|
||||
<Plus className="mr-1 h-4 w-4" /> Add mapping
|
||||
</Button>
|
||||
</div>
|
||||
{contextDestinationRoutes.map((route, index) => (
|
||||
<div key={index} className="grid grid-cols-[1fr_1fr_auto] gap-2">
|
||||
<div key={route.id} className="grid grid-cols-[1fr_1fr_auto] gap-2">
|
||||
<Input
|
||||
aria-label={`Context value ${index + 1}`}
|
||||
value={route.context_value}
|
||||
onChange={(event) => onContextDestinationRoutesChange(
|
||||
contextDestinationRoutes.map((item, itemIndex) =>
|
||||
itemIndex === index
|
||||
contextDestinationRoutes.map((item) =>
|
||||
item.id === route.id
|
||||
? { ...item, context_value: event.target.value }
|
||||
: item
|
||||
)
|
||||
|
|
@ -420,8 +424,8 @@ export function TransferCallToolConfig({
|
|||
aria-label={`PBX destination ${index + 1}`}
|
||||
value={route.destination}
|
||||
onChange={(event) => onContextDestinationRoutesChange(
|
||||
contextDestinationRoutes.map((item, itemIndex) =>
|
||||
itemIndex === index
|
||||
contextDestinationRoutes.map((item) =>
|
||||
item.id === route.id
|
||||
? { ...item, destination: event.target.value }
|
||||
: item
|
||||
)
|
||||
|
|
@ -434,7 +438,7 @@ export function TransferCallToolConfig({
|
|||
size="icon"
|
||||
aria-label={`Remove mapping ${index + 1}`}
|
||||
onClick={() => onContextDestinationRoutesChange(
|
||||
contextDestinationRoutes.filter((_, itemIndex) => itemIndex !== index)
|
||||
contextDestinationRoutes.filter((item) => item.id !== route.id)
|
||||
)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ import { detailFromError } from "@/lib/apiError";
|
|||
import { useAuth } from "@/lib/auth";
|
||||
|
||||
import {
|
||||
type ContextDestinationRoute,
|
||||
type ContextDestinationRouteRow,
|
||||
createMcpDefinition,
|
||||
DEFAULT_END_CALL_REASON_DESCRIPTION,
|
||||
type EndCallMessageType,
|
||||
|
|
@ -143,7 +143,7 @@ export default function ToolDetailPage() {
|
|||
const [transferPresetParameters, setTransferPresetParameters] = useState<PresetToolParameter[]>([]);
|
||||
const [transferContextMappingPath, setTransferContextMappingPath] = useState("");
|
||||
const [transferContextDestinationRoutes, setTransferContextDestinationRoutes] =
|
||||
useState<ContextDestinationRoute[]>([]);
|
||||
useState<ContextDestinationRouteRow[]>([]);
|
||||
const [transferFallbackDestination, setTransferFallbackDestination] = useState("");
|
||||
|
||||
// HTTP API form state - custom message type
|
||||
|
|
@ -245,7 +245,12 @@ export default function ToolDetailPage() {
|
|||
})),
|
||||
);
|
||||
setTransferContextMappingPath(config.context_mapping?.context_path || "");
|
||||
setTransferContextDestinationRoutes(config.context_mapping?.routes || []);
|
||||
setTransferContextDestinationRoutes(
|
||||
(config.context_mapping?.routes || []).map((route) => ({
|
||||
...route,
|
||||
id: crypto.randomUUID(),
|
||||
}))
|
||||
);
|
||||
setTransferFallbackDestination(
|
||||
config.context_mapping?.fallback_destination || ""
|
||||
);
|
||||
|
|
|
|||
|
|
@ -25,6 +25,10 @@ export interface ContextDestinationRoute {
|
|||
destination: string;
|
||||
}
|
||||
|
||||
export interface ContextDestinationRouteRow extends ContextDestinationRoute {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface ContextDestinationMappingConfig {
|
||||
context_path: string;
|
||||
routes: ContextDestinationRoute[];
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ import { Button } from "@/components/ui/button";
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useOrgConfig } from "@/context/OrgConfigContext";
|
||||
import { useUserConfig } from "@/context/UserConfigContext";
|
||||
import { detailFromError } from "@/lib/apiError";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
|
|
@ -94,7 +93,6 @@ function getTimezoneValue(tz: ITimezoneOption | string): string {
|
|||
export function OrganizationPreferencesSection() {
|
||||
const { user, loading: authLoading } = useAuth();
|
||||
const { refreshConfig } = useUserConfig();
|
||||
const { refreshConfig: refreshOrgConfig } = useOrgConfig();
|
||||
const timezoneSelectId = useId();
|
||||
const hasFetched = useRef(false);
|
||||
|
||||
|
|
@ -180,7 +178,6 @@ export function OrganizationPreferencesSection() {
|
|||
});
|
||||
setTimezone(result.data.timezone || emptyPreferences.timezone || "UTC");
|
||||
await refreshConfig();
|
||||
await refreshOrgConfig();
|
||||
toast.success("Preferences saved");
|
||||
} catch {
|
||||
toast.error("Failed to save preferences");
|
||||
|
|
|
|||
92
ui/src/context/OrgConfigContext.test.tsx
Normal file
92
ui/src/context/OrgConfigContext.test.tsx
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { OrgConfigProvider, useOrgConfig } from './OrgConfigContext';
|
||||
|
||||
const {
|
||||
getCurrentOrganizationContextMock,
|
||||
getPreferencesMock,
|
||||
getUserConfigurationsMock,
|
||||
useAuthMock,
|
||||
} = vi.hoisted(() => ({
|
||||
getCurrentOrganizationContextMock: vi.fn(),
|
||||
getPreferencesMock: vi.fn(),
|
||||
getUserConfigurationsMock: vi.fn(),
|
||||
useAuthMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/client/sdk.gen', () => ({
|
||||
getCurrentOrganizationContextApiV1OrganizationsContextGet: getCurrentOrganizationContextMock,
|
||||
getPreferencesApiV1OrganizationsPreferencesGet: getPreferencesMock,
|
||||
getUserConfigurationsApiV1UserConfigurationsUserGet: getUserConfigurationsMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/apiClient', () => ({
|
||||
createClientConfig: (config: unknown) => config,
|
||||
setupAuthInterceptor: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/auth', () => ({
|
||||
useAuth: useAuthMock,
|
||||
}));
|
||||
|
||||
function ContextState() {
|
||||
const { error, loading } = useOrgConfig();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<span data-testid="loading">{String(loading)}</span>
|
||||
<span data-testid="error">{error?.message ?? ''}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe('OrgConfigProvider', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useAuthMock.mockReturnValue({
|
||||
user: { id: 'user-1', provider: 'local' },
|
||||
isAuthenticated: true,
|
||||
loading: false,
|
||||
getAccessToken: vi.fn(async () => 'token'),
|
||||
redirectToLogin: vi.fn(),
|
||||
logout: vi.fn(async () => undefined),
|
||||
provider: 'local',
|
||||
});
|
||||
getCurrentOrganizationContextMock.mockResolvedValue({
|
||||
data: {
|
||||
organization_id: 1,
|
||||
organization_provider_id: null,
|
||||
model_services: {
|
||||
config_source: 'empty',
|
||||
has_model_configuration_v2: false,
|
||||
managed_service_version: null,
|
||||
uses_managed_service_v2: false,
|
||||
},
|
||||
},
|
||||
error: undefined,
|
||||
});
|
||||
getUserConfigurationsMock.mockResolvedValue({
|
||||
data: {},
|
||||
error: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('surfaces an HTTP error returned while loading organization preferences', async () => {
|
||||
getPreferencesMock.mockResolvedValue({
|
||||
data: undefined,
|
||||
error: { detail: 'Preferences unavailable' },
|
||||
});
|
||||
|
||||
render(
|
||||
<OrgConfigProvider>
|
||||
<ContextState />
|
||||
</OrgConfigProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('loading').textContent).toBe('false');
|
||||
});
|
||||
expect(screen.getByTestId('error').textContent).toBe('Preferences unavailable');
|
||||
});
|
||||
});
|
||||
|
|
@ -6,6 +6,7 @@ import { client } from '@/client/client.gen';
|
|||
import { getCurrentOrganizationContextApiV1OrganizationsContextGet, getPreferencesApiV1OrganizationsPreferencesGet, getUserConfigurationsApiV1UserConfigurationsUserGet } from '@/client/sdk.gen';
|
||||
import type { OrganizationContextResponse, OrganizationPreferences, UserConfigurationRequestResponseSchema } from '@/client/types.gen';
|
||||
import { setupAuthInterceptor } from '@/lib/apiClient';
|
||||
import { detailFromError } from '@/lib/apiError';
|
||||
import type { AuthUser } from '@/lib/auth';
|
||||
import { useAuth } from '@/lib/auth';
|
||||
|
||||
|
|
@ -111,6 +112,10 @@ export function OrgConfigProvider({ children }: { children: ReactNode }) {
|
|||
getPreferencesApiV1OrganizationsPreferencesGet(),
|
||||
]);
|
||||
|
||||
if (preferencesResponse.error) {
|
||||
throw new Error(detailFromError(preferencesResponse.error, 'Failed to load organization preferences'));
|
||||
}
|
||||
|
||||
if (orgContextResponse.data) {
|
||||
setOrgContext(orgContextResponse.data);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue