Adds frontend for cms page

This commit is contained in:
2026-06-30 23:23:50 +02:00
parent c156107cb1
commit 488ab821a7
38 changed files with 2047 additions and 39 deletions
+29
View File
@@ -82,6 +82,35 @@ export interface ChangeRolePayload {
export type AvailabilityStatus = 'Available' | 'Maintenance' | 'NotAvailable';
export type CmsInstanceStatus = 'Available' | 'NotAvailable' | 'Inactive';
export interface CmsInstance {
id: string;
name: string;
url: string;
status: CmsInstanceStatus;
disableMessage: string | null;
lastContactedAt: string | null;
lastStatusPushedAt: string | null;
lastIntegrityCheckFailedAt: string | null;
}
export interface CreateCmsInstanceRequest {
name: string;
url: string;
apiKey: string;
}
export interface UpdateCmsInstanceStatusRequest {
status: CmsInstanceStatus;
disableMessage: string | null;
}
export interface UpdateStatusResult {
success: boolean;
slaveContactSuccess: boolean;
}
export interface AvailabilityResponse {
status: AvailabilityStatus;
checkedAt: string; // ISO 8601
@@ -0,0 +1,54 @@
import { renderHook, waitFor, act } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { http, HttpResponse } from 'msw';
import { createElement } from 'react';
import { describe, expect, it, vi } from 'vitest';
import { server } from '@/mocks/server';
import { API_BASE } from '@/mocks/auth/fixtures';
import { useAddCmsInstance } from './useAddCmsInstance';
function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return ({ children }: { children: React.ReactNode }) =>
createElement(QueryClientProvider, { client: queryClient }, children);
}
const CMS_URL = `${API_BASE}/api/v1/CmsInstances`;
describe('useAddCmsInstance', () => {
it('returns created instance on success', async () => {
const { result } = renderHook(() => useAddCmsInstance(), { wrapper: createWrapper() });
await act(async () => {
await result.current.mutateAsync({
name: 'Test CMS',
url: 'http://test.example.com',
apiKey: 'secret',
});
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data?.name).toBe('Test CMS');
});
it('calls onError callback on HTTP 400', async () => {
server.use(http.post(CMS_URL, () => HttpResponse.json({}, { status: 400 })));
const onError = vi.fn();
const { result } = renderHook(() => useAddCmsInstance({ onError }), {
wrapper: createWrapper(),
});
await act(async () => {
try {
await result.current.mutateAsync({ name: 'X', url: 'http://x.com', apiKey: 'k' });
} catch {
// expected
}
});
await waitFor(() => expect(onError).toHaveBeenCalled());
});
});
+14
View File
@@ -0,0 +1,14 @@
import { useMutation, useQueryClient, type UseMutationOptions } from '@tanstack/react-query';
import { api } from '@/lib/api-client';
import type { CmsInstance, CreateCmsInstanceRequest } from './types';
export function useAddCmsInstance(
options?: Pick<UseMutationOptions<CmsInstance, Error, CreateCmsInstanceRequest>, 'onError'>,
) {
const queryClient = useQueryClient();
return useMutation<CmsInstance, Error, CreateCmsInstanceRequest>({
mutationFn: (data) => api.post<CmsInstance>('/api/v1/CmsInstances', data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['cmsInstances'] }),
...options,
});
}
+44
View File
@@ -0,0 +1,44 @@
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { http, HttpResponse } from 'msw';
import { createElement } from 'react';
import { describe, expect, it } from 'vitest';
import { server } from '@/mocks/server';
import { API_BASE } from '@/mocks/auth/fixtures';
import { getMockCmsInstances } from '@/mocks/cms/handlers';
import { useCmsInstances } from './useCmsInstances';
function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return ({ children }: { children: React.ReactNode }) =>
createElement(QueryClientProvider, { client: queryClient }, children);
}
const CMS_URL = `${API_BASE}/api/v1/CmsInstances`;
describe('useCmsInstances', () => {
it('starts in loading state', () => {
const { result } = renderHook(() => useCmsInstances(), { wrapper: createWrapper() });
expect(result.current.isPending).toBe(true);
});
it('returns the instances list on success', async () => {
const { result } = renderHook(() => useCmsInstances(), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.isPending).toBe(false));
expect(result.current.isError).toBe(false);
expect(result.current.data).toHaveLength(getMockCmsInstances().length);
expect(result.current.data?.[0].name).toBe(getMockCmsInstances()[0].name);
});
it('enters error state on network error', async () => {
server.use(http.get(CMS_URL, () => HttpResponse.json({}, { status: 500 })));
const { result } = renderHook(() => useCmsInstances(), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.isPending).toBe(false));
expect(result.current.isError).toBe(true);
});
});
+11
View File
@@ -0,0 +1,11 @@
import { useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api-client';
import type { CmsInstance } from './types';
export function useCmsInstances() {
return useQuery<CmsInstance[], Error>({
queryKey: ['cmsInstances'],
queryFn: () => api.get<CmsInstance[]>('/api/v1/CmsInstances'),
staleTime: 30_000,
});
}
@@ -0,0 +1,64 @@
import { renderHook, waitFor, act } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { http, HttpResponse } from 'msw';
import { createElement } from 'react';
import { describe, expect, it, vi } from 'vitest';
import { server } from '@/mocks/server';
import { API_BASE } from '@/mocks/auth/fixtures';
import { useUpdateCmsInstanceStatus } from './useUpdateCmsInstanceStatus';
function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return ({ children }: { children: React.ReactNode }) =>
createElement(QueryClientProvider, { client: queryClient }, children);
}
const INSTANCE_ID = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa';
const STATUS_URL = `${API_BASE}/api/v1/CmsInstances/${INSTANCE_ID}/status`;
describe('useUpdateCmsInstanceStatus', () => {
it('returns UpdateStatusResult on success', async () => {
const onSuccess = vi.fn();
const { result } = renderHook(() => useUpdateCmsInstanceStatus({ onSuccess }), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.mutateAsync({
id: INSTANCE_ID,
status: 'NotAvailable',
disableMessage: 'Under maintenance',
});
});
await waitFor(() => expect(onSuccess).toHaveBeenCalled());
const callArg = onSuccess.mock.calls[0][0];
expect(callArg.success).toBe(true);
expect(callArg.slaveContactSuccess).toBe(true);
});
it('calls onError on network failure', async () => {
server.use(http.put(STATUS_URL, () => HttpResponse.json({}, { status: 500 })));
const onError = vi.fn();
const { result } = renderHook(() => useUpdateCmsInstanceStatus({ onError }), {
wrapper: createWrapper(),
});
await act(async () => {
try {
await result.current.mutateAsync({
id: INSTANCE_ID,
status: 'Available',
disableMessage: null,
});
} catch {
// expected
}
});
await waitFor(() => expect(onError).toHaveBeenCalled());
});
});
@@ -0,0 +1,20 @@
import { useMutation, useQueryClient, type UseMutationOptions } from '@tanstack/react-query';
import { api } from '@/lib/api-client';
import type { UpdateCmsInstanceStatusRequest, UpdateStatusResult } from './types';
type UpdateVariables = { id: string } & UpdateCmsInstanceStatusRequest;
export function useUpdateCmsInstanceStatus(
options?: Pick<UseMutationOptions<UpdateStatusResult, Error, UpdateVariables>, 'onSuccess' | 'onError'>,
) {
const queryClient = useQueryClient();
return useMutation<UpdateStatusResult, Error, UpdateVariables>({
mutationFn: ({ id, ...body }) =>
api.put<UpdateStatusResult>(`/api/v1/CmsInstances/${id}/status`, body),
onSuccess: (data, variables, context) => {
queryClient.invalidateQueries({ queryKey: ['cmsInstances'] });
options?.onSuccess?.(data, variables, context);
},
onError: options?.onError,
});
}
+5 -2
View File
@@ -1,4 +1,4 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useQuery, useMutation, useQueryClient, type UseMutationOptions } from '@tanstack/react-query';
import { api } from '@/lib/api-client';
import type { UserListItem, InviteUserPayload, InviteUserResponse, ChangeRolePayload, UserRole } from './types';
@@ -10,11 +10,14 @@ export function useUsers() {
});
}
export function useInviteUser() {
export function useInviteUser(
options?: Pick<UseMutationOptions<InviteUserResponse, Error, InviteUserPayload>, 'onError'>,
) {
const queryClient = useQueryClient();
return useMutation<InviteUserResponse, Error, InviteUserPayload>({
mutationFn: (data) => api.post<InviteUserResponse>('/api/v1/Users/invite', data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['users'] }),
...options,
});
}