Skip to content

Commit

Permalink
feat: rename service from header
Browse files Browse the repository at this point in the history
Signed-off-by: Jonathan Perchoc <[email protected]>
  • Loading branch information
jperchoc committed Dec 26, 2024
1 parent da641d0 commit c9be3e4
Show file tree
Hide file tree
Showing 5 changed files with 324 additions and 24 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { useTrackAction } from '@/hooks/useTracking';
import { TRACKING } from '@/configuration/tracking.constants';
import { getCdbApiErrorMessage } from '@/lib/apiHelper';
import RouteModal from '@/components/route-modal/RouteModal';
import { useRenameServiceForm } from './useRenameServiceForm';

interface RenameServiceProps {
service: database.Service;
Expand All @@ -42,6 +43,7 @@ const RenameService = ({ service, onError, onSuccess }: RenameServiceProps) => {
const track = useTrackAction();
const { t } = useTranslation('pci-databases-analytics/services/service');
const toast = useToast();
const form = useRenameServiceForm(service);
const { editService, isPending } = useEditService({
onError: (err) => {
toast.toast({
Expand All @@ -65,29 +67,7 @@ const RenameService = ({ service, onError, onSuccess }: RenameServiceProps) => {
}
},
});
// define the schema for the form
const schema = z.object({
description: z
.string()
.min(3, {
message: t('renameServiceErrorMinLength', { min: 3 }),
})
.max(30, {
message: t('renameServiceErrorMaxLength', { max: 30 }),
}),
});
// generate a form controller
const form = useForm<z.infer<typeof schema>>({
resolver: zodResolver(schema),
defaultValues: {
description: '',
},
});
// fill form with service values
useEffect(() => {
if (!service) return;
form.setValue('description', service.description);
}, [service, form]);


const onSubmit = form.handleSubmit((formValues) => {
track(TRACKING.renameService.confirm(service.engine));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import ServiceStatusBadge from '../../_components/ServiceStatusBadge.component';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { humanizeEngine } from '@/lib/engineNameHelper';
import ServiceNameWithUpdate from './ServiceNameWithUpdate.component';

export const ServiceHeader = ({ service }: { service: database.Service }) => {
const { t } = useTranslation('regions');
Expand All @@ -17,7 +18,7 @@ export const ServiceHeader = ({ service }: { service: database.Service }) => {
<Database width={40} height={40} />
</div>
<div>
<h2>{service.description ?? 'Dashboard'}</h2>
<ServiceNameWithUpdate service={service} />
<div className="flex gap-2 flex-wrap">
<ServiceStatusBadge status={service.status} />
<Badge variant={'outline'}>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { useParams } from 'react-router-dom';
import { useEffect, useState } from 'react';
import { Check, Pen, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Skeleton } from '@/components/ui/skeleton';
import * as database from '@/types/cloud/project/database';
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from '@/components/ui/form';
import { Button } from '@/components/ui/button';
import { useRenameServiceForm } from './useRenameServiceForm';
import { Input } from '@/components/ui/input';
import { useEditService } from '@/hooks/api/database/service/useEditService.hook';
import { useToast } from '@/components/ui/use-toast';
import { getCdbApiErrorMessage } from '@/lib/apiHelper';

const ServiceNameWithUpdate = ({ service }: { service: database.Service }) => {
const { t } = useTranslation('pci-databases-analytics/services/service');
const [isEditing, setIsEditing] = useState(false);
const { projectId } = useParams();
const form = useRenameServiceForm(service);
const toast = useToast();
const { editService } = useEditService({
onError: (err) => {
toast.toast({
title: t('renameServiceToastErrorTitle'),
variant: 'destructive',
description: getCdbApiErrorMessage(err),
});
},
onEditSuccess: (renamedService) => {
toast.toast({
title: t('renameServiceToastSuccessTitle'),
description: t('renameServiceToastSuccessDescription', {
newName: renamedService.description,
}),
});
setIsEditing(false);
},
});
const onSubmit = form.handleSubmit((formValues) => {
editService({
serviceId: service.id,
projectId,
engine: service.engine,
data: {
description: formValues.description,
},
});
});

useEffect(() => {
form.reset();
form.setValue('description', service.description);
}, [isEditing]);

if (!service) {
return <Skeleton className="h-4 w-36" />;
}
if (isEditing) {
return (
<Form {...form}>
<form onSubmit={onSubmit} className="flex gap-2">
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
className="mb-[5px]"
data-testid="rename-service-input"
placeholder="name"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex gap-2 justify-end">
<Button
type="button"
onClick={() => setIsEditing(false)}
variant="ghost"
size="table"
className="py-0 h-auto"
data-testid="cancel-button"
>
<X />
</Button>
<Button
type="submit"
variant="ghost"
size="table"
className="py-0 h-auto"
data-testid="validate-button"
>
<Check />
</Button>
</div>
</form>
</Form>
);
}
return (
<div className="flex gap-2">
<h2>{service.description}</h2>
<Button
onClick={() => setIsEditing(true)}
variant="ghost"
size="table"
className="py-0 h-auto"
data-testid="edit-button"
>
<Pen />
</Button>
</div>
);
};

export default ServiceNameWithUpdate;
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
render,
screen,
waitFor,
fireEvent,
act,
} from '@testing-library/react';
import { RouterWithQueryClientWrapper } from '@/__tests__/helpers/wrappers/RouterWithQueryClientWrapper';
import ServiceNameWithUpdate from './ServiceNameWithUpdate.component';
import { useToast } from '@/components/ui/use-toast';
import { mockedService } from '@/__tests__/helpers/mocks/services';
import { apiErrorMock } from '@/__tests__/helpers/mocks/cdbError';
import * as serviceApi from '@/data/api/database/service.api';

describe('ServiceNameWithUpdate', () => {
beforeEach(() => {
vi.mock('react-router-dom', async () => {
const mod = await vi.importActual('react-router-dom');
return {
...mod,
useParams: () => ({
projectId: 'projectId',
}),
};
});

vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));

vi.mock('@/components/ui/use-toast', () => {
const toastMock = vi.fn();
return {
useToast: vi.fn(() => ({
toast: toastMock,
})),
};
});

vi.mock('@/data/api/database/service.api', () => ({
editService: vi.fn((s) => s),
}));
});

afterEach(() => {
vi.clearAllMocks();
});

it('should display the service name', () => {
render(<ServiceNameWithUpdate service={mockedService} />, {
wrapper: RouterWithQueryClientWrapper,
});

expect(screen.getByText(mockedService.description)).toBeInTheDocument();
});

it('should switch to edit mode on clicking the edit button', () => {
render(<ServiceNameWithUpdate service={mockedService} />, {
wrapper: RouterWithQueryClientWrapper,
});

act(() => {
fireEvent.click(screen.getByTestId('edit-button'));
});

expect(screen.getByTestId('rename-service-input')).toBeInTheDocument();
});

it('should submit the new name and show success toast', async () => {
render(<ServiceNameWithUpdate service={mockedService} />, {
wrapper: RouterWithQueryClientWrapper,
});

act(() => {
fireEvent.click(screen.getByTestId('edit-button'));
});

act(() => {
fireEvent.change(screen.getByTestId('rename-service-input'), {
target: { value: 'NewServiceName' },
});
});

act(() => {
fireEvent.click(screen.getByTestId('validate-button'));
});

await waitFor(() => {
expect(serviceApi.editService).toHaveBeenCalledWith({
serviceId: mockedService.id,
projectId: 'projectId',
engine: mockedService.engine,
data: {
description: 'NewServiceName',
},
});
expect(useToast().toast).toHaveBeenCalledWith({
title: 'renameServiceToastSuccessTitle',
description: 'renameServiceToastSuccessDescription',
});
});
});

it('should show error toast on API failure', async () => {
vi.mocked(serviceApi.editService).mockImplementation(() => {
throw apiErrorMock;
});

render(<ServiceNameWithUpdate service={mockedService} />, {
wrapper: RouterWithQueryClientWrapper,
});

act(() => {
fireEvent.click(screen.getByTestId('edit-button'));
});

act(() => {
fireEvent.change(screen.getByTestId('rename-service-input'), {
target: { value: 'NewServiceName' },
});
});

act(() => {
fireEvent.click(screen.getByTestId('validate-button'));
});

await waitFor(() => {
expect(serviceApi.editService).toHaveBeenCalled();
expect(useToast().toast).toHaveBeenCalledWith({
title: 'renameServiceToastErrorTitle',
description: apiErrorMock.response.data.message,
variant: 'destructive',
});
});
});

it('should exit edit mode on cancel button click', () => {
render(<ServiceNameWithUpdate service={mockedService} />, {
wrapper: RouterWithQueryClientWrapper,
});

act(() => {
fireEvent.click(screen.getByTestId('edit-button'));
});

expect(screen.getByTestId('rename-service-input')).toBeInTheDocument();

act(() => {
fireEvent.click(screen.getByTestId('cancel-button'));
});

expect(
screen.queryByTestId('rename-service-input'),
).not.toBeInTheDocument();
expect(screen.getByText(mockedService.description)).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { z } from 'zod';
import * as database from '@/types/cloud/project/database';

export function useRenameServiceForm(service: database.Service) {
const { t } = useTranslation('pci-databases-analytics/services/service');
const schema = z.object({
description: z
.string()
.min(3, {
message: t('renameServiceErrorMinLength', { min: 3 }),
})
.max(30, {
message: t('renameServiceErrorMaxLength', { max: 30 }),
}),
});
// generate a form controller
const form = useForm<z.infer<typeof schema>>({
resolver: zodResolver(schema),
defaultValues: {
description: '',
},
});
// fill form with service values
useEffect(() => {
if (!service) return;
form.setValue('description', service.description);
}, [service, form]);
return form;
}

0 comments on commit c9be3e4

Please sign in to comment.