diff --git a/frontend/package.json b/frontend/package.json index 28c79352..8d12ce35 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -31,6 +31,7 @@ "react-intl": "^6.5.2", "react-tailwindcss-datepicker": "^1.6.6", "swr": "^2.2.4", + "unique-names-generator": "^4.7.1", "zustand": "^4.4.6" }, "devDependencies": { diff --git a/frontend/public/locales/en.json b/frontend/public/locales/en.json index 785549f9..b3029a56 100644 --- a/frontend/public/locales/en.json +++ b/frontend/public/locales/en.json @@ -172,6 +172,21 @@ "promptHeader": "Prompt Testing", "savedPromptConfig": "Saved Templates" }, + "promptConfig": { + "apiCalls": "API Calls", + "clone": "Clone", + "configCloned": "Successfully cloned", + "general": "General", + "id": "ID", + "modelConfiguration": "Model Configuration", + "modelsCost": "Models Cost", + "name": "Name", + "noOfVariables": "Number of variables", + "overview": "Overview", + "partOfApplication": "Part of application", + "status": "Status", + "test": "Test" + }, "signin": { "authHeader": "Welcome to", "authSubtitle": "Integrate in minutes, scale to millions", diff --git a/frontend/src/app/projects/[projectId]/applications/[applicationId]/prompts/[promptConfigId]/page.tsx b/frontend/src/app/projects/[projectId]/applications/[applicationId]/prompts/[promptConfigId]/page.tsx new file mode 100644 index 00000000..d882f322 --- /dev/null +++ b/frontend/src/app/projects/[projectId]/applications/[applicationId]/prompts/[promptConfigId]/page.tsx @@ -0,0 +1,111 @@ +'use client'; + +import { useTranslations } from 'next-intl'; +import { useState } from 'react'; +import { Speedometer2 } from 'react-bootstrap-icons'; +import useSWR from 'swr'; + +import { handleRetrievePromptConfigs } from '@/api'; +import { PromptAnalyticsPage } from '@/components/projects/[projectId]/applications/[applicationId]/prompts/[promptId]/prompt-analytics-page'; +import { PromptGeneralInfo } from '@/components/projects/[projectId]/applications/[applicationId]/prompts/[promptId]/prompt-general-info'; +import { TabData, TabNavigation } from '@/components/tab-navigation'; +import { ApiError } from '@/errors'; +import { useAuthenticatedUser } from '@/hooks/use-authenticated-user'; +import { useProjectBootstrap } from '@/hooks/use-project-bootstrap'; +import { usePromptConfig, useSetPromptConfigs } from '@/stores/project-store'; +import { useShowError } from '@/stores/toast-store'; + +enum TAB_NAMES { + OVERVIEW, + MODEL, + PROMPT, + SETTINGS, +} + +export default function PromptConfiguration({ + params: { projectId, applicationId, promptConfigId }, +}: { + params: { + projectId: string; + applicationId: string; + promptConfigId: string; + }; +}) { + useAuthenticatedUser(); + useProjectBootstrap(false); + + const t = useTranslations('promptConfig'); + const showError = useShowError(); + + const promptConfig = usePromptConfig(applicationId, promptConfigId); + const setPromptConfigs = useSetPromptConfigs(); + + const { isLoading } = useSWR( + promptConfig ? null : { projectId, applicationId }, + handleRetrievePromptConfigs, + { + onError({ message }: ApiError) { + showError(message); + }, + onSuccess(promptConfigs) { + setPromptConfigs(applicationId, promptConfigs); + }, + }, + ); + + const tabs: TabData[] = [ + { + id: TAB_NAMES.OVERVIEW, + text: t('overview'), + icon: , + }, + ]; + const [selectedTab, setSelectedTab] = useState(TAB_NAMES.OVERVIEW); + + if (!promptConfig && isLoading) { + return ( +
+ +
+ ); + } else if (promptConfig) { + return ( +
+

+ {t('modelConfiguration')} / {promptConfig.name} +

+
+ + tabs={tabs} + selectedTab={selectedTab} + onTabChange={setSelectedTab} + trailingLine={true} + /> +
+ {selectedTab === TAB_NAMES.OVERVIEW && ( + <> + +
+ + + )} +
+ ); + } else { + return null; + } +} diff --git a/frontend/src/components/projects/[projectId]/applications-list.tsx b/frontend/src/components/projects/[projectId]/applications-list.tsx index 7cd4f0ee..33e54237 100644 --- a/frontend/src/components/projects/[projectId]/applications-list.tsx +++ b/frontend/src/components/projects/[projectId]/applications-list.tsx @@ -10,9 +10,9 @@ import { Navigation } from '@/constants'; import { ApiError } from '@/errors'; import { useApplications, - usePromptConfig, + usePromptConfigs, useSetProjectApplications, - useSetPromptConfig, + useSetPromptConfigs, } from '@/stores/project-store'; import { useShowError } from '@/stores/toast-store'; import { populateApplicationId, populateProjectId } from '@/utils/navigation'; @@ -22,8 +22,8 @@ export function ApplicationsList({ projectId }: { projectId: string }) { const applications = useApplications(projectId); const setProjectApplications = useSetProjectApplications(); - const promptConfigs = usePromptConfig(); - const setPromptConfig = useSetPromptConfig(); + const promptConfigs = usePromptConfigs(); + const setPromptConfig = useSetPromptConfigs(); const dialogRef = useRef(null); diff --git a/frontend/src/components/projects/[projectId]/applications/[applicationId]/application-general-settings.tsx b/frontend/src/components/projects/[projectId]/applications/[applicationId]/application-general-settings.tsx index 30eb3121..a90f13f2 100644 --- a/frontend/src/components/projects/[projectId]/applications/[applicationId]/application-general-settings.tsx +++ b/frontend/src/components/projects/[projectId]/applications/[applicationId]/application-general-settings.tsx @@ -11,8 +11,8 @@ import { MIN_NAME_LENGTH } from '@/constants'; import { ApiError } from '@/errors'; import { useApplication, - usePromptConfig, - useSetPromptConfig, + usePromptConfigs, + useSetPromptConfigs, useUpdateApplication, } from '@/stores/project-store'; import { useShowError } from '@/stores/toast-store'; @@ -41,8 +41,8 @@ export function ApplicationGeneralSettings({ const [defaultPromptConfig, setDefaultPromptConfig] = useState< string | undefined >(); - const setPromptConfig = useSetPromptConfig(); - const promptConfigs = usePromptConfig(); + const setPromptConfig = useSetPromptConfigs(); + const promptConfigs = usePromptConfigs(); const isChanged = name !== application?.name || diff --git a/frontend/src/components/projects/[projectId]/applications/[applicationId]/application-prompt-configs.tsx b/frontend/src/components/projects/[projectId]/applications/[applicationId]/application-prompt-configs.tsx index d16e3290..269d85d6 100644 --- a/frontend/src/components/projects/[projectId]/applications/[applicationId]/application-prompt-configs.tsx +++ b/frontend/src/components/projects/[projectId]/applications/[applicationId]/application-prompt-configs.tsx @@ -1,12 +1,15 @@ +import { useRouter } from 'next/navigation'; import { useTranslations } from 'next-intl'; import { Front, PencilFill, Plus, Search } from 'react-bootstrap-icons'; import useSWR from 'swr'; import { handleRetrievePromptConfigs } from '@/api'; +import { Navigation } from '@/constants'; import { ApiError } from '@/errors'; -import { usePromptConfig, useSetPromptConfig } from '@/stores/project-store'; +import { usePromptConfigs, useSetPromptConfigs } from '@/stores/project-store'; import { useShowError, useShowSuccess } from '@/stores/toast-store'; import { copyToClipboard } from '@/utils/helpers'; +import { populateLink } from '@/utils/navigation'; export function ApplicationPromptConfigs({ projectId, @@ -16,8 +19,10 @@ export function ApplicationPromptConfigs({ applicationId: string; }) { const t = useTranslations('application'); - const setPromptConfig = useSetPromptConfig(); - const promptConfigs = usePromptConfig(); + const router = useRouter(); + + const setPromptConfig = useSetPromptConfigs(); + const promptConfigs = usePromptConfigs(); const showError = useShowError(); const showSuccess = useShowSuccess(); @@ -38,6 +43,17 @@ export function ApplicationPromptConfigs({ }, ); + function editPrompt(promptId: string) { + router.push( + populateLink( + Navigation.Prompts, + projectId, + applicationId, + promptId, + ), + ); + } + function renderPromptConfigs() { if (isLoading) { return ( @@ -83,7 +99,12 @@ export function ApplicationPromptConfigs({ - diff --git a/frontend/src/components/projects/[projectId]/applications/[applicationId]/prompts/[promptId]/prompt-analytics-page.tsx b/frontend/src/components/projects/[projectId]/applications/[applicationId]/prompts/[promptId]/prompt-analytics-page.tsx new file mode 100644 index 00000000..6565bf49 --- /dev/null +++ b/frontend/src/components/projects/[projectId]/applications/[applicationId]/prompts/[promptId]/prompt-analytics-page.tsx @@ -0,0 +1,86 @@ +import { useTranslations } from 'next-intl'; +import { useState } from 'react'; +import { Activity, Cash } from 'react-bootstrap-icons'; +import { DateValueType } from 'react-tailwindcss-datepicker'; +import useSWR from 'swr'; + +import { handlePromptConfigAnalytics } from '@/api'; +import { DataCard } from '@/components/dashboard/data-card'; +import { DatePicker } from '@/components/dashboard/date-picker'; +import { ApiError } from '@/errors'; +import { useShowError } from '@/stores/toast-store'; +import { useDateFormat } from '@/stores/user-config-store'; + +export function PromptAnalyticsPage({ + projectId, + applicationId, + promptConfigId, +}: { + projectId: string; + applicationId: string; + promptConfigId: string; +}) { + const t = useTranslations('promptConfig'); + const dateFormat = useDateFormat(); + const showError = useShowError(); + + const oneWeekAgo = new Date(); + oneWeekAgo.setDate(oneWeekAgo.getDate() - 7); + + const [dateRange, setDateRange] = useState({ + startDate: oneWeekAgo, + endDate: new Date(), + }); + + const { data: analytics, isLoading } = useSWR( + { + projectId, + applicationId, + promptConfigId, + fromDate: dateRange?.startDate, + toDate: dateRange?.endDate, + }, + handlePromptConfigAnalytics, + { + onError({ message }: ApiError) { + showError(message); + }, + }, + ); + + return ( +
+
+

+ {t('status')} +

+ +
+
+ } + metric={t('apiCalls')} + totalValue={analytics?.totalRequests ?? ''} + percentage={'100'} + currentValue={'324'} + loading={isLoading} + /> +
+ } + metric={t('modelsCost')} + totalValue={`${analytics?.tokensCost ?? ''}$`} + percentage={'103'} + currentValue={'3.3'} + loading={isLoading} + /> +
+
+ ); +} diff --git a/frontend/src/components/projects/[projectId]/applications/[applicationId]/prompts/[promptId]/prompt-general-info.tsx b/frontend/src/components/projects/[projectId]/applications/[applicationId]/prompts/[promptId]/prompt-general-info.tsx new file mode 100644 index 00000000..0c8a1fab --- /dev/null +++ b/frontend/src/components/projects/[projectId]/applications/[applicationId]/prompts/[promptId]/prompt-general-info.tsx @@ -0,0 +1,156 @@ +import { useRouter } from 'next/navigation'; +import { useTranslations } from 'next-intl'; +import { useState } from 'react'; + +import { handleCreatePromptConfig } from '@/api'; +import { Navigation } from '@/constants'; +import { ApiError } from '@/errors'; +import { + useAddPromptConfig, + useApplication, + usePromptConfig, +} from '@/stores/project-store'; +import { useShowError, useShowInfo } from '@/stores/toast-store'; +import { OpenAIModelParameters, OpenAIPromptMessage } from '@/types'; +import { getCloneName } from '@/utils/helpers'; +import { populateLink } from '@/utils/navigation'; + +export function PromptGeneralInfo({ + projectId, + applicationId, + promptConfigId, +}: { + projectId: string; + applicationId: string; + promptConfigId: string; +}) { + const t = useTranslations('promptConfig'); + const router = useRouter(); + + const promptConfig = usePromptConfig< + OpenAIPromptMessage, + OpenAIModelParameters + >(applicationId, promptConfigId); + const addPromptConfig = useAddPromptConfig(); + const application = useApplication(projectId, applicationId); + + const [cloning, setCloning] = useState(false); + const showError = useShowError(); + const showInfo = useShowInfo(); + + function navigateToPromptTesting() { + router.push( + populateLink( + Navigation.TestingConfig, + projectId, + applicationId, + promptConfigId, + ), + ); + } + + async function clonePrompt() { + if (!promptConfig || cloning) { + return; + } + + const { + name, + modelParameters, + modelType, + modelVendor, + providerPromptMessages, + } = promptConfig; + + try { + setCloning(true); + const newPromptConfig = await handleCreatePromptConfig({ + projectId, + applicationId, + data: { + name: getCloneName(name), + modelVendor, + modelType, + modelParameters, + promptMessages: providerPromptMessages, + }, + }); + addPromptConfig(applicationId, newPromptConfig); + showInfo(t('configCloned')); + router.push( + populateLink( + Navigation.Prompts, + projectId, + applicationId, + newPromptConfig.id, + ), + ); + } catch (e) { + showError((e as ApiError).message); + } finally { + setCloning(false); + } + } + + if (!promptConfig || !application) { + return null; + } + + return ( +
+

{t('general')}

+
+
+
+ +

+ {promptConfig.name} +

+
+
+ +

+ {application.name} +

+
+
+ +

+ {promptConfig.expectedTemplateVariables.length} +

+
+
+ +

{promptConfig.id}

+
+
+
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/tab-navigation.tsx b/frontend/src/components/tab-navigation.tsx index 93d983ef..fda34861 100644 --- a/frontend/src/components/tab-navigation.tsx +++ b/frontend/src/components/tab-navigation.tsx @@ -20,7 +20,7 @@ export function TabNavigation({ trailingLine, }: TabNavigationProps) { return ( -
+
+ ); } diff --git a/frontend/src/components/testing/all-configs-table.tsx b/frontend/src/components/testing/all-configs-table.tsx index 6de0fb86..66128c05 100644 --- a/frontend/src/components/testing/all-configs-table.tsx +++ b/frontend/src/components/testing/all-configs-table.tsx @@ -9,9 +9,9 @@ import { Navigation } from '@/constants'; import { ApiError } from '@/errors'; import { useApplications, - usePromptConfig, + usePromptConfigs, useSetProjectApplications, - useSetPromptConfig, + useSetPromptConfigs, } from '@/stores/project-store'; import { useShowError } from '@/stores/toast-store'; import { Application, PromptConfig } from '@/types'; @@ -23,8 +23,8 @@ export function AllConfigsTable({ projectId }: { projectId: string }) { const applications = useApplications(projectId); const setProjectApplications = useSetProjectApplications(); - const promptConfigs = usePromptConfig(); - const setPromptConfig = useSetPromptConfig(); + const promptConfigs = usePromptConfigs(); + const setPromptConfigs = useSetPromptConfigs(); const showError = useShowError(); @@ -68,7 +68,7 @@ export function AllConfigsTable({ projectId }: { projectId: string }) { ); } data.forEach((promptConfig, index) => { - setPromptConfig(applications![index].id, promptConfig); + setPromptConfigs(applications![index].id, promptConfig); }); }, onError({ message }: ApiError) { diff --git a/frontend/src/constants/navigation.ts b/frontend/src/constants/navigation.ts index 41c0b084..8caae081 100644 --- a/frontend/src/constants/navigation.ts +++ b/frontend/src/constants/navigation.ts @@ -6,6 +6,7 @@ export enum Navigation { Overview = '/projects/:projectId', PrivacyPolicy = '/privacy-policy', Projects = '/projects', + Prompts = '/projects/:projectId/applications/:applicationId/prompts/:configId', Testing = '/projects/:projectId/testing', TestingNewConfig = '/projects/:projectId/testing/new-config', TestingConfig = '/projects/:projectId/applications/:applicationId/:configId/testing', diff --git a/frontend/src/stores/project-store.ts b/frontend/src/stores/project-store.ts index be0f4376..1dfba01d 100644 --- a/frontend/src/stores/project-store.ts +++ b/frontend/src/stores/project-store.ts @@ -32,7 +32,11 @@ export interface ProjectStore { ) => void; setPromptConfig: ( applicationId: string, - promptConfig: PromptConfig[], + promptConfigs: PromptConfig[], + ) => void; + addPromptConfig: ( + applicationId: string, + promptConfig: PromptConfig, ) => void; setAPIKeys: (applicationId: string, apiKeys: APIKey[]) => void; setProjectUsers: ( @@ -130,11 +134,22 @@ export const projectStoreStateCreator: StateCreator = ( }, })); }, - setPromptConfig: (applicationId: string, promptConfig: PromptConfig[]) => { + setPromptConfig: (applicationId: string, promptConfigs: PromptConfig[]) => { set((state) => ({ promptConfigs: { ...state.promptConfigs, - [applicationId]: promptConfig, + [applicationId]: promptConfigs, + }, + })); + }, + addPromptConfig: (applicationId: string, promptConfig: PromptConfig) => { + set((state) => ({ + promptConfigs: { + ...state.promptConfigs, + [applicationId]: [ + ...(state.promptConfigs[applicationId] ?? []), + promptConfig, + ], }, })); }, @@ -257,9 +272,21 @@ export const useDeleteApplication = () => export const useAddApplication = () => useProjectStore((s) => s.addApplication); export const useUpdateApplication = () => useProjectStore((s) => s.updateApplication); -export const usePromptConfig = () => useProjectStore((s) => s.promptConfigs); -export const useSetPromptConfig = () => +export const usePromptConfigs = () => useProjectStore((s) => s.promptConfigs); +export const usePromptConfig = ( + applicationId: string, + promptConfigId: string, +) => + useProjectStore( + (s) => + s.promptConfigs[applicationId]?.find( + (promptConfig) => promptConfig.id === promptConfigId, + ) as PromptConfig | undefined, + ); +export const useSetPromptConfigs = () => useProjectStore((s) => s.setPromptConfig); +export const useAddPromptConfig = () => + useProjectStore((s) => s.addPromptConfig); export const useAPIKeys = (applicationId: string) => useProjectStore((s) => s.apiKeys[applicationId]); export const useSetAPIKeys = () => useProjectStore((s) => s.setAPIKeys); diff --git a/frontend/src/styles/globals.scss b/frontend/src/styles/globals.scss index b4d902b7..6a0373a7 100644 --- a/frontend/src/styles/globals.scss +++ b/frontend/src/styles/globals.scss @@ -68,4 +68,16 @@ div#__next > div { .dialog-box { @apply modal-box p-0 border border-neutral max-w-[43rem]; } + + .main-page { + @apply mx-8 my-32; + + h1 { + @apply text-2xl font-semibold text-base-content; + } + + nav { + @apply mt-3.5 w-full mb-8; + } + } } diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 5c402f07..e1b2e2b9 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -67,12 +67,8 @@ export interface PromptConfig

{ export type PromptConfigCreateBody

= Pick< PromptConfig, - | 'name' - | 'modelParameters' - | 'modelType' - | 'modelVendor' - | 'providerPromptMessages' ->; + 'name' | 'modelParameters' | 'modelType' | 'modelVendor' +> & { promptMessages: M[] }; export type PromptConfigUpdateBody = Partial; diff --git a/frontend/src/utils/helpers.ts b/frontend/src/utils/helpers.ts index 6f32426d..bef606b7 100644 --- a/frontend/src/utils/helpers.ts +++ b/frontend/src/utils/helpers.ts @@ -1,4 +1,9 @@ import { EventHandler, SyntheticEvent } from 'react'; +import { + adjectives, + animals, + uniqueNamesGenerator, +} from 'unique-names-generator'; export function handleChange( cb: (value: any) => void, @@ -16,3 +21,9 @@ export function handleChange( export function copyToClipboard(text: string) { void navigator.clipboard.writeText(text); } + +export function getCloneName(name: string) { + return `${uniqueNamesGenerator({ + dictionaries: [adjectives, animals], // colors can be omitted here as not used + })} clone of ${name}`; +} diff --git a/frontend/src/utils/navigation.ts b/frontend/src/utils/navigation.ts index a10a7973..8c87cf66 100644 --- a/frontend/src/utils/navigation.ts +++ b/frontend/src/utils/navigation.ts @@ -32,6 +32,7 @@ export function populateLink( } return url; } + export function populateProjectId(search: string, projectId: string) { return search.replaceAll(':projectId', projectId); } @@ -39,6 +40,7 @@ export function populateProjectId(search: string, projectId: string) { export function populateApplicationId(search: string, applicationId: string) { return search.replaceAll(':applicationId', applicationId); } + export function populateConfigId(search: string, configId: string) { return search.replaceAll(':configId', configId); } diff --git a/frontend/tests/api/prompt-config-api.spec.ts b/frontend/tests/api/prompt-config-api.spec.ts index aeb8a4be..58edfae3 100644 --- a/frontend/tests/api/prompt-config-api.spec.ts +++ b/frontend/tests/api/prompt-config-api.spec.ts @@ -36,7 +36,7 @@ describe('prompt configs API', () => { modelParameters: promptConfig.modelParameters, modelVendor: promptConfig.modelVendor, modelType: promptConfig.modelType, - providerPromptMessages: promptConfig.providerPromptMessages, + promptMessages: promptConfig.providerPromptMessages, }; const data = await handleCreatePromptConfig({ @@ -66,7 +66,7 @@ describe('prompt configs API', () => { it('returns a list of prompt configs', async () => { const project = await ProjectFactory.build(); const application = await ApplicationFactory.build(); - const promptConfigs = await PromptConfigFactory.batch(2); + const promptConfigs = PromptConfigFactory.batchSync(2); mockFetch.mockResolvedValueOnce({ ok: true, @@ -110,7 +110,7 @@ describe('prompt configs API', () => { modelParameters: promptConfig.modelParameters, modelVendor: promptConfig.modelVendor, modelType: promptConfig.modelType, - providerPromptMessages: promptConfig.providerPromptMessages, + promptMessages: promptConfig.providerPromptMessages, }; const data = await handleUpdatePromptConfig({ diff --git a/frontend/tests/app/projects/[projectId]/application/[applicationId]/application-prompt-configs.spec.tsx b/frontend/tests/app/projects/[projectId]/application/[applicationId]/application-prompt-configs.spec.tsx index 4e376665..52cef48e 100644 --- a/frontend/tests/app/projects/[projectId]/application/[applicationId]/application-prompt-configs.spec.tsx +++ b/frontend/tests/app/projects/[projectId]/application/[applicationId]/application-prompt-configs.spec.tsx @@ -1,6 +1,8 @@ import { fireEvent, waitFor } from '@testing-library/react'; import { PromptConfigFactory } from 'tests/factories'; +import { routerPushMock } from 'tests/mocks'; import { render, screen } from 'tests/test-utils'; +import { expect } from 'vitest'; import * as PromptConfigAPI from '@/api/prompt-config-api'; import { ApplicationPromptConfigs } from '@/components/projects/[projectId]/applications/[applicationId]/application-prompt-configs'; @@ -85,4 +87,25 @@ describe('ApplicationPromptConfigs', () => { promptConfigs[0].id, ); }); + + it('navigates to edit prompt screen', async () => { + const promptConfigs = await PromptConfigFactory.batch(1); + handleRetrievePromptConfigsSpy.mockResolvedValueOnce(promptConfigs); + + await waitFor(() => + render( + , + ), + ); + + const editButton = screen.getByTestId('application-edit-prompt-button'); + fireEvent.click(editButton); + + expect(routerPushMock).toHaveBeenCalledWith( + `/projects/${projectId}/applications/${applicationId}/prompts/${promptConfigs[0].id}`, + ); + }); }); diff --git a/frontend/tests/app/projects/[projectId]/application/[applicationId]/application.spec.tsx b/frontend/tests/app/projects/[projectId]/application/[applicationId]/application.spec.tsx index 83b0e41f..be0d4fac 100644 --- a/frontend/tests/app/projects/[projectId]/application/[applicationId]/application.spec.tsx +++ b/frontend/tests/app/projects/[projectId]/application/[applicationId]/application.spec.tsx @@ -30,6 +30,26 @@ describe('ApplicationPage', () => { HTMLDialogElement.prototype.close = vi.fn(); }); + it('returns null when application is not present', () => { + const { + result: { current: setProjects }, + } = renderHook(useSetProjects); + const projects = ProjectFactory.batchSync(1); + setProjects(projects); + + render( + , + ); + + const pageContainer = screen.queryByTestId('application-page'); + expect(pageContainer).not.toBeInTheDocument(); + }); + it('renders all 3 screens in tab navigation', async () => { const { result: { current: setProjects }, diff --git a/frontend/tests/app/projects/[projectId]/application/[applicationId]/prompts/[promptId]/prompt-analytics.spec.tsx b/frontend/tests/app/projects/[projectId]/application/[applicationId]/prompts/[promptId]/prompt-analytics.spec.tsx new file mode 100644 index 00000000..ace3b4a8 --- /dev/null +++ b/frontend/tests/app/projects/[projectId]/application/[applicationId]/prompts/[promptId]/prompt-analytics.spec.tsx @@ -0,0 +1,117 @@ +import { waitFor } from '@testing-library/react'; +import { useTranslations } from 'next-intl'; +import { fireEvent, render, renderHook, screen } from 'tests/test-utils'; + +import * as PromptConfigAPI from '@/api/prompt-config-api'; +import { PromptAnalyticsPage } from '@/components/projects/[projectId]/applications/[applicationId]/prompts/[promptId]/prompt-analytics-page'; +import { ApiError } from '@/errors'; +import { ToastType } from '@/stores/toast-store'; +import { AnalyticsDTO } from '@/types'; + +describe('PromptAnalyticsPage', () => { + const projectId = '1'; + const applicationId = '2'; + const promptConfigId = '3'; + + const handlePromptConfigAnalyticsSpy = vi.spyOn( + PromptConfigAPI, + 'handlePromptConfigAnalytics', + ); + + beforeEach(() => { + vi.resetAllMocks(); + }); + + it('renders analytics', async () => { + const { + result: { current: t }, + } = renderHook(() => useTranslations('application')); + const analytics = { + totalRequests: 434, + tokensCost: 3, + } satisfies AnalyticsDTO; + handlePromptConfigAnalyticsSpy.mockResolvedValueOnce(analytics); + + await waitFor(() => + render( + , + ), + ); + const apiCalls = screen.getByTestId( + `data-card-total-value-${t('apiCalls')}`, + ); + const modelsCost = screen.getByTestId( + `data-card-total-value-${t('modelsCost')}`, + ); + + expect(apiCalls.innerHTML).toBe(analytics.totalRequests.toString()); + expect(modelsCost.innerHTML).toBe(`${analytics.tokensCost}$`); + }); + + it('renders updated analytics on date change', async () => { + const { + result: { current: t }, + } = renderHook(() => useTranslations('application')); + const initialAnalytics = { + totalRequests: 434, + tokensCost: 3, + } satisfies AnalyticsDTO; + handlePromptConfigAnalyticsSpy.mockResolvedValueOnce(initialAnalytics); + + await waitFor(() => + render( + , + ), + ); + + const apiCalls = screen.getByTestId( + `data-card-total-value-${t('apiCalls')}`, + ); + + const updatedAnalytics = { + totalRequests: 474, + tokensCost: 4, + } satisfies AnalyticsDTO; + handlePromptConfigAnalyticsSpy.mockResolvedValueOnce(updatedAnalytics); + + const datePicker = screen.getByTestId('datepicker'); + fireEvent.click(datePicker); + const todayBtn = screen.getByText('Today'); + fireEvent.click(todayBtn); + + await waitFor(() => { + expect(apiCalls.innerHTML).toBe( + updatedAnalytics.totalRequests.toString(), + ); + }); + }); + + it('shows error when unable to fetch analytics', () => { + handlePromptConfigAnalyticsSpy.mockImplementationOnce(() => { + throw new ApiError('unable to fetch prompt config analytics', { + statusCode: 401, + statusText: 'Bad Request', + }); + }); + + render( + , + ); + const errorToast = screen.getByText( + 'unable to fetch prompt config analytics', + ); + expect(errorToast.className).toContain(ToastType.ERROR); + }); +}); diff --git a/frontend/tests/app/projects/[projectId]/application/[applicationId]/prompts/[promptId]/prompt-configuration.spec.tsx b/frontend/tests/app/projects/[projectId]/application/[applicationId]/prompts/[promptId]/prompt-configuration.spec.tsx new file mode 100644 index 00000000..4c480b22 --- /dev/null +++ b/frontend/tests/app/projects/[projectId]/application/[applicationId]/prompts/[promptId]/prompt-configuration.spec.tsx @@ -0,0 +1,163 @@ +import { + ApplicationFactory, + ProjectFactory, + PromptConfigFactory, +} from 'tests/factories'; +import { render, renderHook, screen, waitFor } from 'tests/test-utils'; +import { expect } from 'vitest'; + +import * as PromptConfigAPI from '@/api/prompt-config-api'; +import PromptConfiguration from '@/app/projects/[projectId]/applications/[applicationId]/prompts/[promptConfigId]/page'; +import { ApiError } from '@/errors'; +import { + useSetProjectApplications, + useSetProjects, + useSetPromptConfigs, +} from '@/stores/project-store'; +import { ToastType } from '@/stores/toast-store'; + +describe('PromptConfiguration', () => { + const handleRetrievePromptConfigsSpy = vi.spyOn( + PromptConfigAPI, + 'handleRetrievePromptConfigs', + ); + + const { + result: { current: setProjects }, + } = renderHook(useSetProjects); + const projects = ProjectFactory.batchSync(1); + setProjects(projects); + + const applications = ApplicationFactory.batchSync(1); + const { + result: { current: setProjectApplications }, + } = renderHook(useSetProjectApplications); + setProjectApplications(projects[0].id, applications); + + const projectId = projects[0].id; + const applicationId = applications[0].id; + + it('renders all 3 screens in tab navigation', async () => { + const promptConfig = PromptConfigFactory.buildSync(); + handleRetrievePromptConfigsSpy.mockResolvedValueOnce([promptConfig]); + + render( + , + ); + + await waitFor(() => { + const pageTitle = screen.getByTestId('prompt-page-title'); + expect(pageTitle.innerHTML).toContain(promptConfig.name); + }); + + // Renders overview + const analytics = screen.getByTestId('prompt-analytics-container'); + expect(analytics).toBeInTheDocument(); + + const generalInfo = screen.getByTestId('prompt-general-info-container'); + expect(generalInfo).toBeInTheDocument(); + + const promptName = screen.getByTestId('prompt-general-info-name'); + expect(promptName.innerHTML).toBe(promptConfig.name); + + // TODO: update this test when more tabs are added to navigation + }); + + it('shows loading when prompt config is being fetched', () => { + const promptConfig = PromptConfigFactory.buildSync(); + handleRetrievePromptConfigsSpy.mockResolvedValueOnce([promptConfig]); + + render( + , + ); + + const loading = screen.getByTestId('prompt-config-page-loading'); + expect(loading).toBeInTheDocument(); + }); + + it('shows null when there is no prompt config available', async () => { + const promptConfig = PromptConfigFactory.buildSync(); + handleRetrievePromptConfigsSpy.mockResolvedValueOnce([]); + + render( + , + ); + + const loading = screen.getByTestId('prompt-config-page-loading'); + expect(loading).toBeInTheDocument(); + + await waitFor(() => { + const loading = screen.queryByTestId('prompt-config-page-loading'); + expect(loading).not.toBeInTheDocument(); + }); + + const promptPage = screen.queryByTestId('prompt-page'); + expect(promptPage).not.toBeInTheDocument(); + }); + + it('shows error when unable to fetch prompt config', () => { + const promptConfig = PromptConfigFactory.buildSync(); + handleRetrievePromptConfigsSpy.mockImplementationOnce(() => { + throw new ApiError('unable to fetch prompt configs', { + statusCode: 401, + statusText: 'Bad Request', + }); + }); + + render( + , + ); + + const errorToast = screen.getByText('unable to fetch prompt configs'); + expect(errorToast.className).toContain(ToastType.ERROR); + }); + + it('does not make the prompt config API call when prompt config is already in store', () => { + vi.resetAllMocks(); + const promptConfig = PromptConfigFactory.buildSync(); + + const { + result: { current: setPromptConfigs }, + } = renderHook(useSetPromptConfigs); + setPromptConfigs(applicationId, [promptConfig]); + + render( + , + ); + + const loading = screen.queryByTestId('prompt-config-page-loading'); + expect(loading).not.toBeInTheDocument(); + + expect(handleRetrievePromptConfigsSpy).not.toBeCalled(); + }); +}); diff --git a/frontend/tests/app/projects/[projectId]/application/[applicationId]/prompts/[promptId]/prompt-general-info.spec.tsx b/frontend/tests/app/projects/[projectId]/application/[applicationId]/prompts/[promptId]/prompt-general-info.spec.tsx new file mode 100644 index 00000000..51e928cf --- /dev/null +++ b/frontend/tests/app/projects/[projectId]/application/[applicationId]/prompts/[promptId]/prompt-general-info.spec.tsx @@ -0,0 +1,136 @@ +import { fireEvent, waitFor } from '@testing-library/react'; +import { ApplicationFactory, PromptConfigFactory } from 'tests/factories'; +import { routerPushMock } from 'tests/mocks'; +import { render, renderHook, screen } from 'tests/test-utils'; +import { expect } from 'vitest'; + +import * as PromptConfigAPI from '@/api/prompt-config-api'; +import { PromptGeneralInfo } from '@/components/projects/[projectId]/applications/[applicationId]/prompts/[promptId]/prompt-general-info'; +import { ApiError } from '@/errors'; +import { + usePromptConfig, + useSetProjectApplications, + useSetPromptConfigs, +} from '@/stores/project-store'; +import { ToastType } from '@/stores/toast-store'; + +describe('PromptGeneralInfo', () => { + const handleCreatePromptConfigSpy = vi.spyOn( + PromptConfigAPI, + 'handleCreatePromptConfig', + ); + + const projectId = '1'; + const application = ApplicationFactory.buildSync(); + + const { + result: { current: setProjectApplications }, + } = renderHook(useSetProjectApplications); + setProjectApplications(projectId, [application]); + + const promptConfig = PromptConfigFactory.buildSync(); + const { + result: { current: setPromptConfigs }, + } = renderHook(useSetPromptConfigs); + setPromptConfigs(application.id, [promptConfig]); + + it('renders prompt settings', () => { + render( + , + ); + + const promptName = screen.getByTestId('prompt-general-info-name'); + expect(promptName.innerHTML).toBe(promptConfig.name); + }); + + it('returns null when application not found', () => { + render( + , + ); + + const settingsContainer = screen.queryByTestId( + 'prompt-general-info-container', + ); + expect(settingsContainer).not.toBeInTheDocument(); + }); + + it('navigates to prompt testing screen when clicked on test button', () => { + render( + , + ); + + const testButton = screen.getByTestId('prompt-test-btn'); + fireEvent.click(testButton); + expect(routerPushMock).toHaveBeenCalledWith( + `/projects/${projectId}/applications/${application.id}/${promptConfig.id}/testing`, + ); + }); + + it('successfully clones a prompt and navigates to it', async () => { + render( + , + ); + + const clonedPromptConfig = PromptConfigFactory.buildSync(); + handleCreatePromptConfigSpy.mockResolvedValueOnce(clonedPromptConfig); + + const cloneButton = screen.getByTestId('prompt-clone-btn'); + fireEvent.click(cloneButton); + // This takes care of debounce line coverage + fireEvent.click(cloneButton); + + await waitFor(() => { + expect(handleCreatePromptConfigSpy).toHaveBeenCalledOnce(); + }); + + const { + result: { current: clonedPromptConfigInStore }, + } = renderHook(() => + usePromptConfig(application.id, clonedPromptConfig.id), + ); + expect(clonedPromptConfigInStore).toBe(clonedPromptConfig); + + expect(routerPushMock).toHaveBeenCalledWith( + `/projects/${projectId}/applications/${application.id}/prompts/${clonedPromptConfig.id}`, + ); + }); + + it('shows error when fails to clone a prompt', () => { + render( + , + ); + + handleCreatePromptConfigSpy.mockImplementationOnce(() => { + throw new ApiError('unable to clone prompt config', { + statusCode: 401, + statusText: 'Bad Request', + }); + }); + + const cloneButton = screen.getByTestId('prompt-clone-btn'); + fireEvent.click(cloneButton); + + const errorToast = screen.getByText('unable to clone prompt config'); + expect(errorToast.className).toContain(ToastType.ERROR); + }); +}); diff --git a/frontend/tests/stores/project-store.spec.ts b/frontend/tests/stores/project-store.spec.ts index 30d409a1..007e4e00 100644 --- a/frontend/tests/stores/project-store.spec.ts +++ b/frontend/tests/stores/project-store.spec.ts @@ -12,6 +12,7 @@ import { useAddApplication, useAddProject, useAddProjectUser, + useAddPromptConfig, useAPIKeys, useApplication, useApplications, @@ -20,14 +21,14 @@ import { useDeleteProject, useProject, useProjectUsers, - usePromptConfig, + usePromptConfigs, useRemoveProjectUser, useSetAPIKeys, useSetCurrentProject, useSetProjectApplications, useSetProjects, useSetProjectUsers, - useSetPromptConfig, + useSetPromptConfigs, useUpdateApplication, useUpdateProject, useUpdateProjectUser, @@ -330,23 +331,46 @@ describe('project-store tests', () => { }); }); - describe('getPromptConfig and setPromptConfig', () => { + describe('getPromptConfig, setPromptConfig and addPromptConfig', () => { it('sets and gets prompt config', async () => { const applicationId = '1'; const { result: { current: setPromptConfig }, - } = renderHook(useSetPromptConfig); + } = renderHook(useSetPromptConfigs); const promptConfigs = await PromptConfigFactory.batch(2); setPromptConfig(applicationId, promptConfigs); const { result: { current: getPromptConfig }, - } = renderHook(usePromptConfig); + } = renderHook(usePromptConfigs); const config = getPromptConfig[applicationId]; expect(config).toEqual(promptConfigs); }); + + it('adds a prompt config to existing configs', () => { + const applicationId = '1'; + const { + result: { current: setPromptConfig }, + } = renderHook(useSetPromptConfigs); + const promptConfigs = PromptConfigFactory.batchSync(2); + setPromptConfig(applicationId, promptConfigs); + + const { + result: { current: addPromptConfig }, + } = renderHook(useAddPromptConfig); + const newPromptConfig = PromptConfigFactory.buildSync(); + addPromptConfig(applicationId, newPromptConfig); + + const { + result: { current: getPromptConfig }, + } = renderHook(usePromptConfigs); + + const config = getPromptConfig[applicationId]; + + expect(config).toEqual([...promptConfigs, newPromptConfig]); + }); }); describe('getAPIKeys and setAPIKeys', () => { diff --git a/frontend/tests/utils/helpers.spec.ts b/frontend/tests/utils/helpers.spec.ts index 49148be9..035204bb 100644 --- a/frontend/tests/utils/helpers.spec.ts +++ b/frontend/tests/utils/helpers.spec.ts @@ -1,4 +1,4 @@ -import { copyToClipboard, handleChange } from '@/utils/helpers'; +import { copyToClipboard, getCloneName, handleChange } from '@/utils/helpers'; describe('handleChange tests', () => { it('return an event handler', () => { @@ -49,3 +49,29 @@ describe('copyToClipboard tests', () => { expect(navigator.clipboard.writeText).toHaveBeenCalledWith(text); }); }); + +describe('geCloneName tests', () => { + it('gets a clone name from a source name', () => { + const sourceName = 'basemind'; + const cloneName = getCloneName(sourceName); + + expect(cloneName).toContain(sourceName); + }); + + it('gets fairly unique clone names from source name', () => { + // This means that we're okay if the cloned names are unique 95% of the time + // In reality chances of collision are very very low + const sourceName = 'basemind'; + const cloneCount = 1000; + const passPercentage = 95; + const passCloneCount = cloneCount * (passPercentage / 100); + + const cloneNames = [...Array.from({ length: cloneCount }).keys()].map( + (_) => getCloneName(sourceName), + ); + + const cloneNameSet = new Set(cloneNames); + + expect(cloneNameSet.size).toBeGreaterThan(passCloneCount); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 563167f6..ba586c4f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -184,6 +184,9 @@ importers: swr: specifier: ^2.2.4 version: 2.2.4(react@18.2.0) + unique-names-generator: + specifier: ^4.7.1 + version: 4.7.1 zustand: specifier: ^4.4.6 version: 4.4.6(@types/react@18.2.37)(react@18.2.0) @@ -19226,6 +19229,14 @@ packages: engines: { node: '>=4' } dev: true + /unique-names-generator@4.7.1: + resolution: + { + integrity: sha512-lMx9dX+KRmG8sq6gulYYpKWZc9RlGsgBR6aoO8Qsm3qvkSJ+3rAymr+TnV8EDMrIrwuFJ4kruzMWM/OpYzPoow==, + } + engines: { node: '>=8' } + dev: false + /unique-string@2.0.0: resolution: {